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.

E2E extension test architecture: Playwright driving all MV3 contextsPlaywright launches a persistent Chromium context with --load-extension. The test process then reaches the service worker via context.serviceWorkers(), drives the popup via chrome-extension:// URL, and observes content script effects in the host page.Playwrighttest process (Node.js)ChromiumpersistentContext (headed)Service Workerbackground contextPopup / Optionschrome-extension:// pageHost Pagecontent script targetGitHub Actionsxvfb-run (headed)

Prerequisites checklist

Before writing a single test, confirm these are in place:

  • Playwright version 1.33 or later — earlier releases had unstable serviceWorkers() support and waitForEvent('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=new mode as of Chrome 112. Every test must run with headless: false and a virtual display in CI.
  • userDataDir chosen carefullylaunchPersistentContext 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.
  • 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.

Why an extension test needs a persistent contextA normal browser launch has no profile to install an extension into, so extension tests use a persistent context with the unpacked build loaded through command-line flags.browser.launch()ephemeral, no profileNo extension loadedchrome.* undefineduse the persistent form insteadlaunchPersistentContextreal user-data-dir--load-extension flagspoints at the build outputWorker registerscontext.serviceWorkers()
The extension lives in the profile, and only a persistent context has one.
 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.

Getting a handle on the worker and its extension idThe test waits for the service worker event, parses the extension id out of its URL, and uses that id to navigate to extension pages.TestBrowser contextService workerlaunchPersistentContext(...)registers the unpacked extensionwaitForEvent('serviceworker')or read serviceWorkers()[0]parse id from chrome-extension://<id>/goto('chrome-extension://<id>/popup.html')
Race the wait against a timeout — a worker that never registers means a build error, not a slow machine.
 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 to manifest.json from 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() 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.
  • Persistent context shares state between tests: use separate USER_DATA_DIR paths per test or call chrome.storage.local.clear() in beforeEach.

Cross-browser notes

  • Chrome/Edge — The reference target. --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 — Playwright supports Firefox WebExtensions via the 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://.
  • Safari — Playwright has no first-class Safari extension support. Safari extension automation requires XCTest or safaridriver via 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.