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.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

End-to-end tests that only click through the UI can reach the service worker’s behaviour only indirectly and slowly: waiting a real minute for an alarm, clicking twenty times to create test data, never exercising a cold start because the worker stays warm for the whole run. Playwright exposes the worker as an object you can evaluate code in, which turns all of those into one-line steps — and makes it possible to test the lifecycle behaviour that causes most MV3 bugs. This guide is part of end-to-end testing automation.

What a test can do to the worker

Worker operations available to a testEvaluating code, seeding storage, triggering alarms, forcing a cold start and reading internal state compared on the Playwright mechanism used and what each replaces.OperationMechanismReplacesRun code in the workersw.evaluate(fn)Nothing — only way inSeed storageevaluate storage.setMany UI clicksFire an alarm nowCall the handler directlyWaiting a minuteForce a cold startCDP: stop the workerNever tested otherwiseRead internal stateevaluate storage.get / getAllGuessing from the UI
Each row replaces a slow or impossible UI-driven step with a direct one.

Step-by-step

1. Get a handle to the worker

1export const test = base.extend({
2  sw: async ({ context }, use) => {
3    const sw = context.serviceWorkers()[0] ?? await context.waitForEvent("serviceworker");
4    await use(sw);
5  },
6});

Execution context: Node, under Playwright. The Worker object’s evaluate runs a function inside the service worker, with its globals and chrome.* bindings. The fixture builds on the launch setup in loading an unpacked extension in Playwright.

2. Seed and read storage directly

1test("list view renders 200 items", async ({ sw, context, extensionId }) => {
2  const items = Array.from({ length: 200 }, (_, i) => ({ id: `a${i}`, title: `Article ${i}`, url: `https://example.com/${i}` }));
3  await sw.evaluate((items) => chrome.storage.local.set({ items }), items);
4
5  const page = await context.newPage();
6  await page.goto(`chrome-extension://${extensionId}/popup.html`);
7  await expect(page.getByRole("listitem")).toHaveCount(30);   // windowed rendering
8});

Execution context: sw.evaluate runs in the worker; its argument is serialised across, so pass plain data. Seeding 200 items this way takes milliseconds; creating them through the UI would dominate the test’s runtime.

3. Trigger alarm work without waiting

Real alarms cannot fire faster than once a minute in a packed extension. Expose the handler so a test can call it — but do it in a way that does not ship a debug surface.

1// service-worker.js
2import { onAlarm } from "./scheduler.js";
3chrome.alarms.onAlarm.addListener(onAlarm);
4if (import.meta.env.DEV || import.meta.env.MODE === "test") {
5  globalThis.__test = { onAlarm };
6}
1test("sync alarm updates the badge", async ({ sw }) => {
2  await sw.evaluate(() => globalThis.__test.onAlarm({ name: "daily-sync", scheduledTime: Date.now() }));
3  expect(await sw.evaluate(() => chrome.action.getBadgeText({}))).toBe("3");
4});

Execution context: the service worker, in a test build. The __test hook is compiled out of production builds, so the surface never ships. Calling the handler directly tests exactly what the alarm would run, without the one-minute floor described in minimum alarm period and throttling.

4. Force a real cold start

The bugs that matter most in MV3 happen when the worker starts cold. A test suite on a warm worker never meets them. Chrome DevTools Protocol can stop the worker on demand.

 1async function stopWorker(context, sw) {
 2  const page = context.pages()[0] ?? await context.newPage();
 3  const cdp = await context.newCDPSession(page);
 4  const { targetInfos } = await cdp.send("Target.getTargets");
 5  const target = targetInfos.find((t) => t.type === "service_worker" && t.url === sw.url());
 6  await cdp.send("Target.closeTarget", { targetId: target.targetId });
 7}
 8
 9test("message sent to a cold worker is answered", async ({ context, sw, extensionId }) => {
10  await stopWorker(context, sw);
11  const page = await context.newPage();
12  await page.goto(`chrome-extension://${extensionId}/popup.html`);
13  await expect(page.getByRole("status")).not.toHaveText(/error/i);
14});

Execution context: Node, speaking CDP to Chromium. Closing the worker’s target is equivalent to the Stop button on chrome://extensions; the next event restarts it cold. This is the test that catches the late-registration bug described in messages sent while the worker is starting.

5. Get the new worker after a restart

After a stop, the old Worker handle is dead. Wait for the replacement before evaluating again.

1async function currentWorker(context) {
2  const live = context.serviceWorkers().at(-1);
3  if (live) return live;
4  return context.waitForEvent("serviceworker");
5}

Execution context: Node. Evaluating on a stale handle throws “Target closed”; always re-acquire after anything that might restart the worker, including a chrome.runtime.reload().

6. Assert on state the UI never shows

Some behaviour has no UI at all: which alarms exist, which content scripts are registered, which rules are enabled. Assert on it directly.

1test("install schedules the expected alarms and registrations", async ({ sw }) => {
2  const [alarms, scripts] = await sw.evaluate(async () => [
3    (await chrome.alarms.getAll()).map((a) => a.name).sort(),
4    (await chrome.scripting.getRegisteredContentScripts()).map((s) => s.id),
5  ]);
6  expect(alarms).toEqual(["cache-trim", "daily-sync"]);
7  expect(scripts).toEqual(["user-sites"]);
8});

Execution context: the service worker. This single test catches the most common post-update regression — alarms or registrations not rebuilt in onInstalled — which the audit in auditing scheduled alarms with getAll also targets at runtime.

A cold-start testThe test seeds storage in the warm worker, closes the worker's target over CDP, opens the popup which wakes a fresh worker, and asserts the popup received a correct response.Test (Node)CDPService workerPopupevaluate: seed storageTarget.closeTarget(sw)worker terminatedgoto popup.htmlsendMessage → cold startresponse
Without the stop step, this test would pass on code that fails for real users every morning.

Test hooks without a production back door

The __test hook in step 3 is a small debug surface, and debug surfaces have a way of shipping. Three rules keep it safe.

Compile it out. Guard it with a build-time constant the bundler replaces, so the branch — and everything it imports — is removed from production output. A runtime check such as “is Developer mode on?” is not enough, because the code still ships.

Verify its absence. Add a CI step that greps the release build for the hook name and fails if found.

1grep -rn "__test" dist/chrome/ && { echo "test hook in release build"; exit 1; } || echo "no test hooks"

Execution context: your shell, in CI after the release build. It is the same discipline as keeping development reload code out of releases, described in hot reloading an extension during development.

Expose functions, not capabilities. __test.onAlarm lets a test call a handler that already exists. A hook like __test.eval(code) would let anything with access to the worker run arbitrary code — exactly what MV3’s CSP exists to prevent.

Test hooks from source to releaseHandlers are exposed on a test-only global behind a build-time flag; the test build includes them, the release build eliminates the branch, and CI verifies the absence.if (MODE === 'test')build-time constantglobalThis.__testexisting handlers onlyTest buildhook presentin the release buildBranch eliminateddead codeCI grepno __testRelease uploadedno debug surface
The CI grep is what makes the guarantee checkable rather than hoped for.

Cross-browser variation

  • Chrome / Edge: context.serviceWorkers() and Worker.evaluate work with Playwright’s Chromium; CDP Target.closeTarget stops the worker.
  • Firefox: the MV3 background is an event page rather than a service worker, and Playwright does not expose it the same way. Drive it through web-ext and the remote debugging protocol, or test the handler modules in Node.
  • Safari: no automation access to the background context. Keep handler logic in modules that can be unit-tested in Node, and use Safari runs for manual verification.
  • All three: the more logic lives in plain modules with thin event wiring, the less of it needs a browser to test — the approach in testing message handlers in isolation.

Verification

  1. Run the cold-start test with the late-registration bug reintroduced (an await before addListener) and confirm it fails.
  2. Seed 200 items through the worker and confirm the popup renders them without any UI setup.
  3. Confirm the worker handle is re-acquired after a stop:
1await stopWorker(context, sw);
2const fresh = await currentWorker(context);
3expect(await fresh.evaluate(() => typeof chrome.runtime.id)).toBe("string");

Execution context: Node, then the new worker. A “Target closed” error here means the test is still holding the old handle.

  1. Build for release and confirm the CI grep reports no test hooks.

FAQ

Is calling the alarm handler directly a real test?

It tests the handler’s behaviour, which is what you care about. The one thing it skips is the browser’s scheduling — cover that separately with an assertion that the alarm exists with the right period.

Why not use a fake clock?

Fake timers work in unit tests, as in testing alarms and timers deterministically. In a real browser the alarm scheduler is outside your JavaScript and cannot be faked.

Does stopping the worker lose storage?

No. storage.local persists; storage.session survives a worker stop but not a browser restart. Only in-memory variables are lost — which is precisely what the test is checking.

Other Testing, Debugging & Performance Optimization Resources