End-to-End Worker Tests with Playwright
An end-to-end test is the only layer that can prove the whole chain — bundler output, worker construction, message protocol, and the DOM update at the end — actually works together in a shipped build. Playwright exposes workers as first-class objects, which makes those assertions specific rather than a screenshot and a hope.
This is the integration layer of Testing Workers in CI, part of Debugging, Profiling & Production Optimization. Keep this layer small: a handful of tests that each prove something no cheaper layer can.
Asserting on a Real Worker
// e2e/worker.spec.ts
import { expect, test } from '@playwright/test';
test('the dashboard computes its summary on a worker', async ({ page }) => {
// Start listening BEFORE the navigation that creates the worker.
const workerPromise = page.waitForEvent('worker');
await page.goto('/dashboard');
const worker = await workerPromise;
expect(worker.url()).toContain('aggregate'); // the right script
// This callback runs INSIDE worker scope, not in the page.
const scope = await worker.evaluate(() => self.constructor.name);
expect(scope).toBe('DedicatedWorkerGlobalScope');
await page.getByRole('button', { name: 'Recalculate' }).click();
await expect(page.getByTestId('summary')).toHaveText(/12,480 rows/);
});
Three assertions, three different claims: a worker exists and was loaded from the expected script; code can execute in its scope, which no mock can fake; and the user-visible result appears. The last one alone would pass even if the computation quietly fell back to the main thread — which is exactly the regression this layer exists to catch.
Executing Inside Worker Scope
worker.evaluate() runs a function in the worker’s global scope, which opens up assertions that are otherwise unreachable:
// Is the environment what the code expects?
expect(await worker.evaluate(() => typeof SharedArrayBuffer)).toBe('function');
expect(await worker.evaluate(() => self.crossOriginIsolated)).toBe(true);
// Did the worker load the module it should have, and is its state initialised?
const state = await worker.evaluate(() => ({
hasWasm: typeof WebAssembly.Module === 'function',
cacheSize: (self as unknown as { __cache?: Map<string, unknown> }).__cache?.size ?? -1,
}));
expect(state.hasWasm).toBe(true);
Two cautions. The function is serialised and evaluated in the worker, so it cannot close over test-file variables — pass arguments explicitly as the second parameter. And exposing internals purely for tests (self.__cache) is a real coupling; prefer asserting through the worker’s own API where one exists, and reserve scope evaluation for environment facts the API cannot report.
Faults You Can Only Inject Here
The integration layer is the only place where the loading of the worker can be manipulated, which makes it the natural home for failure-path tests.
test('shows a fallback when the worker script fails to load', async ({ page }) => {
await page.route('**/aggregate*.js', (route) => route.abort('failed'));
await page.goto('/dashboard');
// The Worker object fires `error`; the app should degrade, not hang.
await expect(page.getByTestId('fallback-note')).toBeVisible();
await expect(page.getByTestId('summary')).toHaveText(/12,480 rows/); // computed inline
});
test('reports a worker crash to the error surface', async ({ page }) => {
await page.goto('/dashboard');
const worker = await page.waitForEvent('worker');
await worker.evaluate(() => { throw new Error('synthetic crash'); }).catch(() => {});
await expect(page.getByRole('alert')).toContainText('background task failed');
});
Both tests assert on behaviour that is invisible in a happy-path suite and that users hit regularly — a CDN hiccup, an out-of-memory kill, an ad blocker that blocks a chunk. The recovery patterns being exercised are those in Error Handling & Crash Recovery; this is how you verify they are wired.
Worker console output is also available, and worth wiring into the test’s diagnostics:
page.on('worker', (w) => w.on('console', (msg) => console.log(`[worker] ${msg.text()}`)));
Without it, a failing test shows only what the page logged, and the worker’s own diagnostics — usually the interesting half — are invisible in CI output.
Proving the Main Thread Stayed Free
The claim a worker exists to support — “the interface stays responsive while this runs” — is testable here, and only here. Rather than timing the operation, measure whether the main thread kept doing its job during it:
test('the grid stays interactive while the aggregation runs', async ({ page }) => {
await page.goto('/dashboard');
await page.evaluate(() => {
const w = window as never as { frames: number };
w.frames = 0;
const tick = () => { w.frames++; requestAnimationFrame(tick); };
requestAnimationFrame(tick);
});
await page.getByRole('button', { name: 'Recalculate' }).click();
await expect(page.getByTestId('summary')).toBeVisible();
const frames = await page.evaluate(() => (window as never as { frames: number }).frames);
expect(frames).toBeGreaterThan(20); // a blocked thread produces almost none
});
The threshold is intentionally far from the boundary. An unblocked thread at 60 fps produces hundreds of frames over a second; a thread blocked by a long synchronous computation produces one or two. Anything in between does not occur in practice, so the assertion separates the two states cleanly without encoding machine speed.
The same technique catches regressions no unit test can: a refactor that accidentally awaits the worker result inside a synchronous layout pass, or a fallback path that silently computes inline when the worker fails to load. Both keep the output correct and both destroy the property the architecture was built for.
Service Workers Will Serve You Yesterday’s Bundle
Any application that registers a service worker needs one extra precaution in this layer, because a service worker installed by an earlier test can serve a cached bundle to a later one — including a bundle from a previous build. The symptom is a test that fails against code that is demonstrably correct, and passes after a manual browser restart.
test.beforeEach(async ({ context }) => {
await context.clearCookies();
// Unregister anything a previous test installed, then clear its caches.
await context.addInitScript(() => {
navigator.serviceWorker?.getRegistrations?.().then((rs) => rs.forEach((r) => void r.unregister()));
caches?.keys?.().then((keys) => keys.forEach((k) => void caches.delete(k)));
});
});
If service-worker behaviour is itself under test — offline support, cached computation as in Service Workers for Computation — do the opposite deliberately: give those tests their own project with a fresh context per test and assert on the registration state explicitly, rather than letting it persist implicitly across an entire suite.
Configuration That Keeps CI Green
// playwright.config.ts
export default defineConfig({
webServer: {
command: 'npm run preview', // the PRODUCTION build, not the dev server
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
},
use: { baseURL: 'http://localhost:4173', trace: 'on-first-retry' },
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined, // Playwright workers ≠ Web Workers
});
Four choices matter for worker tests specifically.
Test the production build. Worker resolution differs between the dev server and the built output — inlining, hashing, base paths — and this layer’s main job is to catch that difference.
Serve isolation headers if needed. The preview server must send the same COOP and COEP headers as production, or every SharedArrayBuffer test fails in a way that has nothing to do with the code.
Limit parallelism. Each Playwright worker runs a browser, and each browser may spawn several Web Workers. On a two-core CI container, eight parallel browsers make timing-sensitive tests unreliable regardless of how well they are written.
Keep trace: 'on-first-retry'. A worker failure in CI is difficult to reason about from a message alone; the trace includes console output, network requests for the worker script, and the DOM at failure.
Gotchas
Listening for the worker after navigating. page.waitForEvent('worker') only sees workers created after the listener is attached. Create the promise first, then navigate — the ordering in the first example is deliberate.
worker.evaluate closures capture nothing. The function is serialised, so referencing a test-scope variable throws at evaluation time with a confusing message. Pass values as arguments.
Headless and headed engines can differ. Timer resolution and background throttling policies are not always identical between the two, so a test that passes headed can fail headless. Debug headed if that helps, but always confirm in the mode CI uses.
Service workers are a different event. page.waitForEvent('worker') covers dedicated workers; service workers appear on the browser context (context.serviceWorkers()), and a page controlled by a stale service worker can serve an old bundle to the entire suite. Clear storage between tests when service workers are involved.
Blocking the worker script does not throw in application code. It fires an error event on the Worker; code that only handles message sees silence, and the test hangs rather than failing usefully. That is worth asserting deliberately.
A worker created before page.goto resolves is easy to miss. Workers started during module evaluation can be constructed before the navigation promise settles, so a listener attached after goto never fires and the test times out with a misleading message. Attach the listener before navigating, and prefer page.waitForEvent over polling page.workers().
Auto-waiting does not apply to worker state. Playwright retries locator assertions, not worker.evaluate results. Poll with expect.poll when asserting on a value that becomes true asynchronously inside worker scope.
Traces are large when workers are involved. A trace captures console output and network activity for every worker as well as the page, so a suite with trace: 'on' produces artefacts several times bigger than usual. on-first-retry keeps the diagnostic value without the storage cost.
Performance Note
A suite of six worker-focused end-to-end tests against a production build took 11 s on a 2023 laptop and 31 s on a two-core CI container with parallelism limited to two — roughly 5 s per test in CI. That is the budget this layer costs, which is why it holds six tests rather than sixty: everything cheaper belongs in the browser-mode unit layer, where the same assertions run in about 200 ms each.