Mocking WebSocket Messages Locally
The live order feed cannot be developed without the broker, so the whole feature is blocked whenever staging is down. This page scripts the conversation instead: a ws handler that answers subscribes and heartbeats, pushes a numbered sequence, and drops the connection on cue.
Context: a socket is a conversation, not a response
An HTTP mock answers a question. A WebSocket mock has to hold up its end of a dialogue: acknowledge a subscribe, answer a ping, push frames unprompted, and close when the test says so. Skipping any of those produces a mock that behaves nothing like the real broker.
Two of them are load-bearing in ways that surprise people. The heartbeat is not optional — a client that sends ping and gets nothing back will usually close the connection itself, which surfaces as a test that mysteriously loses its socket a few seconds in. And sequence numbers are what let a test tell correct ordering from lucky ordering; without them, “the three updates rendered” cannot be distinguished from “they rendered in the wrong order and nobody noticed”.
Solution
1. Link the socket and record connections
// src/mocks/ws-handlers.ts
import { ws } from 'msw';
const stream = ws.link('wss://api.example.com/stream');
/** Everything a test needs to assert on, kept beside the handler. */
export const wsLog = {
connections: 0,
received: [] as unknown[],
reset() { this.connections = 0; this.received = []; },
};
export const wsHandlers = [
stream.addEventListener('connection', ({ client }) => {
wsLog.connections += 1;
client.addEventListener('message', (event) => {
const msg = JSON.parse(String(event.data)) as { type: string; channel?: string };
wsLog.received.push(msg);
if (msg.type === 'ping') {
client.send(JSON.stringify({ type: 'pong', at: Date.now() }));
return;
}
if (msg.type === 'subscribe') {
// Acknowledge BEFORE pushing, or frames arrive before the UI listens.
client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
pushSequence(client);
}
});
}),
];
wsLog is the socket equivalent of an HTTP request journal. Without it a test can only assert on rendered output, and “did the client send the right subscribe payload?” becomes unanswerable.
2. Push a numbered sequence
// src/mocks/ws-handlers.ts (continued)
const SEQUENCE = [
{ type: 'order.updated', seq: 1, id: 'ord_1', status: 'processing' },
{ type: 'order.updated', seq: 2, id: 'ord_1', status: 'shipped' },
{ type: 'order.created', seq: 3, id: 'ord_2', status: 'pending' },
];
function pushSequence(client: { send(data: string): void }, from = 0): void {
for (const frame of SEQUENCE.slice(from)) {
client.send(JSON.stringify(frame));
}
}
Emitting them synchronously is right for most tests: it removes timing from the assertion. Where the interleaving matters — a frame arriving while a fetch is in flight — space them with await delay() instead, and drive the clock as described in simulating network latency in MSW.
3. Close mid-stream and resume
// src/mocks/ws-handlers.ts (continued)
export const dropAfter = (n: number) =>
stream.addEventListener('connection', ({ client }) => {
let sent = 0;
client.addEventListener('message', (event) => {
const msg = JSON.parse(String(event.data)) as { type: string; lastSeq?: number };
if (msg.type !== 'subscribe') return;
client.send(JSON.stringify({ type: 'subscribed' }));
// Resume from where the client says it got to, if it says.
for (const frame of SEQUENCE.slice(msg.lastSeq ?? 0)) {
if (sent >= n) { client.close(1006, 'connection lost'); return; }
client.send(JSON.stringify(frame));
sent += 1;
}
});
});
Close code 1006 is the one to use: it means “abnormal closure, no close frame received”, which is what a dropped connection actually looks like to a client. Closing with 1000 (normal) tells the client the server finished deliberately, and most clients will not reconnect from that — so a reconnection test using 1000 proves nothing.
4. Assert the whole conversation
// src/features/live/OrderFeed.test.tsx
import { beforeEach, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { wsLog } from '../../mocks/ws-handlers';
import { OrderFeed } from './OrderFeed';
beforeEach(() => { wsLog.reset(); vi.useFakeTimers({ shouldAdvanceTime: true }); });
it('subscribes, renders in sequence order, and reconnects once after a drop', async () => {
render(<OrderFeed channel="orders" />);
// The client sent the subscribe the server expects
expect(await screen.findByText(/shipped/i)).toBeInTheDocument();
expect(wsLog.received[0]).toMatchObject({ type: 'subscribe', channel: 'orders' });
// Rows are in sequence order, not arrival order
const rows = screen.getAllByRole('listitem').map((el) => el.textContent);
expect(rows).toEqual(['ord_1 shipped', 'ord_2 pending']);
// One reconnect after the drop — not zero, and not a storm
await vi.advanceTimersByTimeAsync(3_000);
expect(wsLog.connections).toBe(2);
});
Asserting connections === 2 catches both halves of the reconnection bug: a client that never reconnects, and one that reconnects in a tight loop.
Verification
npx vitest run src/features/live --reporter=basic
# In the browser: confirm the socket is intercepted, not proxied
# DevTools → Network → WS should show the connection with no remote address
If DevTools shows a real remote address, the ws.link URL does not match what the client connects to — a trailing slash or a wss versus ws mismatch is the usual cause.
Gotchas and edge cases
-
Binary frames need explicit handling.
event.datais aBloborArrayBufferfor binary messages, andJSON.parse(String(data))produces[object Blob]rather than throwing. Branch ontypeof event.data === 'string'before parsing, or binary traffic silently becomes a parse failure you never see. -
client.close()inside the message handler races the frames you just sent. Sending three frames and closing synchronously can deliver the close before the client has processed them. Where ordering matters,await delay(0)between the last send and the close, so the frames flush first. -
One
ws.linkper URL, not per test. Registering a second link for the same URL in a spec adds a listener rather than replacing one, so both handlers run and frames are duplicated. Reset through the sharedresetMockStatedescribed in resetting scenario state between tests.
Modelling the protocol above the socket
A raw WebSocket carries bytes; almost every real application layers a protocol on top of it, and the mock has to speak that protocol rather than the socket.
Most such protocols share a small set of message kinds: a handshake or authentication frame, subscribe and unsubscribe, an acknowledgement, a heartbeat pair, data frames, and an error frame. A mock that handles only data frames will connect successfully and then behave wrongly in ways that are hard to attribute.
Two details are worth attention.
Acknowledgements are load-bearing. Many clients will not treat a subscription as established until the server confirms it, and some queue outbound messages until then. A mock that pushes data before acknowledging is delivering into a client that is not yet listening, and the frames are usually dropped without any error.
Errors have a shape. A protocol-level error — an unknown channel, an expired token, a malformed subscribe — arrives as a frame, not as a closed socket. A client’s handling of that frame is a real branch, and a mock that only ever closes the connection never exercises it.
There is also a question of who owns the protocol definition. If the client and the mock each encode it independently, they drift, and the drift shows up as a mock that stops working after a backend release. Where the protocol has a schema — a set of message types with payload shapes — generating both the client’s parser and the mock’s matcher from it removes that class of problem entirely, in exactly the way schema-driven generation does for REST.
None of this is more than a hundred lines. The reason it is worth writing down is that a socket mock which handles the happy path only tends to be trusted more than it deserves, precisely because the happy path works so convincingly.
Related
- Stubbing Server-Sent Events in a Dev Server — the one-way transport and its automatic resume
- Stubbing GraphQL Subscriptions Locally — the same conversation over a GraphQL transport
- Modeling CRUD State in a Mock Server — the store these pushed frames should read from
← Back to WebSocket & SSE Mocking