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.
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.
Prerequisites checklist
Before writing a single test, confirm these are in place:
- Playwright version 1.33 or later — earlier releases had unstable
serviceWorkers()support andwaitForEvent('serviceworker')was unreliable. - A built extension directory on disk — Playwright loads from the filesystem, not a zip. Your CI pipeline must build the extension before the test run.
- Headed-only awareness — Chrome blocks extension loading in
--headless=newmode as of Chrome 112. Every test must run withheadless: falseand a virtual display in CI. userDataDirchosen carefully —launchPersistentContextrequires 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.- No
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.
1. Launch configuration
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.*
2. Retrieving the service worker
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.
3. Driving popup and options pages
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.*
4. Content script integration tests
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.*
5. What end-to-end tests are for in an extension
End-to-end tests are the slowest and most fragile tests an extension has, so the question of what they should cover deserves a deliberate answer rather than “everything”. Their unique value is testing the things no other layer can: that the extension actually loads, that listeners are registered in time to receive the events that wake the worker, that messages really travel between contexts, that content scripts inject into the pages they should and not the ones they should not, and that state written in one context is visible in another.
Business logic — parsing, validation, ranking, migrations — is better tested in Node, where tests run in milliseconds and edge cases are one argument away. Visual details are better checked in component tests or by eye. What remains for the browser is the wiring and the lifecycle, and a suite that concentrates on those can stay small, fast and trusted. A reasonable target for many extensions is one end-to-end test per message type, one per scheduled job, one per content-script entry point, and a handful covering updates and cold starts.
6. Testing the lifecycle deliberately
Most MV3 bugs are lifecycle bugs, and a naive end-to-end suite never meets them, because the service worker starts once at the beginning of the run and stays warm throughout. Real users meet a cold worker most of the time: after the browser starts, after thirty idle seconds, after every update. Tests that do not reproduce those conditions pass on code that fails in the field.
Three scenarios deserve explicit tests. A cold start, produced by stopping the worker through the DevTools protocol and then sending a message or triggering an alarm, catches late listener registration and initialisation races. An update, produced by loading the previous build, populating storage, then loading the new build over it, catches migrations that fail and alarms or registrations that are not rebuilt. And an orphaned content script, produced by reloading the extension while a matching tab is open, catches the “extension context invalidated” failures that follow every real update. The mechanics are in driving service worker state from a test.
7. Controlling the environment
End-to-end tests become flaky when they depend on things outside the extension: third-party websites that change their markup, network latency, the order in which tests run, state left behind by an earlier test. Each of these has a standard remedy. Serve pages from local fixtures through request routing, so content scripts still match the real URLs but the markup never changes underneath the test. Reset storage, alarms and dynamic rules before every test, or use a fresh profile per test file. Replace fixed sleeps with condition-based waits that poll for the expected state. The full catalogue is in stabilising flaky extension tests, and it is worth applying from the first test rather than after the suite has earned a reputation for flakiness.
8. Choosing the tool
For Chromium, both Playwright and Puppeteer load unpacked extensions and expose the service worker to tests; Playwright adds a test runner, fixtures, traces and auto-waiting assertions, and is the more comfortable default for a new suite. Puppeteer stays closer to the DevTools protocol and suits suites that lean on raw protocol features such as coverage collection. For Firefox, web-ext is the practical route, and Safari automation is limited enough that most teams cover it with manual checks on a small set of flows. Whatever the tool, keep extension-specific setup — launching with the extension, finding the worker, opening extension pages — in shared fixtures, so the choice can change later without rewriting every test. The comparison is covered in testing extensions with Puppeteer.
9. Running the suite where it helps most
An end-to-end suite that only runs before a release catches problems too late to be cheap. Run a fast subset — the load check, one round trip per message type, the cold-start test — on every pull request, and the full suite on the main branch and before tagging a release. Keep the subset under a couple of minutes so nobody is tempted to skip it, and make the full suite’s traces and screenshots available as build artifacts, so a failure can be diagnosed from the report without rerunning anything locally.
Pay attention to the runner environment as well. New headless Chromium loads extensions, so a display server is rarely needed, but tests that exercise media capture or clipboard behaviour may still require one; isolate them in their own project rather than running the whole suite under a virtual display. Caching the downloaded browsers keyed on the lockfile saves minutes per run, and testing the exact artifact produced by the build job — rather than rebuilding inside the test job — guarantees that what passed the tests is what reaches the store. Those details are covered in running extension tests in headless CI.
10. Reading failures quickly
When an extension test fails, the first question is almost always which context failed: the page, the content script, the service worker, or the wiring between them. Make that question cheap to answer. Capture console output from the worker as well as from pages, prefix every log line with its context, and include the worker’s recent log in the failure report. A failure report that says “popup waited ten seconds for a reply; worker log shows no handler for settings:read” points straight at a late listener, where a bare timeout sends someone off to reproduce the problem by hand.
MV3 constraints to design around
- No headless support in Chrome 112+: extensions are fully blocked in
--headless=new. All E2E runs require a headed Chromium and a virtual display in CI. - Extension ID is not stable without a manifest key: if cross-run ID stability matters (for hardcoded URLs in test fixtures), add a
"key"field tomanifest.jsonfrom a generated CRX key. - Service workers are evicted after ~30 seconds idle: long test runs that pause between assertions may find the worker inactive. Re-fetch the worker reference via
context.serviceWorkers()ifworker.evaluate()starts throwing. chrome.runtime.openOptionsPage()cannot be called from the test process: it must be invoked viaworker.evaluate()or from inside an extension page.- Persistent context shares state between tests: use separate
USER_DATA_DIRpaths per test or callchrome.storage.local.clear()inbeforeEach.
Cross-browser notes
- Chrome/Edge — The reference target.
--load-extensionand--disable-extensions-exceptare Chromium flags and work identically on Edge (replacechromiumwithchromiumbut point to the Edge binary viaexecutablePath). - Firefox — Playwright supports Firefox WebExtensions via the
firefoxchannel. Usefirefox.launchPersistentContextwith--load-extensionis not the Firefox approach; instead passfirefoxUserPrefsor use theweb-exttool to generate a temporary profile. ThewaitForEvent('serviceworker')API works on Firefox for MV3 extensions but the URL scheme changes tomoz-extension://. - Safari — Playwright has no first-class Safari extension support. Safari extension automation requires XCTest or
safaridrivervia WebDriver; the patterns in this guide are Chrome/Firefox only.
Everything above assumes Chromium, where a directory can be loaded by flag. Firefox installs add-ons rather than loading directories and assigns a per-profile UUID, so the harness needs a different fixture entirely — testing extensions in Firefox with web-ext builds it, and shows how to share the specs between both engines.
Further guides in this topic
The guides below go deeper into specific end-to-end testing automation for mv3 extensions problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- Driving Service Worker State from a Test — Control an MV3 service worker from Playwright — evaluating in the worker, seeding storage, firing alarms on demand, forcing eviction and cold starts, and asserting on state the UI never shows.
- Stabilising Flaky Extension Tests — Find and fix the causes of flaky MV3 extension tests — service worker startup races, eviction mid-test, storage bleeding between tests, real network and timing assumptions — instead of adding retries.
- Testing a Popup and Options Page with Playwright — Test MV3 extension pages end to end — loading popup.html as a tab, simulating the active tab the popup expects, asserting storage side effects, and what the page-as-tab approach cannot cover.
- Testing Extensions with Puppeteer — Load and test an MV3 extension with Puppeteer — launch flags, finding the service worker target, evaluating in the worker, opening extension pages, and when to prefer Playwright instead.
Related
- Loading an unpacked extension in Playwright — the full
launchPersistentContextrecipe with edge cases. - Testing extensions in Firefox with web-ext — the Gecko half of a cross-browser suite.
- Debugging extension contexts — when a test fails, this is how you inspect each MV3 context manually.
- Service worker fundamentals — lifecycle and eviction behaviour that shapes test timing.
- Unit & Integration Testing — mock-based unit tests that complement the E2E layer.
- Up to Testing, Debugging & Performance Optimization.