Testing Workers in CI

Worker code has a reputation for being untestable, and it is half deserved: the algorithm is ordinary JavaScript that any runner can test, while the boundary needs a real browser and behaves differently from anything else in the suite. Treating those as one problem is what produces a suite that is either useless or permanently flaky.

This topic sits under Debugging, Profiling & Production Optimization and covers the split: what to test where, how to keep message-based assertions deterministic, and the CI-specific failures β€” core counts, origins, timeouts, isolation headers β€” that make a green local run mean nothing.


The Four Layers, and Where Each Is Tested

Layer Example Environment Count
Algorithm the parser, the aggregation, the layout maths any runner, no DOM hundreds
Protocol request ids, credit accounting, retry logic any runner, with a fake port dozens
Boundary cloneability, transfer, expose timing, error propagation real browser, real Worker one per method
Integration the feature works, results reach the screen Playwright or equivalent a handful

The shape of a healthy suite follows from the table: the vast majority of tests never touch a thread, a small deliberate set proves the boundary works, and a few end-to-end tests prove the whole thing is wired together. Suites that struggle usually have the opposite distribution β€” everything is an integration test, so every test is slow and every failure is ambiguous.

Distribution of a worker test suite by layer Four bands, widest at the bottom. Algorithm tests are the widest band: hundreds of fast tests that need no browser and run in milliseconds. Protocol tests are the next band: dozens of tests that drive the message protocol through a plain MessageChannel with no worker. Boundary tests are a narrow band: one per exposed method, run in a real browser, proving arguments clone and errors propagate. Integration tests are the narrowest: a handful of end-to-end runs. A note records the failure mode of an inverted distribution, where everything is an integration test and every failure is slow and ambiguous. What a healthy worker suite looks like integration β€” a handful real browser, real app, slowest boundary β€” one per exposed method real Worker: cloneability, transfer, expose timing, error propagation protocol β€” dozens request ids, credits, retries β€” driven through a plain MessageChannel, no thread algorithm β€” hundreds the computation as a neutral module: no worker, no DOM, milliseconds per test Inverted suites β€” everything at the top band β€” are slow, flaky, and never say what broke.
The bands are not arbitrary: each one tests something the band below cannot, and nothing more.

Prerequisites

  • A worker entry file that contains no logic. If the parser lives inside onmessage, it can only be tested through a thread. Extract first; the refactor is usually mechanical.
  • A browser-backed runner for the boundary layer β€” Vitest’s browser mode, Web Test Runner, Karma, or Playwright’s component testing. Which one matters far less than having one.
  • A pinned browser binary in CI, so an engine update cannot change the suite’s behaviour overnight.
  • An HTTP origin for tests. file:// breaks worker construction in every engine; the runner must serve over http://localhost.
  • Isolation headers in the test server if any test touches SharedArrayBuffer, or those tests fail with a constructor that does not exist.

Step 1 β€” Split the Entry From the Algorithm

// core/aggregate.ts β€” pure, importable anywhere, the bulk of the tests
export interface Summary { rows: number; sum: number; nulls: number }

export function aggregate(values: Float64Array): Summary {
  let sum = 0, nulls = 0;
  for (const v of values) { if (Number.isNaN(v)) nulls++; else sum += v; }
  return { rows: values.length, sum, nulls };
}
// aggregate.worker.ts β€” wiring only; nothing here needs its own unit test
import { aggregate } from './core/aggregate';

self.addEventListener('message', (event: MessageEvent<{ id: number; values: Float64Array }>) => {
  const { id, values } = event.data;
  try {
    self.postMessage({ id, ok: true, summary: aggregate(values) });
  } catch (error) {
    self.postMessage({ id, ok: false, error: (error as Error).message });
  }
});

Everything interesting is now in core/aggregate.ts, which any runner tests in milliseconds with no environment at all. The worker file has one behaviour worth a test β€” that it answers with the right id and converts a throw into an ok: false reply β€” and that test belongs to the boundary layer.

The refactor pays twice

A neutral algorithm module is also what lets the same code run on the server, in a service worker, or synchronously as a fallback for engines where a worker is unavailable β€” the sharing pattern described in Server-Side Rendering and Worker Hydration Pitfalls.


Step 2 β€” Test the Protocol Without a Thread

Request correlation, credit accounting, retry and timeout logic are pure state machines. Driving them through a MessageChannel gives a faithful async environment with no worker, no bundler, and no flakiness:

// protocol.test.ts β€” both ends of the real protocol, one thread
import { RequestClient } from '../src/request-client';
import { serve } from '../src/worker-entry-logic';

it('rejects the matching request when the responder reports an error', async () => {
  const { port1, port2 } = new MessageChannel();
  serve(port2, { aggregate: () => { throw new Error('boom'); } });   // the entry's logic
  const client = new RequestClient(port1);

  await expect(client.request({ kind: 'AGGREGATE', values: new Float64Array(4) }))
    .rejects.toThrow('boom');
});

This is the layer where message-shaped bugs actually live: an id that is never cleared from the pending map, a reply matched to the wrong request after a retry, a credit not returned on the error path. All of them are reproducible here, deterministically, in a millisecond.


Step 3 β€” Prove the Boundary Once Per Method

Boundary tests answer questions the layers above cannot: does this argument survive structured clone, does the transfer actually detach, does expose run before the first call, does an error arrive with a usable message.

// boundary.test.ts β€” runs in a real browser
import { workerApi } from '../src/worker-service';

it('moves the buffer rather than copying it', async () => {
  const values = new Float64Array([1, 2, 3, NaN]);
  const summary = await workerApi.aggregate(transfer(values, [values.buffer]));

  expect(values.byteLength).toBe(0);            // proves transfer, not clone
  expect(summary).toEqual({ rows: 4, sum: 6, nulls: 1 });
});

it('propagates a worker-side failure as a rejection', async () => {
  await expect(workerApi.aggregate(null as never)).rejects.toThrow();
});

One test per exposed method is enough. The goal is coverage of the boundary, not of the logic β€” the logic already has hundreds of fast tests behind it.


Step 4 β€” Remove Every Timing Assumption

The single largest source of worker-test flake is asserting on elapsed time. Machines differ, CI runners are slower and noisier, and a threshold that holds on a laptop fails on a shared runner under load.

// βœ— Flaky: assumes the worker is faster than 200 ms on every machine, forever.
worker.postMessage({ kind: 'RUN' });
await sleep(200);
expect(received).toHaveLength(1);

// βœ“ Stable: waits for the event that actually indicates completion.
const done = new Promise<Result>((resolve, reject) => {
  const timer = setTimeout(() => reject(new Error('worker did not reply within 10 s')), 10_000);
  worker.addEventListener('message', function on(event: MessageEvent) {
    if (event.data.id !== id) return;                     // ignore unrelated traffic
    worker.removeEventListener('message', on);
    clearTimeout(timer);
    resolve(event.data);
  });
});
worker.postMessage({ kind: 'RUN', id });
expect(await done).toMatchObject({ ok: true });

The timeout still exists β€” a hung worker must fail rather than hang the suite β€” but it is a failure threshold set generously, not a success threshold set tightly. Ten seconds never fires on a healthy run and always fires on a deadlock.

Sources of worker-test flake in continuous integration Four flake sources with their fixes. Timing assumptions: a fixed sleep that passes on a fast laptop and fails on a loaded runner, fixed by awaiting a correlated reply with a generous failure timeout. Core count: a pool sized from hardwareConcurrency behaves differently on a two-core runner, fixed by injecting the size. Origin and protocol: tests served from a file URL or a different origin cannot construct a worker, fixed by serving over the same HTTP origin as the app. Isolation headers: shared memory tests fail because the test server does not send the cross-origin headers, fixed by configuring them in the runner's server. Four flake sources, and what each one actually is fixed sleep before asserting passes on a laptop, fails on a loaded runner await a correlated reply generous timeout as a failure, not a success, threshold pool sized from hardwareConcurrency two-core runner changes concurrency and ordering inject the pool size tests pin it; production still reads the hardware tests served from file:// or a second origin the Worker constructor refuses the URL serve over the app's own origin http://localhost, same scheme and port as the app shared-memory tests on a plain server SharedArrayBuffer is undefined without isolation send COOP and COEP in the test server same headers the production document sends
Every left-hand box is a test that will eventually fail for a reason unrelated to the code under test β€” which is the working definition of flake.
Isolation headers in the test server

Any test touching SharedArrayBuffer needs the test server to send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, exactly as production does. Without them crossOriginIsolated is false, the constructor is undefined, and the failure looks like a missing feature rather than a missing header. Assert crossOriginIsolated === true in a setup file so the cause is stated once instead of guessed at repeatedly.


Step 5 β€” Make the Environment Explicit

Two values must never be read from the environment during a test.

Pool size. navigator.hardwareConcurrency is 8 on a developer laptop and 2 on a typical CI container. A pool that sizes itself will run a different concurrency in each, so ordering-sensitive tests pass in one and fail in the other. Inject it: new Pool({ size: options.size ?? navigator.hardwareConcurrency }).

Worker URLs. A test that constructs a worker from a hard-coded path breaks whenever the bundler’s output layout changes. Construct through the same factory the application uses, so the test exercises the real resolution path.

Everything else β€” timeouts, retry counts, credit windows β€” should be parameters with production defaults, so a test can set them to values that make the behaviour observable in milliseconds rather than seconds.


Step 6 β€” Make Concurrency Deterministic

Pool and queue tests are where suites become mysteriously flaky, because the assertion usually depends on which worker picked up which job β€” an outcome that varies with core count and scheduling.

The fix is to make the interleaving explicit rather than incidental. With a stubbed worker whose reply timing the test controls, every ordering question becomes a decision instead of a race:

// pool.test.ts β€” the test decides who finishes first
const replies = new Map<number, (value: Result) => void>();
const pool = new Pool({ size: 2, create: () => new ControlledWorker(replies) });

const a = pool.run({ id: 1 });
const b = pool.run({ id: 2 });
const c = pool.run({ id: 3 });          // queued: both workers are busy

expect(pool.stats()).toMatchObject({ busy: 2, queued: 1 });

replies.get(2)!({ ok: true });          // the SECOND job finishes first, deliberately
await b;
expect(pool.stats()).toMatchObject({ busy: 2, queued: 0 });   // c was dispatched

Three properties are worth asserting this way, and none of them can be tested reliably against real threads: that a queued job is dispatched as soon as any worker frees up, that results are matched to their own requests when they complete out of order, and that a rejected job releases its worker rather than leaking a slot.

Pin the pool size in every such test. A pool that reads navigator.hardwareConcurrency produces busy: 8 on a laptop and busy: 2 on a runner, and the expectations above become environment-dependent β€” the single most common cause of β€œpasses locally, fails in CI” in worker code.


Testing Performance Claims Without Testing Performance

A worker exists to keep the main thread free, so it is tempting to assert on timing. Wall-clock assertions are the wrong tool β€” they encode machine speed β€” but the underlying claim is testable if it is restated as a behavioural one.

Claim: β€œthe main thread is not blocked during the computation.” Assert that it kept servicing frames, not that the job was fast:

test('the main thread keeps painting during a heavy job', async ({ page }) => {
  await page.evaluate(() => {
    (window as never as { frames: number }).frames = 0;
    const tick = () => { (window as never as { frames: number }).frames++; requestAnimationFrame(tick); };
    requestAnimationFrame(tick);
  });

  await page.getByRole('button', { name: 'Recalculate' }).click();
  await expect(page.getByTestId('summary')).toBeVisible();

  // A blocked main thread produces near-zero frames; a free one produces dozens.
  expect(await page.evaluate(() => (window as never as { frames: number }).frames)).toBeGreaterThan(20);
});

The threshold is deliberately loose. It distinguishes β€œthe thread was free” from β€œthe thread was blocked”, which is the architectural property under test, and it does not encode how fast the machine is β€” a slow runner simply produces fewer frames in the same wall clock, and 20 is far below what any unblocked thread manages.

Claim: β€œwork happens off the main thread.” That is a structural assertion, checked by observing that a worker exists and executed, not by timing anything. The end-to-end techniques for it are in End-to-End Worker Tests with Playwright.

Claim: β€œthis is faster than the previous implementation.” That is a benchmark, not a test. Run it separately, compare distributions rather than single runs, and never gate a pull request on a wall-clock number produced by a shared CI runner.


Verification & Measurement

Track suite duration by layer. If the browser-backed layer grows past a minute or two, tests have leaked upward out of the algorithm layer. The fix is to move them back down, not to parallelise harder.

Fail the build on unhandled rejections. A worker error that nobody awaited will not fail a test by default; it becomes an unhandled rejection that most runners print and ignore. Configure the runner to treat those as failures, or a broken error path can stay green indefinitely.

Re-run the suite N times before trusting a green. Flake is a probability, not a state. A --repeat-each=5 run on the boundary and integration layers, once per week in CI, surfaces the timing assumptions that a single run hides.


Choosing a Runner for the Boundary Layer

Four options are in common use, and the differences that matter for worker tests are narrower than the general comparisons suggest.

Vitest browser mode runs your existing Vitest suite in a real engine driven by Playwright or WebDriver. Its advantage for worker code is that Vite’s own worker resolution applies, so new URL('./w.ts', import.meta.url) behaves exactly as it does in the application β€” the same transform pipeline, the same output shape. If the project already builds with Vite, this is the path of least surprise.

Web Test Runner is bundler-agnostic and serves real modules over HTTP with esbuild transforms. It is a good fit when the application is not built with Vite, and its coverage of worker scope is respectable, but any bundler-specific worker spelling has to be reproduced in its configuration.

Playwright directly is the heaviest option per test and the only one that can assert on the built artefact β€” which makes it the right home for the integration layer rather than the boundary layer. Using it for both means paying browser-launch cost for tests that only needed a Worker object.

Karma still works and still runs everywhere, and it is the pragmatic answer for a legacy suite that already uses it. It is not worth adopting for new work.

Whichever you choose, the properties to insist on are the same three: tests are served over a real HTTP origin, the runner can send custom response headers (for cross-origin isolation), and the browser binary is pinned. A runner missing any of those will produce failures that look like application bugs and are not.

One decision that outranks the choice of tool: run the boundary suite against one engine on every commit and the others on a schedule. Engine differences in module workers and structured clone are real and worth catching, but catching them within an hour rather than within a minute is a perfectly good trade for a suite that stays fast enough to run before every push.


Failure Modes & Error Handling

A green suite that never exercised a worker. If the boundary project is skipped when its browser is missing, CI reports success having run only the fast layer. Fail the build when a project is skipped rather than treating it as an optional extra, or the most valuable tests quietly stop running.

A worker that never replies hangs the runner. Without a per-request timeout, the suite waits for the global test timeout, reports an unhelpful message, and leaves the thread running. Always fail fast with a message naming the request.

Workers that outlive their test. A worker created in a test and never terminated keeps running between tests, consuming CPU and occasionally answering a later test’s message. Terminate in afterEach, and assert that the count of live workers returns to its baseline.

Errors swallowed by the entry file. A try/catch around the whole onmessage handler that posts { ok: false } will hide a programming error as a soft failure. Distinguish expected failures from bugs, and let bugs propagate to self.onerror so Fixing Uncaught Exceptions in Dedicated Workers applies.

Retries that hide a real defect. A single retry is reasonable insurance against infrastructure noise; a suite that needs two or three is telling you the worker tests contain a race. Track which tests consume retries and treat a repeat offender as a bug report rather than as background noise β€” worker races almost never stay benign.

Cross-test state inside a warm worker. A worker reused across tests keeps its module state β€” caches, loaded datasets, counters. Either reset it explicitly through the API or create a fresh worker per test file, and prefer the former, since construction is the slow part.


Suite duration by layer for a representative worker test suite Four layers measured on the same project. Three hundred and forty algorithm and protocol tests run in the Node environment in about 1.9 seconds including startup. Twenty-two boundary tests in a real browser take 6.4 seconds, of which 2.1 seconds is browser startup. Six end-to-end tests against the production build take 11 seconds locally. The same six take 31 seconds on a two-core CI container, which is why the layer stays at six tests rather than sixty. What each layer costs to run 340 algorithm + protocol 1.9 s including startup 22 boundary, real Worker 6.4 s β€” 2.1 s of it startup 6 end-to-end, local 11 s 6 end-to-end, CI 31 s on two cores 0 11 22 32 s Marginal cost per test: about 6 ms, 200 ms, and 5 s respectively.
The ratios are the argument for the split: moving ten tests down one layer saves seconds on every run, forever.

Browser Compatibility for Test Runners

Capability Chromium headless Firefox headless WebKit headless
Module workers in tests βœ… βœ… (114+) βœ… (15+)
SharedArrayBuffer with isolation headers βœ… βœ… βœ… (15.2+)
Coverage instrumentation inside worker scope βœ… partial partial
DevTools protocol for worker inspection βœ… limited limited

Chromium is the pragmatic default for the boundary layer because coverage and debugging inside worker scope work best there. Running the same suite against Firefox and WebKit is still worth doing β€” engine differences in module workers and structured clone are real β€” but as a scheduled cross-browser job rather than on every commit.

Frequently Asked Questions

Why do my worker tests pass locally and fail in CI?
Three causes account for nearly all of it. CI machines have fewer cores, so a pool sized from navigator.hardwareConcurrency behaves differently and timing assumptions break. CI is slower, so a fixed timeout that passes on a laptop expires. And CI often serves test files from a different origin or protocol, which changes what the Worker constructor accepts. Fix by asserting on events rather than elapsed time, injecting the pool size, and serving tests over the same origin as the app.
Can I test worker code without a browser at all?
You can test the algorithm without one β€” extract it into a runtime-neutral module and unit-test that in any environment. What you cannot test without a browser is the boundary itself: cloneability of arguments, transfer semantics, expose timing, and error propagation. Those need a real Worker, which means a browser-backed runner, and they only need a handful of tests.
How do I make message-based tests deterministic?
Never assert on timing; assert on the messages. Give every request an id, await a promise that resolves on the matching reply, and fail with a descriptive error on a generous timeout. A test that waits a fixed 200 ms for a result is a flake generator; a test that awaits a correlated reply is stable on any machine speed.

See also