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.
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);
});
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.
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.
Related #
- Testing Real-Time Frontends — which assertions belong at which layer.
- Playwright Tests for WebSocket Apps — the end-to-end layer this deliberately does not cover.
- useWebSocket Cleanup and Teardown Patterns — the teardown contract the close-call assertions verify.
- Building a useWebSocket React Hook with TypeScript — the injectable socket factory that makes any of this testable.
- WebSocket Close Codes Explained — why
dropConnectionreports 1006 andserverClosedoes not.
Back to Testing Real-Time Frontends