End-to-End Testing Automation for MV3 Extensions
Automate MV3 extension testing with Playwright and Puppeteer: load unpacked extensions, control service workers, drive popup and options pages, and run headless-incompatible tests in CI.
Automate MV3 extension testing with Playwright and Puppeteer: load unpacked extensions, control service workers, drive popup and options pages, and run headless-incompatible tests in CI.
The biggest mistake in MV3 extension testing is applying generic web-app E2E patterns unchanged. Playwright’s default browser launch does not load extensions at all — no --load-extension flag means no service worker, no content scripts, no chrome.* APIs, and no extension pages to navigate to. By the time most teams discover this, they have already written a test suite that passes against a plain tab and catches nothing real. This guide is part of the Testing, Debugging & Performance Optimization section and covers the full E2E layer: the exact launch configuration, service worker retrieval, popup and options driving, and GitHub Actions CI wiring that makes extension automation reliable.
Before writing a single test, confirm these are in place:
serviceWorkers() support and waitForEvent('serviceworker') was unreliable.--headless=new mode as of Chrome 112. Every test must run with headless: false and a virtual display in CI.userDataDir chosen carefully — launchPersistentContext requires a non-temporary directory if you need stable extension IDs across test runs. Use a fixed path inside your project’s temp folder and clean it before each run.page.goto('chrome-extension://...') before the ID is known — the extension ID is not predictable without a fixed key in the manifest. Retrieve it dynamically from the service worker URL.The hard constraint is launchPersistentContext, not launch. The standard browser.newPage() flow does not load extensions regardless of what flags you pass to launch(). A persistent context maps to a real Chrome profile directory, which is the only channel Chrome uses to associate extensions with a browsing session.
1// tests/helpers/extension.ts
2import { chromium, BrowserContext } from '@playwright/test';
3import path from 'path';
4
5const EXTENSION_PATH = path.resolve(__dirname, '../../dist');
6const USER_DATA_DIR = path.resolve(__dirname, '../../.tmp/chrome-profile');
7
8export async function launchExtension(): Promise<BrowserContext> {
9 return chromium.launchPersistentContext(USER_DATA_DIR, {
10 headless: false, // extensions are blocked in headless mode
11 args: [
12 `--disable-extensions-except=${EXTENSION_PATH}`,
13 `--load-extension=${EXTENSION_PATH}`,
14 '--no-sandbox', // required in Docker/GitHub Actions
15 '--disable-dev-shm-usage', // avoids /dev/shm exhaustion in CI
16 ],
17 });
18}
Execution context: Node.js test process. The EXTENSION_PATH must point to the built output directory containing manifest.json, not the source root. Both --disable-extensions-except and --load-extension are required together — one without the other may silently fail to load the extension in some Chrome versions.*
The service worker is a separate browsing context — it is not a Page — and its URL encodes the extension ID you need to navigate to extension pages. Two approaches exist: polling context.serviceWorkers() for an already-registered worker, or using waitForEvent('serviceworker') to catch the first registration.
1// tests/helpers/extension.ts (continued)
2import { Worker } from '@playwright/test';
3
4export async function getServiceWorker(context: BrowserContext): Promise<Worker> {
5 // The service worker may already be registered by the time this runs
6 const existing = context.serviceWorkers();
7 if (existing.length > 0) return existing[0];
8
9 // Otherwise wait for the registration event (fires within ~500 ms of launch)
10 return context.waitForEvent('serviceworker');
11}
12
13export function getExtensionId(worker: Worker): string {
14 // Service worker URL format: chrome-extension://<id>/background.js
15 const url = new URL(worker.url());
16 return url.hostname; // the extension ID
17}
Execution context: Node.js test process. context.serviceWorkers() and waitForEvent('serviceworker') are Playwright-side APIs that observe Chrome’s service worker registration lifecycle. The Worker object returned is a Playwright abstraction — use worker.evaluate(fn) to run code inside the actual service worker context. Firefox WebExtensions expose service workers via the same Playwright API, though the URL scheme is moz-extension://.*
See the detailed recipe in loading an unpacked extension in Playwright for edge cases including worker restart after idle eviction and handling multiple installed extensions.
With the extension ID known, open extension pages as regular pages. The popup HTML and options page are standard web pages running in a privileged extension context — they respond to all standard Playwright page interactions.
1// tests/e2e/popup.test.ts
2import { test, expect } from '@playwright/test';
3import { launchExtension, getServiceWorker, getExtensionId } from '../helpers/extension';
4
5test.describe('Popup', () => {
6 test('displays saved setting after service worker update', async () => {
7 const context = await launchExtension();
8 const worker = await getServiceWorker(context);
9 const extId = getExtensionId(worker);
10
11 // Write state via the service worker before opening the popup
12 await worker.evaluate(async () => {
13 await chrome.storage.local.set({ theme: 'dark' });
14 });
15
16 const popup = await context.newPage();
17 await popup.goto(`chrome-extension://${extId}/popup.html`);
18
19 await expect(popup.locator('[data-testid="theme-label"]')).toHaveText('dark');
20 await context.close();
21 });
22});
Execution context: The popup variable is a Playwright Page running in the extension’s privileged browsing context — it has access to chrome.* APIs and shares the same storage as the service worker. Do not use page.route() to intercept chrome-extension:// URLs; those requests bypass the Fetch handler entirely.*
Content script behavior is tested by navigating a real page and asserting DOM mutations that the content script produces. The tricky constraint is that content scripts injected at document_start may run before Playwright has attached its own evaluation context — always wait for a known DOM signal rather than a fixed delay.
1// tests/e2e/content-script.test.ts
2import { test, expect } from '@playwright/test';
3import { launchExtension } from '../helpers/extension';
4
5test('content script injects the overlay banner', async () => {
6 const context = await launchExtension();
7 const page = await context.newPage();
8
9 await page.goto('https://example.com');
10
11 // Wait for the element the content script creates — never use waitForTimeout
12 const banner = page.locator('#my-extension-banner');
13 await expect(banner).toBeVisible({ timeout: 5_000 });
14 await expect(banner).toContainText('Extension active');
15
16 await context.close();
17});
Execution context: The page here is a normal web page. The content script runs in its isolated world alongside it. Playwright observes the shared DOM, so mutations from the content script are visible via standard locators. Firefox and Safari both support this pattern; on Firefox you may need to allow the extension to run in private windows via browser.extension.isAllowedIncognitoAccess() if your test context uses incognito.*
--headless=new. All E2E runs require a headed Chromium and a virtual display in CI."key" field to manifest.json from a generated CRX key.context.serviceWorkers() if worker.evaluate() starts throwing.chrome.runtime.openOptionsPage() cannot be called from the test process: it must be invoked via worker.evaluate() or from inside an extension page.USER_DATA_DIR paths per test or call chrome.storage.local.clear() in beforeEach.--load-extension and --disable-extensions-except are Chromium flags and work identically on Edge (replace chromium with chromium but point to the Edge binary via executablePath).firefox channel. Use firefox.launchPersistentContext with --load-extension is not the Firefox approach; instead pass firefoxUserPrefs or use the web-ext tool to generate a temporary profile. The waitForEvent('serviceworker') API works on Firefox for MV3 extensions but the URL scheme changes to moz-extension://.safaridriver via WebDriver; the patterns in this guide are Chrome/Firefox only.launchPersistentContext recipe with edge cases.