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.

Which environment each kind of worker test needs Two columns. The Node project runs algorithm tests and protocol tests: it provides MessageChannel and structuredClone but no DOM Worker, starts in about 200 milliseconds, and runs hundreds of tests in a second. The browser project runs boundary tests: it provides a real Worker, real transfer semantics and real module resolution, costs about two seconds of browser startup, and should contain roughly one test per exposed method. An arrow between them notes that a test written in the wrong column either cannot run at all or runs an order of magnitude slower than it needs to. Two projects, chosen by file name project: unit — Node *.test.ts algorithms · request correlation credits · retries · timeouts has MessageChannel and structuredClone has no DOM Worker ≈ 200 ms to start · hundreds of tests project: boundary — Chromium *.browser.test.ts cloneability · transfer detachment module resolution · error propagation a real thread, a real bundler pipeline isolation headers from the test server ≈ 2 s to start · one test per method A test in the wrong column either cannot run at all, or runs ten times slower than it needs to.
Both projects run from one 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.

Where coverage comes from once the algorithm is extracted Two stacked views of the same codebase. Before extraction, the worker entry file contains both wiring and the algorithm, so most of the meaningful code sits in a module that the coverage provider attributes poorly and that only browser tests can reach. After extraction, the algorithm module holds the branches and is covered by fast Node tests, while the entry file is a dozen lines of wiring excluded from coverage and exercised once by a boundary test. Extraction changes what can be measured before aggregate.worker.ts — wiring + algorithm, reachable only through a thread 0% shown after core/aggregate.ts — every branch covered by Node tests worker.ts — wiring excluded; one boundary test proves it is wired The percentage does not change because the tests got better — it changes because the code moved.
A stubborn 0% on a worker file is usually a structural message rather than a tooling problem.

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.


What belongs in each Vitest project Four categories assigned to a project. Pure algorithm tests belong in the Node project because they need no environment at all. Protocol tests belong there too, driven through a real MessageChannel with no thread. Boundary tests belong in the browser project, one per exposed method, proving cloneability and transfer. Component tests belong in the Node or jsdom project with the service module mocked, because they test rendering rather than threading. Sorting a test into the right project pure algorithm — parsing, aggregation, layout maths Node project; no DOM, no worker, milliseconds per test protocol — ids, credits, retries, timeouts Node project, driven through a real MessageChannel boundary — cloneability, transfer, expose timing browser project; one test per exposed method component behaviour — pending, error, stale results mock the service module; the thread is irrelevant here Only the mustard row needs a browser, and only one test per method belongs in it.
Every row that drifts upward into the browser project makes the suite slower without testing anything new.

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.

Frequently Asked Questions

Do I need Vitest browser mode, or is the default environment enough?
The default Node environment is enough for algorithm and protocol tests, which should be most of the suite — Node has MessageChannel and structuredClone, so the protocol layer runs there faithfully. Browser mode is needed only where a real DOM Worker is required: cloneability of browser-specific values, transfer detachment, module-worker resolution, and error propagation from a real thread.
Why does coverage show 0% for my worker file?
Instrumentation is injected per module graph, and a worker started from a URL is a separate graph the coverage provider may not attach to. Two fixes: import the worker’s logic module directly in tests so it is covered as ordinary code, and run browser-mode coverage with the V8 provider, which attributes worker scripts to their source files. Chasing coverage of the entry file itself is rarely worth it — keep the entry trivial instead.

See also