Mocking WebSockets in Vitest #

WebSocket is not defined on the first run. Then, once you have stubbed it, a reconnect test that passes locally and fails one CI run in eight because it waited 1,100 ms for something scheduled at 1,000 ms. Then a suite that “could not close the process” because a socket survived teardown. Real-time frontends are perfectly testable — but only after you stop testing against real time and a real transport.

This page is the fake socket in full, the fake-timer recipes for backoff and heartbeats, and the specific traps that make otherwise-correct tests flaky.

Root cause #

jsdom does not implement WebSocket. Depending on the version you may get nothing at all, which surfaces as a ReferenceError from inside your hook rather than a useful assertion failure. So the first thing every real-time test needs is a stand-in.

The second problem is time. Reconnect backoff, heartbeat intervals and ack timeouts are all defined in milliseconds, and a test that waits for them in wall-clock time is at the mercy of whatever else the CI runner is doing. Fake timers remove that dependency completely: vi.advanceTimersByTime(1000) moves the clock exactly 1,000 ms with no relationship to real duration, so the test is both instant and deterministic.

The third is control. A mock that merely records calls tells you what your code sent; it cannot tell you what your code does when the server replies, drops the connection, or sends a malformed frame. Those are the behaviours worth testing, and they need a fake that the test can drive from the server side.

The test plays the role of the server The hook constructs a fake socket, and the test drives every server-side event explicitly: accepting the connection, emitting messages, and dropping the connection to trigger the reconnect path. The test plays the role of the server Test FakeSocket Hook new WebSocket(url) acceptConnection() onopen — status becomes open emitMessage({ type: 'tick' }) onmessage — state updates dropConnection() onclose 1006 — schedules retry Nothing here is asynchronous in real time — every arrow is a synchronous method call from the test
Because the test is the server, every branch of the connection state machine is reachable in a line of code.

Resolution #

Install the fake globally in a setup file so no test can forget it, and give it explicit server-side controls.

// test/setup.ts — referenced from vitest.config.ts setupFiles
import { beforeEach, afterEach, vi } from 'vitest';

type Listener = (ev: any) => void;

export class FakeSocket {
static instances: FakeSocket[] = [];
static get last(): FakeSocket { return FakeSocket.instances[FakeSocket.instances.length - 1]; }
static reset() { FakeSocket.instances = []; }

static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;

readyState = 0;
sent: string[] = [];
closeCalls: Array<{ code: number; reason: string }> = [];
onopen: Listener | null = null;
onmessage: Listener | null = null;
onerror: Listener | null = null;
onclose: Listener | null = null;
private listeners = new Map<string, Set<Listener>>();

constructor(public url: string, public protocols?: string | string[]) {
FakeSocket.instances.push(this);
}

// Support both handler styles — hooks in the wild use either.
addEventListener(type: string, fn: Listener) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type)!.add(fn);
}
removeEventListener(type: string, fn: Listener) { this.listeners.get(type)?.delete(fn); }
private fire(type: string, ev: any) {
(this as any)['on' + type]?.(ev);
this.listeners.get(type)?.forEach((fn) => fn(ev));
}

send(data: string) {
if (this.readyState !== 1) throw new DOMException('Still in CONNECTING state.', 'InvalidStateError');
this.sent.push(data);
}

close(code = 1000, reason = '') {
if (this.readyState === 3) return; // idempotent, like the real thing
this.closeCalls.push({ code, reason });
this.readyState = 3;
this.fire('close', { code, reason, wasClean: true });
}

// ── server-side controls ────────────────────────────────────
acceptConnection() { this.readyState = 1; this.fire('open', {}); }
emitMessage(payload: unknown) { this.fire('message', { data: JSON.stringify(payload) }); }
emitRaw(data: string) { this.fire('message', { data }); }
emitError() { this.fire('error', { type: 'error' }); }
/** A network cut: no close frame, so the browser reports 1006. */
dropConnection() {
this.readyState = 3;
this.fire('close', { code: 1006, reason: '', wasClean: false });
}
/** A deliberate server close, e.g. a deploy drain. */
serverClose(code: number, reason = '') {
this.readyState = 3;
this.fire('close', { code, reason, wasClean: true });
}
}

beforeEach(() => {
FakeSocket.reset();
vi.stubGlobal('WebSocket', FakeSocket);
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

Two recipes cover most of what you will write. Backoff, where the assertion is about when a socket is created:

test('backoff doubles and resets after a successful reconnect', async () => {
const { result } = renderHook(() => useWebSocket('wss://x.test/ws'));
act(() => { FakeSocket.last.acceptConnection(); });

act(() => { FakeSocket.last.dropConnection(); });
act(() => { vi.advanceTimersByTime(999); });
expect(FakeSocket.instances).toHaveLength(1); // not yet
act(() => { vi.advanceTimersByTime(1); });
expect(FakeSocket.instances).toHaveLength(2); // exactly at the boundary

act(() => { FakeSocket.last.acceptConnection(); }); // success resets the counter
act(() => { FakeSocket.last.dropConnection(); });
act(() => { vi.advanceTimersByTime(1000); });
expect(FakeSocket.instances).toHaveLength(3); // back to the base delay
});

And heartbeats, where the clock does the waiting:

test('closes the socket when a pong never arrives', () => {
renderHook(() => useWebSocket('wss://x.test/ws', { heartbeatMs: 30_000, pongTimeoutMs: 10_000 }));
const socket = FakeSocket.last;
act(() => { socket.acceptConnection(); });

act(() => { vi.advanceTimersByTime(30_000); });
expect(JSON.parse(socket.sent.at(-1)!)).toMatchObject({ type: 'ping' });

act(() => { vi.advanceTimersByTime(10_000); }); // no pong emitted
expect(socket.closeCalls).toHaveLength(1);
});
The flake, and what actually causes it A table matching each common flaky-test symptom in WebSocket suites to its underlying cause and the specific fix. The flake, and what actually causes it Symptom Real cause Fix Timeout in CI only test waits real ms wall clock under load fake timers Hangs forever advance before promise microtask not flushed await, then advance Two sockets, expected one StrictMode double-invoke dev-mode effect replay assert on .last Passes alone, fails in suite leaked global stub or singleton persisted reset in afterEach Process will not exit live socket or interval teardown incomplete assert closeCalls Only one of these is a test-framework problem — the rest are real bugs the flake was pointing at
Most WebSocket test flakiness is a real teardown bug wearing a timing costume.

Verification #

A test suite is only as good as its ability to fail. Break things deliberately and confirm the suite notices.

# Deterministic order, no watch — the baseline
npx vitest run src/realtime

# Ten consecutive runs; a flaky async test nearly always surfaces inside ten
for i in $(seq 1 10); do npx vitest run src/realtime || { echo "FLAKE on run $i"; break; }; done

# Randomised order to expose cross-file state leaks
npx vitest run --sequence.shuffle src/realtime

Then mutate on purpose: remove the clearTimeout from your cleanup and confirm a test goes red; change a 1006 to 1000 in dropConnection and confirm the reconnect test notices. A suite that stays green through both is testing the framework, not your code.

Suite runtime: fake clock versus real waits Tests that advance a fake clock finish in single-digit milliseconds while the same assertions against real timers must wait out the full backoff, heartbeat and timeout intervals. Suite runtime: fake clock versus real waits same assertions, computed from the intervals each test must cross Fake timers ms Real timers ms 0 ms 20k ms 40k ms 60k ms 80k ms Backoff 40k ms Heartbeat Ack timeout 62k ms Full suite
The runtime difference is the sum of every interval the tests would otherwise have to sit through — and it is also where the flakiness lived.

Operational checklist #

FAQ #

Should I use a library like mock-socket instead? #

You can, and it is a reasonable choice if you want a fake that also implements a server object with URL matching. The forty lines above have one advantage worth weighing: you control exactly what the fake does, so when a test fails you are debugging your own code rather than a library’s interpretation of the spec. For most suites the hand-rolled version is less total complexity.

How do I test with React 18 StrictMode? #

Expect every effect to run mount → cleanup → mount in development, so a hook that opens a socket produces two instances before any reconnect. Assert on FakeSocket.last for behaviour and on closeCalls.length for teardown correctness. If a test genuinely needs a single socket, render it outside StrictMode — but first make sure the double-invoke is not exposing a real idempotency bug, which is exactly what it is designed to do.

Why does my test hang after enabling fake timers? #

Because something awaits a real promise before scheduling the timer you are advancing. vi.advanceTimersByTime is synchronous and does not flush microtasks, so the timer has not been scheduled yet when you advance past it. Either await Promise.resolve() first or use await vi.advanceTimersByTimeAsync(…), which interleaves microtask flushes.

Can I share the fake between Vitest and Jest? #

Yes — the class itself has no framework dependency. Only the setup differs: vi.stubGlobal and vi.useFakeTimers() become global.WebSocket = FakeSocket and jest.useFakeTimers(). Keep the class in its own module and import it from both setups.

What should I not test with a fake? #

Anything about the protocol contract: the handshake, subprotocol negotiation, close-code semantics, or the encoding of the envelope. A fake will happily agree with whatever your client believes, which is exactly the assumption that breaks in production. Cover those with one real in-process ws server test per message family, as described in testing real-time frontends.

Back to Testing Real-Time Frontends