Unit Testing Worker Modules with Vitest
Vitest can run the same suite in three environments, and worker code needs two of them: Node for everything that is really just JavaScript, and a real browser for the handful of tests that must cross a thread boundary.
This guide implements the runner half of Testing Workers in CI, part of Debugging, Profiling & Production Optimization. The configuration below runs both layers in one command while keeping the fast layer fast.
Two Projects, One Command
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{
// Fast layer: algorithms and protocol. No DOM, no threads, milliseconds.
test: {
name: 'unit',
environment: 'node',
include: ['src/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts'],
},
},
{
// Slow layer: real Worker in a real engine.
test: {
name: 'boundary',
include: ['src/**/*.browser.test.ts'],
browser: {
enabled: true,
provider: 'playwright',
headless: true,
instances: [{ browser: 'chromium' }],
},
},
},
],
},
// The test server must send isolation headers if any test uses SharedArrayBuffer.
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
The naming convention does the routing: *.browser.test.ts gets a browser, everything else does not. That keeps the incentive right — writing a browser test is a deliberate act, so the layer stays small.
vitest invocation, so the split costs nothing in developer workflow and everything in feedback speed.The Fast Layer
Algorithm tests are unremarkable, which is the point:
// core/aggregate.test.ts — no worker, no DOM, no configuration
import { aggregate } from './aggregate';
it('counts NaN as null rather than poisoning the sum', () => {
expect(aggregate(new Float64Array([1, NaN, 3]))).toEqual({ rows: 3, sum: 4, nulls: 1 });
});
Protocol tests are the interesting half of this layer, because Node provides MessageChannel — so the real client and the real entry logic can be connected without a thread:
// request-client.test.ts
import { RequestClient } from './request-client';
import { serveOn } from './worker-entry-logic';
it('clears the pending entry when a request times out', async () => {
const { port1, port2 } = new MessageChannel();
serveOn(port2, { slow: () => new Promise(() => {}) }); // never resolves
const client = new RequestClient(port1, { timeoutMs: 20 });
await expect(client.request({ kind: 'slow' })).rejects.toThrow(/timed out/);
expect(client.pendingCount).toBe(0); // the actual assertion
});
That test would be slow, flaky and hard to write against a real worker; against a channel it is deterministic and takes 20 ms. Anything about bookkeeping belongs here.
The Browser Layer
In browser mode, Worker exists and Vite’s worker resolution applies, so the worker is constructed exactly as the application constructs it:
// worker-service.browser.test.ts
import { afterEach, expect, it } from 'vitest';
import * as Comlink from 'comlink';
import type { AggregateApi } from './aggregate.worker';
const workers: Worker[] = [];
function makeApi() {
const worker = new Worker(new URL('./aggregate.worker.ts', import.meta.url), { type: 'module' });
workers.push(worker);
return Comlink.wrap<AggregateApi>(worker);
}
afterEach(() => { workers.splice(0).forEach((w) => w.terminate()); }); // no leaks
it('detaches a transferred buffer and returns the summary', async () => {
const api = makeApi();
const values = new Float64Array([1, 2, NaN]);
const summary = await api.aggregate(Comlink.transfer(values, [values.buffer]));
expect(values.byteLength).toBe(0);
expect(summary).toEqual({ rows: 3, sum: 3, nulls: 1 });
});
it('rejects with a usable message when the worker throws', async () => {
await expect(makeApi().aggregate(undefined as never)).rejects.toThrow(/values/);
});
The afterEach teardown is not optional. Vitest keeps the page alive between test files in a browser project, so an un-terminated worker survives into the next file, consumes CPU, and occasionally answers a message from a later test — a failure that reads as nondeterminism and is really a leak.
Testing a Pool Without Real Threads
Pool logic — dispatch, queueing, slot release, retry — is the part most likely to be wrong and the part least suited to real workers, because every assertion depends on timing you do not control. Inject a factory instead, and the whole state machine becomes testable in the Node project:
// pool.test.ts
class FakeWorker {
handler?: (e: { data: unknown }) => void;
addEventListener(_: 'message', h: (e: { data: unknown }) => void) { this.handler = h; }
postMessage(request: { id: number }) { pending.set(request.id, this); }
terminate() { terminated++; }
}
const pending = new Map<number, FakeWorker>();
let terminated = 0;
it('dispatches a queued job the moment a worker frees up', async () => {
const pool = new Pool({ size: 1, create: () => new FakeWorker() as unknown as Worker });
const first = pool.run({ id: 1 });
const second = pool.run({ id: 2 }); // queued behind the first
expect(pool.stats()).toMatchObject({ busy: 1, queued: 1 });
pending.get(1)!.handler!({ data: { id: 1, ok: true } });
await first;
expect(pool.stats()).toMatchObject({ busy: 1, queued: 0 });
expect(await Promise.race([second, 'pending'])).not.toBe('pending');
});
Note the pinned size: 1. A pool that reads navigator.hardwareConcurrency produces a different busy count on every machine, so injecting the size is what makes the assertion meaningful rather than incidental. The same injection point serves production, where the default reads the hardware.
Tests written this way also fail informatively: a leaked slot shows up as busy never returning to zero, which names the bug directly, whereas the same defect against real workers appears as an unrelated test timing out later in the file.
Coverage That Reflects Reality
Coverage of the entry file is a distraction; coverage of the logic is not. Two settings make the numbers honest:
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
// The entry is wiring; assert on it in one boundary test rather than chasing lines.
exclude: ['src/**/*.worker.ts', 'src/**/*.d.ts'],
}
With the algorithm extracted, excluding *.worker.ts costs nothing in real coverage and removes a persistent source of confusing 0% rows. If an entry file starts accumulating branches worth covering, that is the signal to move them into the logic module rather than to change the coverage configuration.
Running It in CI
# .github/workflows/test.yml — the parts that matter for workers
- run: npm ci
- run: npx playwright install --with-deps chromium # required by browser mode
- run: npx vitest run --project unit # fast layer, fails fast
- run: npx vitest run --project boundary # real threads, only if the above passed
Running the projects as separate steps rather than one command is worth the extra lines: the fast layer fails in seconds when something is genuinely broken, and the browser layer — which needs a downloaded engine and takes an order of magnitude longer to start — only runs when there is a reason to.
Two further settings keep the browser project stable on a small runner. Cap Vitest’s own parallelism (--maxWorkers=2) so several browsers do not compete for two cores, and pin the browser version by pinning @playwright/test, so an engine update lands in a deliberate commit rather than in an unrelated pull request that suddenly goes red.
Cache the Playwright browser download between runs, keyed on the Playwright version. Without it, roughly 20–40 s of every CI run is spent downloading an engine that has not changed.
Gotchas
environment: 'jsdom' does not provide Worker. jsdom implements a large part of the DOM but not workers, so a test that constructs one throws ReferenceError. Either use browser mode or install a stub — the trade-offs are in Mocking Workers in jsdom Environments.
Node’s MessageChannel is not the DOM one. It comes from node:worker_threads and its ports need port.on('message') in some versions, plus an explicit unref() to avoid holding the process open. Vitest exposes a compatible global for most cases, but a hanging test process after a passing suite is usually an un-closed port.
Aliases configured only for the app build. The worker build in Vite takes its own plugin list, so a path alias or environment replacement missing there produces an unresolved import that exists only in the boundary project — and only in the worker file.
import.meta.url resolution differs between projects. In the Node project it is a file URL; in browser mode it is an HTTP URL served by Vite. Code that parses it will behave differently in each — another reason to construct workers through one shared factory.
Fake timers break message delivery. vi.useFakeTimers() replaces setTimeout, which many message-pump implementations rely on for yielding. Restrict fake timers to tests that do not cross a port, or configure them to leave queueMicrotask and MessageChannel alone.
A boundary test that asserts on logic. If a browser-mode test fails because the aggregation is wrong rather than because the boundary is, it belongs in the Node project. Boundary tests should be almost content-free: call the method, assert the shape came back.
Watch mode leaks threads. Every re-run in browser mode creates new workers if teardown is missing, and after a few dozen saves the machine is running dozens of threads. The afterEach terminate above is what prevents it.
Browser-mode tests share one page by default. Module state, registered service workers and installed globals persist across test files in a project. Reset anything global in beforeEach, and prefer creating fresh workers per test over reusing one across files.
Performance Note
On a 2023 laptop, a suite of 340 algorithm and protocol tests in the Node project completed in 1.9 s including startup. The 22 boundary tests in the browser project took 6.4 s, of which 2.1 s was Chromium startup — meaning the marginal cost per boundary test is around 200 ms. That ratio is the argument for the split: moving ten tests from the browser project to the Node project saves roughly two seconds on every run, on every machine, forever.