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.
Table of Contents
Puppeteer is Chrome-team tooling that speaks the DevTools Protocol directly, which makes it a natural fit for extension testing: service workers are first-class targets, extension pages are ordinary pages, and anything DevTools can do, a Puppeteer script can do too. Many existing extension test suites are written with it, and it remains the most direct route to CDP features Playwright does not wrap. The setup differs from Playwright’s in a few specific places. This guide is part of end-to-end testing automation.
Puppeteer and Playwright for extension work
Step-by-step
1. Launch Chrome with the extension
1// e2e/launch.js
2import puppeteer from "puppeteer";
3import path from "node:path";
4
5export async function launchWithExtension() {
6 const ext = path.resolve("dist/chrome");
7 const browser = await puppeteer.launch({
8 headless: true, // new headless mode — loads extensions
9 args: [`--disable-extensions-except=${ext}`, `--load-extension=${ext}`],
10 });
11 return browser;
12}
Execution context: Node. Puppeteer’s bundled Chrome for Testing supports extensions in its default headless mode. --disable-extensions-except ensures only your extension loads, so a stray extension on the build machine cannot interfere.
2. Find the service worker target
1export async function getWorker(browser) {
2 const target = await browser.waitForTarget(
3 (t) => t.type() === "service_worker" && t.url().endsWith("/service-worker.js"),
4 { timeout: 15_000 }
5 );
6 const worker = await target.worker();
7 const extensionId = new URL(target.url()).host;
8 return { worker, extensionId, target };
9}
Execution context: Node. waitForTarget resolves as soon as the worker registers; filtering by URL keeps it from matching some other service worker, such as one registered by a web page in another tab. The target is worth keeping — it is what you close to force a cold start.
3. Evaluate in the worker
1const { worker } = await getWorker(browser);
2
3await worker.evaluate(() => chrome.storage.local.set({ items: [] }));
4const alarms = await worker.evaluate(async () => (await chrome.alarms.getAll()).map((a) => a.name));
5console.assert(alarms.includes("daily-sync"), "daily-sync alarm missing");
Execution context: the function passed to evaluate runs in the service worker; the return value is serialised back to Node. This is the same capability as Playwright’s Worker.evaluate, used as in driving service worker state from a test.
4. Open extension pages
1const page = await browser.newPage();
2await page.setViewport({ width: 360, height: 600 });
3await page.goto(`chrome-extension://${extensionId}/popup.html`);
4
5await page.waitForSelector("text/Nothing saved yet");
6await page.click("::-p-aria(Save this page[role=\"button\"])");
Execution context: Node drives the page; the popup document runs with full extension APIs. Puppeteer’s ARIA selectors (::-p-aria(...)) locate by accessible name and role, which is sturdier than CSS and doubles as an accessibility check. The same popup-as-tab approach and its limits are described in testing a popup and options page with Playwright.
5. Force a cold start
1async function restartWorker(browser, target) {
2 const cdp = await target.createCDPSession();
3 await cdp.send("ServiceWorker.stopAllWorkers").catch(() => {});
4 await target.worker().then((w) => w?.close?.()).catch(() => {});
5 return getWorker(browser); // wait for the next registration
6}
Execution context: Node, speaking CDP. Stopping the worker and then waiting for its target to reappear — triggered by the next event, such as opening the popup — exercises exactly the cold-start path that warm test suites miss. Re-acquire the worker handle afterwards; the old one is dead.
6. Wrap it in a test runner
1// e2e/popup.test.js — Node's built-in test runner
2import { test, before, after } from "node:test";
3import assert from "node:assert/strict";
4import { launchWithExtension, getWorker } from "./launch.js";
5
6let browser, ext;
7before(async () => { browser = await launchWithExtension(); ext = await getWorker(browser); });
8after(() => browser.close());
9
10test("worker schedules the sync alarm on install", async () => {
11 const names = await ext.worker.evaluate(async () => (await chrome.alarms.getAll()).map((a) => a.name));
12 assert.ok(names.includes("daily-sync"));
13});
Execution context: Node’s test runner. Puppeteer has no runner of its own; node:test, Jest and Vitest all work. Sharing one browser across a file keeps the suite fast, with storage cleared between tests where isolation matters.
Choosing between them, and migrating
For a new suite, Playwright is usually the more comfortable choice: a built-in runner, fixtures, traces, parallelism, and role-based locators with auto-waiting. Puppeteer earns its place when the tests lean heavily on raw CDP — coverage collection, performance tracing, network interception at the protocol level — or when an existing suite already uses it and works.
Migrating an extension suite from Puppeteer to Playwright is mostly mechanical, because the extension-specific parts map almost one to one: puppeteer.launch with extension args becomes launchPersistentContext, browser.waitForTarget for the worker becomes context.waitForEvent("serviceworker"), and target.worker().evaluate becomes worker.evaluate. The bulk of the work is replacing explicit waits with auto-waiting assertions, which tends to remove flakiness as a side effect — the subject of stabilising flaky extension tests.
Coverage is the one feature worth singling out. Puppeteer’s page.coverage and the underlying CDP Profiler domain give JavaScript coverage for extension pages directly, which is useful for finding untested code paths in the popup and options page — the strategy discussed in a coverage strategy for extension code.
1await page.coverage.startJSCoverage();
2await page.goto(`chrome-extension://${extensionId}/options.html`);
3await exerciseOptions(page);
4const coverage = await page.coverage.stopJSCoverage();
5const used = coverage.reduce((n, e) => n + e.ranges.reduce((m, r) => m + r.end - r.start, 0), 0);
Execution context: Node and the options page. The result is byte ranges per script; convert with a tool such as v8-to-istanbul for a report.
Cross-browser variation
- Chrome / Edge: Puppeteer’s bundled Chrome for Testing loads extensions in headless mode; service workers are exposed as targets. Edge can be driven through its Chromium base with the right executable path.
- Firefox: Puppeteer’s Firefox support uses WebDriver BiDi; installing a temporary add-on is possible but less mature than Chrome’s extension support.
web-extremains the practical choice for Firefox, as in testing extensions in Firefox with web-ext. - Safari: not supported by Puppeteer. Use
safaridriverwith WebDriver, or manual testing. - All three: tests that exercise shared modules through Node rather than the browser run everywhere; keep as much logic as possible there.
Verification
- Run a single test that only waits for the worker target and asserts the extension id is 32 characters
a–p. If it times out, the launch flags are wrong. - Seed storage through the worker and read it back from a popup page to confirm both see the same data:
1await ext.worker.evaluate(() => chrome.storage.local.set({ probe: 1 }));
2const fromPage = await page.evaluate(() => chrome.storage.local.get("probe"));
3assert.deepEqual(fromPage, { probe: 1 });
Execution context: the worker, then the popup page. Matching values confirm both handles belong to the same extension instance.
- Restart the worker and confirm the next
evaluategoes to a new handle without “Target closed”. - Collect coverage for the options page and confirm the report lists your options script.
FAQ
Does Puppeteer need a special Chrome build for extensions?
Its bundled Chrome for Testing works. Branded Chrome builds have at times restricted command-line extension loading; prefer the bundled binary in CI.
Can I test the real popup surface with Puppeteer?
No more than with Playwright. Open popup.html as a page and cover size and close-on-blur behaviour with targeted tests.
Should I switch an existing working Puppeteer suite to Playwright?
Only if it is costing you — flakiness, missing traces, slow runs. A stable suite is worth more than a fashionable one.
How do I get the extension id before the worker starts?
Pin it with a key in the development manifest, which fixes the id across machines and profiles. Otherwise, derive it from the worker target’s URL as shown — the worker registers within a second of launch in nearly every case.
Related
- Loading an unpacked extension in Playwright — the Playwright equivalent.
- Driving service worker state from a test — worker control patterns that apply to both.
- Running extension tests in headless CI — running either in a pipeline.
- End-to-end testing automation — the parent guide.