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”.

Four things the mock must do A connection lifecycle in four bands. On connection the mock must accept and record the client. On a subscribe frame it must acknowledge before pushing anything. During the session it must answer every ping with a pong or the client closes. On demand it must be able to close mid-stream so reconnection is exercised. Each band names the failure produced by omitting it. 1 · accept and record the connection omit it: the test cannot count reconnects push the client into an array the spec can read 2 · acknowledge the subscribe first omit it: frames arrive before the UI is listening the client usually waits for it before rendering 3 · answer every ping with a pong omit it: the client closes the socket itself reads as a flaky test, not a missing frame 4 · close on demand, mid-stream omit it: reconnection is never exercised the duplicate-render bug ships

Solution

// 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.

One session, one drop, one resume A sequence between the client and the mock. The client connects and subscribes; the mock acknowledges and sends sequence numbers one and two. The mock then closes with code 1006. After the client's reconnect delay it connects again, subscribing with lastSeq set to two, and the mock resumes at sequence three so nothing is delivered twice. Client Mock socket connect + subscribe { channel: "orders" } subscribed · seq 1 · seq 2 close 1006 — abnormal, so the client retries reconnect delay — advanced with fake timers connect + subscribe { lastSeq: 2 } subscribed · seq 3 — nothing repeated wsLog.connections is now 2: exactly one reconnect, which is what the assertion checks.

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.data is a Blob or ArrayBuffer for binary messages, and JSON.parse(String(data)) produces [object Blob] rather than throwing. Branch on typeof 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.link per 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 shared resetMockState described in resetting scenario state between tests.


What the mock should record Three things worth recording from every mocked socket session: the connection count, every inbound frame with its payload, and the order frames were sent in. Together they make reconnection, subscription and ordering assertions possible at all. Connection count catches both no-reconnect and reconnect storms one number, two bugs Every inbound frame subscribe payloads, acknowledgements, pings the socket's request journal Send order what the mock pushed, in sequence lets a test assert rendering order Without a record the only assertions available are about rendered output, which cannot distinguish these failures.

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.

← Back to WebSocket & SSE Mocking