Mocking Workers in jsdom Environments

jsdom implements a great deal of the DOM and not workers. A component that creates one throws ReferenceError: Worker is not defined, which leaves two options: run those tests in a real browser, or substitute something that behaves enough like a worker for the component’s purposes.

Both are correct in different places. This guide covers the substitution — how to build one that is faithful where it matters — as part of Testing Workers in CI, under Debugging, Profiling & Production Optimization.


Prefer Mocking the Service, Not the Constructor

If the application follows the singleton-service pattern, the seam already exists and the mock is trivial:

// Panel.test.tsx — jsdom, no Worker needed anywhere
vi.mock('../src/analysis-service', () => ({
  getAnalysis: () => ({
    histogram: vi.fn().mockResolvedValue(new Uint32Array([3, 1, 4])),
    summarise: vi.fn().mockRejectedValue(new Error('parse failed')),
  }),
}));

it('renders the error state when analysis fails', async () => {
  render(<Panel dataset={dataset} />);
  expect(await screen.findByRole('alert')).toHaveTextContent('parse failed');
});

This is the right default because it tests what the component actually owns: which state it renders, when it cancels, whether a stale result can overwrite a newer one. None of those questions involve threads, and answering them without one keeps the test fast and the failure message specific.

The pattern also puts useful pressure on the design. If a component cannot be tested this way, it is constructing its worker itself — the ownership mistake described in Framework Integration Patterns — and the mock difficulty is a symptom rather than the problem.


A Faithful Worker Stub

When a Worker really is needed — testing a pool, a transport, or legacy code you cannot refactor — build the stub on a real MessageChannel so the asynchrony is genuine rather than simulated:

// test/worker-stub.ts
type Handler = (event: MessageEvent) => void;

export class StubWorker implements Partial<Worker> {
  #channel = new MessageChannel();
  #listeners = new Map<string, Set<Handler>>();
  terminated = false;

  constructor(public url: string | URL, public options?: WorkerOptions) {
    // The "worker side": the fake implementation under test control.
    this.#channel.port2.onmessage = (event) => {
      const reply = StubWorker.respond(event.data);
      // structuredClone mirrors what a real postMessage does to the payload.
      if (reply !== undefined) this.#channel.port2.postMessage(structuredClone(reply));
    };
    this.#channel.port1.onmessage = (event) => this.#emit('message', event);
  }

  /** Test-supplied behaviour; default echoes the request id back. */
  static respond: (request: unknown) => unknown = (request) => request;

  postMessage(data: unknown, transfer: Transferable[] = []): void {
    if (this.terminated) return;                       // matches real behaviour: silent
    this.#channel.port1.postMessage(structuredClone(data), transfer);
  }

  addEventListener(type: string, handler: Handler): void {
    if (!this.#listeners.has(type)) this.#listeners.set(type, new Set());
    this.#listeners.get(type)!.add(handler);
  }

  removeEventListener(type: string, handler: Handler): void {
    this.#listeners.get(type)?.delete(handler);
  }

  terminate(): void {
    this.terminated = true;
    this.#channel.port1.close();
    this.#channel.port2.close();
  }

  #emit(type: string, event: MessageEvent): void {
    this.#listeners.get(type)?.forEach((h) => h(event));
    (this as { onmessage?: Handler }).onmessage?.(event);
  }
}

// Install it for the test run.
vi.stubGlobal('Worker', StubWorker);

Four details make this stub worth having over an object with a postMessage spy.

Real asynchrony. Messages arrive on a later task, so code that assumes a synchronous reply fails here as it would in production. A stub that calls the handler synchronously hides ordering bugs and creates new ones.

structuredClone on both directions. Passing a function, a DOM node or a class instance now throws exactly as it would across a real boundary — the single most valuable fidelity the stub can offer, since it catches the most common real-world failure.

terminate() is silent, not throwing. Real workers accept postMessage after termination and drop it. Tests that rely on a throw are testing the mock.

Listener bookkeeping. removeEventListener must actually remove, or a test that asserts on cleanup passes vacuously.

What a stub can and cannot reproduce Two lists. Reproducible with a message-channel stub: asynchronous delivery on a later task, structured-clone rejection of functions and DOM nodes, listener add and remove semantics, silent posting after termination, and error replies. Not reproducible: true parallelism, so a blocking loop in the stub blocks the test thread; transfer detachment, since a stub can simulate but not enforce it; module resolution and bundler output; worker-scope globals such as importScripts and OffscreenCanvas; and the timing characteristics of a real thread boundary. Fidelity boundary of a message-channel stub faithful · delivery on a later task, not synchronously · structured clone rejects functions and nodes · add / remove listener semantics · posting after terminate is silently dropped · error replies and rejection paths · message ordering within one channel enough to test components, pools and transports not reproducible · parallelism — a busy loop blocks the test · transfer detachment as the platform enforces it · module resolution and bundler output · worker-scope globals and importScripts · real thread-boundary latency · cross-origin isolation and SharedArrayBuffer each of these needs one real boundary test A mock is a tool for testing your code, not for testing the platform.
The right-hand column is short and specific — which is why a handful of browser tests covers it completely.

Simulating Failure and Delay

The stub earns its place when it produces conditions a real worker makes awkward to arrange.

// A worker that fails on the third request — retry logic, deterministically.
let calls = 0;
StubWorker.respond = (request: { id: number }) => {
  calls++;
  if (calls === 3) return { id: request.id, ok: false, error: 'transient failure' };
  return { id: request.id, ok: true, value: 42 };
};

// A worker that replies slowly — timeout paths without waiting for real time.
StubWorker.respond = async (request: { id: number }) => {
  await vi.advanceTimersByTimeAsync(5_000);
  return { id: request.id, ok: true, value: 42 };
};

// A worker that never replies — the hang path, which should surface as a timeout.
StubWorker.respond = () => undefined;

Retry-after-transient-failure, timeout-then-recover and never-replies are the three failure shapes worth covering in every transport, and all three are a one-line change here versus a bespoke worker script and real elapsed time otherwise.


Mocking an RPC Proxy

Applications that use a proxy rather than raw messages have an even simpler seam, because the mock is just an object with the same methods:

// The proxy's shape IS the contract, so the mock is type-checked against it.
import type { Remote } from 'comlink';
import type { AnalysisApi } from '../src/analysis.worker';

function fakeAnalysis(overrides: Partial<Remote<AnalysisApi>> = {}): Remote<AnalysisApi> {
  return {
    load: vi.fn().mockResolvedValue(undefined),
    range: vi.fn().mockResolvedValue({ min: 0, max: 10 }),
    movingAverage: vi.fn().mockResolvedValue(new Float64Array([1, 2, 3])),
    ...overrides,
  } as unknown as Remote<AnalysisApi>;
}

vi.mock('../src/analysis-service', () => ({ getAnalysis: () => fakeAnalysis() }));

Because every remote method returns a promise by construction, a mock that returns promises is faithful to the real timing model — there is no synchronous version to accidentally imitate. And typing the mock as Remote<AnalysisApi> means a change to the worker’s API breaks the mock at compile time, which is precisely the drift that untyped mocks allow to rot.

The one thing to preserve is asynchronous arrival. mockResolvedValue resolves on a microtask, whereas a real call resolves a task later; components that assume “the result cannot arrive before the next render” pass in tests and flicker in production. Where that distinction matters, resolve on a macrotask instead: vi.fn(() => new Promise((r) => setTimeout(() => r(value), 0))).


When a Mocked Test Should Become a Real One

A mock is the right tool until the assertion starts describing the platform rather than your code. Three signals mark the transition, and each corresponds to a test that should move to the browser layer.

The test asserts on cloneability or transfer. “Passing this argument works” is a claim about structured clone, and only a real boundary can settle it.

The test needs worker-scope globals. importScripts, OffscreenCanvas, createImageBitmap in worker scope, or SharedArrayBuffer under isolation — a stub that provides these is asserting that your stub is correct.

The test is really about the build. Whether the bundler emitted the worker chunk, whether new URL(...) resolved, whether the production build inlined it — none of that exists in a mocked environment, and all of it breaks in production regularly.

Keeping that boundary explicit prevents the common failure of both kinds of suite: a mock layer that grows until it is simulating the platform badly, and a browser layer that grows until nobody runs it. Write the component test with a mock, write one boundary test per method with a real worker, and let each answer only what it can.


Choosing the seam to mock Two lists. Mocking the service module suits component behaviour, error and pending states, stale-result handling, cancellation on unmount, and anything a designer would call a state. Stubbing the Worker constructor suits pool dispatch logic, request correlation, credit accounting, retry and timeout paths, and legacy code that constructs its own workers and cannot be refactored. Two seams, two kinds of test mock the service module — the default stub the Worker constructor · which state a component renders · error and pending branches · a stale result must not overwrite a newer one · cancellation on unmount · anything that is really about rendering · pool dispatch and slot release · request correlation across replies · credit accounting under a slow consumer · retry and timeout paths · legacy code that constructs its own workers Pick one seam per test file: mocking both makes the dependencies impossible to reason about.
The left column covers most tests. The right column is for the transport itself, where a faithful channel matters.

Gotchas

A synchronous stub hides ordering bugs. If the reply arrives before postMessage returns, code that registers its handler afterwards still works in tests and breaks in production. The MessageChannel avoids this entirely; a plain object with an immediate callback does not.

structuredClone is not in every jsdom version. It arrived in Node 17 and is present in current jsdom setups, but an older environment needs a polyfill — and a stub that skips cloning quietly permits payloads a real boundary would reject.

Transfer lists are accepted and ignored. postMessage(data, [buf]) in the stub does not detach buf, so a test cannot prove transfer works. If detachment matters, assert it in a boundary test; the stub should not pretend.

Global stubs leak between files. vi.stubGlobal must be paired with vi.unstubAllGlobals() in teardown, or a later file that expected Worker to be undefined — for instance one testing the fallback path — sees the stub instead.

A stub that never fails teaches the component nothing. A mock returning a resolved value for every call means the error branch is never rendered and its markup can rot unnoticed. Give every mocked service at least one test where the promise rejects, and one where it never settles.

Mocking the module and stubbing the global. Doing both usually means one of them is doing nothing, and it makes the test’s dependencies hard to reason about. Pick one seam per test file.


A stub that outlives its assumptions. Stubs written against last year’s protocol keep passing after the protocol changes, because nothing type-checks a hand-written message shape. Derive the stub’s request and reply types from the same module the application uses, so a protocol change breaks the mock at compile time rather than in production.


Failure shapes a stub can simulate in one line Four failure shapes. A transient failure on the third call exercises retry logic deterministically. A reply that never arrives exercises the timeout path without waiting in real time. A reply carrying a non-cloneable value proves the error surfaces at the boundary. And replies that arrive out of order prove the correlation logic matches each result to its own request. Conditions that are awkward with a real worker fail on the third call, succeed otherwise retry logic, deterministically, with no flakiness never reply at all the timeout path, without waiting in real time reply with a value that cannot be cloned proves the failure surfaces where you expect it reply out of order proves correlation matches results to their own requests Each is a one-line change to the stub, and each is a bespoke worker script otherwise.
This is what a stub is genuinely good at: producing the conditions production will eventually produce for you.

Performance Note

On a 2023 laptop, 120 component tests against a mocked service module ran in 1.4 s in jsdom. The same 120 tests rewritten against a real worker in browser mode took 26 s — roughly 18× slower for assertions that were identical, because none of them were about the boundary. The mock layer is not a compromise for those tests; it is the correct tool, provided the small set of boundary tests that a mock cannot justify exists alongside it.

Frequently Asked Questions

Should I mock the Worker constructor or the service module?
Mock the service module by default — it is one line, it keeps tests readable, and component behaviour is what you are testing. Reach for a Worker stub only when the code under test constructs workers itself and you cannot refactor that, or when you are testing the transport layer’s own logic and want a faithful asynchronous channel underneath it.
Does a mock prove my worker code works?
No, and it is important to be explicit about that. A mock proves the component handles pending, success and failure states. It cannot prove arguments are cloneable, that transfers detach, that expose ran in time, or that the bundler emitted the worker at all. Those need a real browser and a small number of boundary tests, which is why the mock layer and the boundary layer are complementary rather than alternatives.

See also