Testing Browser Extensions with Playwright (Chromium and Firefox)

Testing Browser Extensions with Playwright (Chromium and Firefox)

Playwright supports browser extension testing in Chromium (full support) and Firefox (experimental). Use chromium.launchPersistentContext() with --load-extension and --disable-extensions-except flags. For Firefox, launch with firefox.launchPersistentContext() and set firefox_profile preferences. Access the extension popup by navigating directly to its chrome-extension:// URL.

Key Takeaways

Use launchPersistentContext, not browser.newPage. Extensions require a persistent browser profile to load properly. chromium.launchPersistentContext() is the entry point for extension testing in Playwright.

Disable headless mode for Chromium extensions. Chrome restricts extension loading in headless mode. Use headless: false or the new --headless=new flag (Chrome 112+).

Firefox extension testing is experimental. Playwright's Firefox support for WebExtensions works but has fewer API guarantees than Chromium. Test critical paths in both.

Service workers replace background pages in Manifest V3. Use context.serviceWorkers() to access the extension's service worker for MV3 extensions instead of backgroundPages().

Use page.waitForSelector with a timeout. Extension content scripts inject asynchronously. Always wait for your injected elements rather than assuming they're present immediately.

Playwright's multi-browser support makes it ideal for testing WebExtensions across Chromium, Firefox, and eventually Safari. Unlike Puppeteer (Chromium-only), Playwright gives you the full browser matrix with a single API. This guide covers how to set up extension testing in both Chromium and Firefox, and how to test popups, content scripts, and background workers.

Playwright Extension Support Matrix

Feature Chromium Firefox WebKit
Load unpacked extension ✅ (experimental)
Popup testing
Content script testing
Background pages (MV2)
Service workers (MV3) Partial

WebKit (Safari) does not support extension loading via Playwright. For Safari extension testing, you need Xcode and manual testing.

Chromium Setup

Installing Playwright

npm install --save-dev @playwright/test
npx playwright install chromium

Loading the Extension

Chromium extension testing requires a persistent context instead of the standard browser.launch() flow:

// tests/fixtures/extensionFixture.js
const { chromium } = require('@playwright/test');
const path = require('path');

async function createExtensionContext() {
  const extensionPath = path.resolve(__dirname, '../../dist');

  const context = await chromium.launchPersistentContext('', {
    headless: false,
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`,
    ],
  });

  // Wait for the service worker (MV3) or background page (MV2)
  let extensionId;

  // For MV3: get service worker URL
  const [worker] = context.serviceWorkers();
  if (worker) {
    extensionId = new URL(worker.url()).hostname;
  } else {
    // For MV2: get background page URL
    await context.waitForEvent('backgroundpage');
    const backgroundPage = context.backgroundPages()[0];
    extensionId = new URL(backgroundPage.url()).hostname;
  }

  return { context, extensionId };
}

module.exports = { createExtensionContext };

Creating a Playwright Fixture

Using Playwright's fixture system keeps setup/teardown clean:

// tests/fixtures/index.js
const { test: base } = require('@playwright/test');
const { createExtensionContext } = require('./extensionFixture');

const test = base.extend({
  context: async ({}, use) => {
    const { context, extensionId } = await createExtensionContext();
    await use({ context, extensionId });
    await context.close();
  },

  extensionId: async ({ context }, use) => {
    await use(context.extensionId);
  },

  popupPage: async ({ context }, use) => {
    const { context: ctx, extensionId } = context;
    const page = await ctx.newPage();
    await page.goto(`chrome-extension://${extensionId}/popup.html`);
    await use(page);
    await page.close();
  },
});

module.exports = { test, expect: base.expect };

Testing the Popup

// tests/popup.spec.js
const { test, expect } = require('./fixtures');

test('popup renders the main toggle', async ({ popupPage }) => {
  await expect(popupPage.locator('#enable-toggle')).toBeVisible();
});

test('toggle is on by default', async ({ popupPage }) => {
  const toggle = popupPage.locator('#enable-toggle');
  await expect(toggle).toHaveAttribute('aria-checked', 'true');
});

test('clicking toggle disables the extension', async ({ popupPage }) => {
  await popupPage.click('#enable-toggle');

  // Verify storage updated
  const enabled = await popupPage.evaluate(
    () => new Promise(resolve =>
      chrome.storage.local.get('enabled', d => resolve(d.enabled))
    )
  );
  expect(enabled).toBe(false);
});

test('popup shows correct URL count', async ({ context, popupPage }) => {
  const { context: ctx } = context;

  // Open several pages
  const page1 = await ctx.newPage();
  const page2 = await ctx.newPage();
  await page1.goto('https://example.com');
  await page2.goto('https://example.org');

  // Reload popup to refresh count
  await popupPage.reload();
  await expect(popupPage.locator('#tab-count')).toHaveText('2 tabs');

  await page1.close();
  await page2.close();
});

Testing Content Scripts

Content scripts inject into pages matching the matches patterns in manifest.json. Test them by navigating to a matching URL:

// tests/contentScript.spec.js
const { test, expect } = require('./fixtures');

test('content script injects overlay on matching pages', async ({ context }) => {
  const { context: ctx } = context;
  const page = await ctx.newPage();

  await page.goto('https://example.com');

  // Wait for content script to inject
  await page.waitForSelector('#my-extension-toolbar', { timeout: 5000 });
  await expect(page.locator('#my-extension-toolbar')).toBeVisible();

  await page.close();
});

test('content script highlights keyword matches', async ({ context }) => {
  const { context: ctx } = context;
  const page = await ctx.newPage();

  // Navigate to a page with known content
  await page.goto('https://en.wikipedia.org/wiki/Playwright');

  // Set keyword via storage before navigating
  await page.evaluate(() =>
    new Promise(resolve =>
      chrome.storage.local.set({ keywords: ['theater'] }, resolve)
    )
  );

  await page.reload();
  await page.waitForSelector('.my-ext-highlight');

  const highlights = await page.locator('.my-ext-highlight').count();
  expect(highlights).toBeGreaterThan(0);

  await page.close();
});

Testing Background Service Workers (MV3)

Service workers in Manifest V3 replace background pages. Access them through the context:

// tests/serviceWorker.spec.js
const { test, expect } = require('./fixtures');

test('service worker responds to messages', async ({ context }) => {
  const { context: ctx } = context;

  // Get the service worker
  const [serviceWorker] = ctx.serviceWorkers();

  // Send a message to the service worker
  const page = await ctx.newPage();
  await page.goto('about:blank');

  const response = await page.evaluate(() =>
    new Promise(resolve =>
      chrome.runtime.sendMessage({ type: 'GET_VERSION' }, resolve)
    )
  );

  expect(response.version).toBeDefined();
  await page.close();
});

test('service worker handles tab events', async ({ context }) => {
  const { context: ctx, extensionId } = context;

  const [serviceWorker] = ctx.serviceWorkers();

  // Open a page and verify the worker tracks it
  const page = await ctx.newPage();
  await page.goto('https://example.com');

  // Check via popup that tab count increased
  const popupPage = await ctx.newPage();
  await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

  await expect(popupPage.locator('#tab-count')).not.toHaveText('0 tabs');

  await page.close();
  await popupPage.close();
});

Firefox Extension Testing

Firefox extension testing in Playwright uses launchPersistentContext with a Firefox-specific profile:

// tests/firefox/setup.js
const { firefox } = require('@playwright/test');
const path = require('path');

async function createFirefoxExtensionContext() {
  const extensionPath = path.resolve(__dirname, '../../dist-firefox');

  const context = await firefox.launchPersistentContext('', {
    headless: false,
    firefoxUserPrefs: {
      // Allow unsigned extensions in Firefox
      'extensions.autoDisableScopes': 0,
      'extensions.enabledScopes': 15,
    },
  });

  // Load the extension via the Firefox addon API
  const page = await context.newPage();
  await page.goto('about:debugging#/runtime/this-firefox');
  await page.click('text=Load Temporary Add-on...');

  // Use file chooser to load manifest.json
  const [fileChooser] = await Promise.all([
    page.waitForEvent('filechooser'),
    page.click('text=Load Temporary Add-on...'),
  ]);
  await fileChooser.setFiles(path.join(extensionPath, 'manifest.json'));
  await page.close();

  return { context };
}

module.exports = { createFirefoxExtensionContext };

Firefox-Specific Considerations

Firefox uses browser.* instead of chrome.* in some contexts, though both work for WebExtension APIs. Key differences:

  • Firefox doesn't support chrome.action — use browser.browserAction for MV2 extensions
  • Firefox has better promise-based API support (no callback required)
  • Extension IDs in Firefox use @extension-name format, not a hash
// Cross-browser message handling
const browserAPI = typeof chrome !== 'undefined' ? chrome : browser;

browserAPI.runtime.sendMessage({ type: 'PING' }, response => {
  console.log(response);
});

Running Tests in CI

GitHub Actions

name: Extension Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium firefox

      - name: Build extension
        run: npm run build

      - name: Run extension tests
        run: npx playwright test --project=chromium
        env:
          DISPLAY: ':99'  # Virtual display for headful mode

      - name: Upload test results
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: test-results/

Debugging Tips

Use page.pause() for interactive debugging. Add await page.pause() in your test to open Playwright Inspector and step through actions manually.

Record a trace. Playwright traces capture screenshots, network requests, and console logs:

await context.tracing.start({ screenshots: true, snapshots: true });
// ... run tests ...
await context.tracing.stop({ path: 'trace.zip' });

Check the service worker console. For background script errors, create a page and navigate to the background page's URL to access DevTools.

Continuous Monitoring

Testing during development catches bugs before release, but production extensions on live sites need continuous monitoring. HelpMeTest can run scheduled tests against your extension on real pages — write the scenario once and get alerted when it breaks. At $100/month flat, it's cheaper than a single production incident.

Summary

Playwright browser extension testing:

  • Chromium: launchPersistentContext + --load-extension flags, service worker access via context.serviceWorkers()
  • Firefox: launchPersistentContext + Firefox user preferences for unsigned extension loading
  • Popup: navigate directly to chrome-extension://{id}/popup.html
  • Content scripts: navigate to matching pages, wait for selectors
  • Service workers: access via context.serviceWorkers() or communicate via chrome.runtime.sendMessage

Use fixtures to keep setup/teardown DRY across test files, and run both Chromium and Firefox in CI for real cross-browser coverage.

Read more