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 chromiumLoading 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— usebrowser.browserActionfor MV2 extensions - Firefox has better promise-based API support (no callback required)
- Extension IDs in Firefox use
@extension-nameformat, 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-extensionflags, service worker access viacontext.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 viachrome.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.