Mocking WebSockets with MSW #
Your frontend tests mock HTTP with Mock Service Worker, and the real-time parts are tested with a hand-rolled fake WebSocket class injected through a module mock. The fake drifts from the real API — it never fires error before close, it delivers messages synchronously — and every component that constructs new WebSocket() directly has to be refactored to accept it. Since version 2.6, MSW can intercept WebSocket connections the same way it intercepts fetch: your application code keeps calling the real WebSocket constructor, and handlers you declare per test decide what the “server” says. That makes real-time tests read like the HTTP tests next to them, and the same handlers work in Vitest, in Storybook and in the browser during development.
Root cause #
Module-level fakes replace the WebSocket global or the module that creates it. That works, but it couples every test to how the code under test obtains its socket, and the fake has to reimplement the WebSocket state machine — CONNECTING, OPEN, CLOSING, CLOSED, events in the right order, asynchronous delivery — faithfully enough that timing bugs show up in tests rather than in production. Most hand-rolled fakes are incomplete in exactly the places reconnect and teardown bugs live.
MSW’s interception works at the level of the connection. It patches the global WebSocket so that connections to URLs you declare are routed to your handler, while the client-side object your code receives behaves like a real WebSocket, event ordering included. Your test describes server behaviour — “on connection, send a snapshot; when the client sends ping, answer pong” — rather than simulating the browser.
Resolution #
Declare a WebSocket link for your endpoint with ws.link(url) and add a connection handler. The handler receives a client you can send to, listen on and close, plus a server you can optionally connect to for passthrough. Register default handlers in a shared setup and override them per test with server.use(), exactly as with HTTP handlers.
// test/handlers.ts
import { ws } from 'msw';
export const realtime = ws.link('wss://rt.example.com/ws');
export const defaultHandlers = [
realtime.addEventListener('connection', ({ client }) => {
// Initial snapshot, as the real server sends on connect.
client.send(JSON.stringify({ type: 'snapshot', seq: 10, items: [{ id: 'a', text: 'hello' }] }));
client.addEventListener('message', (event) => {
const msg = JSON.parse(String(event.data));
if (msg.type === 'ping') client.send(JSON.stringify({ type: 'pong' }));
if (msg.type === 'chat.send') {
// Echo as the server would broadcast it back, with a sequence number.
client.send(JSON.stringify({ type: 'chat.message', seq: 11, text: msg.text }));
}
});
}),
];
// test/setup.ts (Vitest, jsdom or happy-dom environment)
import { setupServer } from 'msw/node';
import { defaultHandlers } from './handlers';
export const server = setupServer(...defaultHandlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // drop per-test overrides
afterAll(() => server.close());
// feed.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from './setup';
import { realtime } from './handlers';
import { Feed } from '../src/Feed';
it('renders the snapshot, then live messages', async () => {
render(<Feed url="wss://rt.example.com/ws" />);
expect(await screen.findByText('hello')).toBeInTheDocument();
await userEvent.type(screen.getByRole('textbox'), 'hi there{Enter}');
expect(await screen.findByText('hi there')).toBeInTheDocument();
});
it('shows a reconnecting state when the server drops the connection', async () => {
server.use(
realtime.addEventListener('connection', ({ client }) => {
client.close(1011, 'server error'); // abnormal close right after connecting
}),
);
render(<Feed url="wss://rt.example.com/ws" />);
expect(await screen.findByText(/reconnecting/i)).toBeInTheDocument();
});
Per-test overrides are where MSW pays off: the failure scenarios that matter for real-time code — the server closing with a specific code, messages arriving out of order, a slow snapshot, a flood of events — are each a few lines in the test that needs them. Combine them with fake timers to test backoff schedules deterministically, as described in mocking WebSockets in Vitest.
To exercise broadcast behaviour — several tabs or users in one room — use realtime.broadcast(data) to send to every intercepted client, or broadcastExcept(client, data) to skip the sender. In development, the same handlers can run in the browser through setupWorker so you can build UI states (empty room, busy room, degraded connection) without a backend.
Edge cases #
Environment. Interception patches the global WebSocket. In Node test environments without a browser DOM, make sure a global WebSocket exists (Node 22+ has one; older versions need an environment such as jsdom or happy-dom that provides it) and that your code references the global rather than importing a Node client library, which MSW does not patch.
Unhandled connections. With onUnhandledRequest: 'error', a connection to a URL no handler matches fails the test — useful for catching code that points at the wrong endpoint. Declare links for every endpoint the component opens.
Timing. Messages sent from a connection handler are delivered asynchronously, like real ones. Use findBy* queries or waitFor rather than asserting immediately after render, and do not rely on a message arriving before the component’s first effect runs.
Verification #
Check that the tests exercise what they claim. Temporarily break the reconnect logic in the component — for instance, make it ignore close events — and confirm the “reconnecting” test fails. Then run the suite with --repeat 20 (or a loop) to confirm there is no flakiness from timing assumptions:
for i in $(seq 1 20); do npx vitest run feed.test.tsx --reporter=dot || break; done
Finally, confirm the handlers match the real server’s protocol by sharing message schemas between the mock and the server — the idea behind contract testing WebSocket messages. A mock that speaks a different protocol from production gives confident, wrong tests.
Operational checklist #
FAQ #
Can MSW mock WebSockets? #
Yes, since MSW 2.6 through the ws API. You declare a link for a WebSocket URL and handle connection events, sending messages to the intercepted client and listening to what it sends.
Does MSW work with Socket.IO? #
MSW intercepts the underlying WebSocket, so Socket.IO’s transport is visible, but you would have to speak Engine.IO/Socket.IO framing in your handler. The MSW project provides a binding that handles that framing; otherwise, test Socket.IO clients against its own test utilities.
Can I pass some messages through to a real server? #
Yes. Call server.connect() inside the connection handler to open a real connection, then forward, modify or drop messages in either direction. This is useful for injecting failures into otherwise real traffic during development.
Should I use MSW or a real server in end-to-end tests? #
For end-to-end tests, prefer the real server — the point is to test the integration. MSW belongs in component and integration tests, where speed and scripted scenarios matter; see Playwright tests for WebSocket apps.
Related #
- Mocking WebSockets in Vitest — fake timers and deterministic backoff.
- Contract Testing WebSocket Messages — keeping mocks honest.
- Playwright Tests for WebSocket Apps — browser-level tests.
- Integration Testing a WebSocket Server — the server side.
Back to Testing Real-Time Frontends.