Root cause #
HTTP interception works by sitting in the request/response cycle. A WebSocket has exactly one such cycle — the upgrade — and then becomes a bidirectional stream of frames with no further requests to intercept. Stubbing the upgrade response is not useful either: replying with anything other than a genuine protocol switch simply prevents the socket from opening.
The consequence is that WebSocket testing needs a different control point. There are two, and they sit at opposite ends of the connection. You can replace the client’s WebSocket constructor with a fake, so the application talks to an object you control and you push messages into it directly. Or you can point the application at a real socket server you started locally, and drive that server to emit messages on cue.
The two choices trade fidelity against control. A fake constructor is fast, deterministic and completely under the test’s control, but it does not exercise real framing, reconnection or backpressure — you are testing the application’s message handling, not its socket handling. A local server exercises the real client code path including reconnection, at the cost of a process to manage and a small amount of genuine asynchrony. Most suites need mostly the first and a little of the second.
A third mechanism causes more flakiness than either: connection timing. The application usually opens its socket during bootstrap, so a test that installs its fake after cy.visit() misses the connection entirely and then waits for messages that will never arrive. The fake has to be in place before the application’s scripts run, which in Cypress means the onBeforeLoad hook.
Step-by-step fix #
1. Install a fake socket before the application boots #
Replace the constructor in the application’s window, capturing the instance so the test can push messages into it.
// cypress/support/fake-socket.js
// Trade-off: a fake gives complete control and skips real protocol behaviour —
// reconnection, ping/pong and backpressure are not exercised at all.
export function installFakeSocket(win) {
const sockets = [];
win.WebSocket = class FakeSocket {
constructor(url) {
this.url = url;
this.readyState = 1; // OPEN
this.listeners = {};
sockets.push(this);
setTimeout(() => this.dispatch('open', {}), 0);
}
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
removeEventListener(type, fn) {
this.listeners[type] = (this.listeners[type] ?? []).filter((f) => f !== fn);
}
send(data) { (this.sent ||= []).push(data); } // assertable outbound frames
close() { this.readyState = 3; this.dispatch('close', { code: 1000 }); }
dispatch(type, event) {
for (const fn of this.listeners[type] ?? []) fn(event);
this[`on${type}`]?.(event);
}
};
win.__sockets = sockets;
}
// In the spec — install before any page script runs
cy.visit('/dashboard', { onBeforeLoad: installFakeSocket });
2. Push messages on cue and assert the rendering #
With the instance captured, a message is a synchronous call, so there is nothing to wait out and no sleeps involved.
// Trade-off: driving messages directly makes the test deterministic and means
// the payload shape is whatever the test says — keep it aligned with the real API.
cy.window().then((win) => {
const [socket] = win.__sockets;
socket.dispatch('message', {
data: JSON.stringify({ type: 'price', symbol: 'ACME', value: 42.5 }),
});
});
cy.findByTestId('price-ACME').should('have.text', '42.50');
3. Test reconnection against a real server #
Reconnection logic is the part a fake cannot verify, and it is where real-time features most often break. Start a small server in a Cypress task, and close connections deliberately.
// cypress.config.js — a task that controls a local socket server
// Trade-off: a real server introduces genuine asynchrony back into the test;
// keep these specs few and focused on connection behaviour only.
const { WebSocketServer } = require('ws');
let wss;
setupNodeEvents(on) {
on('task', {
startSocketServer() {
wss = new WebSocketServer({ port: 8099 });
return null;
},
dropConnections() {
for (const client of wss.clients) client.terminate(); // abrupt, like a real drop
return null;
},
broadcast(payload) {
for (const client of wss.clients) client.send(JSON.stringify(payload));
return null;
},
});
}
// The spec asserts that the client reconnects and resumes
cy.task('startSocketServer');
cy.visit('/dashboard?socket=ws://localhost:8099');
cy.findByTestId('connection').should('have.text', 'connected');
cy.task('dropConnections');
cy.findByTestId('connection').should('have.text', 'reconnecting');
cy.findByTestId('connection').should('have.text', 'connected'); // retries succeeded
4. Assert what the client sends, not only what it renders #
Half of a socket contract is outbound: subscriptions, heartbeats, acknowledgements. The fake records them, which makes those assertions straightforward and catches a class of bug that rendering assertions never reach.
// Trade-off: asserting on wire format couples the test to the protocol, which
// is appropriate here because the protocol IS the behaviour under test.
cy.window().then((win) => {
const [socket] = win.__sockets;
expect(JSON.parse(socket.sent[0])).to.deep.equal({ type: 'subscribe', symbols: ['ACME'] });
});
5. Cover the message-ordering edge cases deliberately #
Real feeds deliver out-of-order updates, duplicates and bursts. Each is a branch in the application’s reducer, and each is trivial to produce with a fake and nearly impossible to produce on demand with a real feed.
// Trade-off: these are synthetic scenarios and they are exactly the ones that
// cause production incidents in real-time interfaces.
const send = (socket, payload) =>
socket.dispatch('message', { data: JSON.stringify(payload) });
send(socket, { type: 'price', symbol: 'ACME', value: 42.5, seq: 2 });
send(socket, { type: 'price', symbol: 'ACME', value: 41.0, seq: 1 }); // stale, arrives late
cy.findByTestId('price-ACME').should('have.text', '42.50'); // must not regress
6. Keep the socket teardown honest between tests #
An open socket, a pending reconnect timer or a captured instance array that survives into the next test produces the usual order-dependent failures. Cypress’s test isolation reloads the page between tests, which handles most of it — but a reconnect timer scheduled on a long delay can still fire during the next spec if isolation is disabled, so leave it on for the reasons set out in Clearing Browser Storage Between Tests.
Pitfalls #
- Trying to intercept socket frames with
cy.intercept. Only the upgrade is HTTP. Mitigation: replace the constructor or run a local server. - Installing the fake after
cy.visit(). The application already opened its socket. Mitigation: useonBeforeLoad. - Only testing inbound messages. Subscription and heartbeat bugs go unnoticed. Mitigation: assert the frames the client sends.
- Using a real server for everything. Slow specs and reintroduced asynchrony. Mitigation: fake by default, server only for connection behaviour.
- A fixed port for the local server. Parallel specs collide. Mitigation: allocate a free port per run and pass it to the application.
- Never testing a drop. Reconnection is the most common real-time defect. Mitigation: terminate connections deliberately and assert recovery.
Reliability targets #
| Metric | Target | Notes |
|---|---|---|
| Specs using a fake socket | ≥ 80% of real-time specs | Deterministic and fast |
| Reconnection specs | ≥ 1 per socket integration | Against a real local server |
| Fixed sleeps in socket specs | 0 | Messages are pushed on cue |
| Outbound-frame assertions | 1 per subscription contract | Catches protocol regressions |
| Port collisions in parallel runs | 0 | Dynamic port allocation |
Frequently Asked Questions #
Q: Why does cy.intercept not match my socket URL?
A: It matches the upgrade request, and only that. Once the server responds with a protocol switch there are no further HTTP requests to match, so frames are invisible to it. Stubbing the upgrade response is worse than useless — it prevents the socket from opening at all.
Q: My application uses a client library rather than raw WebSocket. Does this still work? A: It depends on whether the library uses the global constructor. Many do, in which case replacing it works unchanged. Libraries that ship their own transport, or that fall back to long polling, need either their own mocking hooks or a real local server — and the fallback path is worth testing explicitly, because it behaves differently from the socket path.
Q: Should server-sent events be handled the same way?
A: The control points are analogous — replace EventSource or run a local endpoint — but SSE travels over a normal HTTP response, so route interception can stream a body to it, which makes the Playwright approach in Mocking Server-Sent Events in Playwright simpler than the WebSocket equivalent.
Q: Can I assert that the client sends a heartbeat on schedule? A: Yes, and it is worth doing, because a broken heartbeat shows up in production as connections silently dropped by an intermediary. Freeze the clock, advance it past the heartbeat interval, and assert the frame appears in the fake socket’s outbound list. Doing this with real time would make the spec as slow as the interval and reintroduce the timing sensitivity you removed.
Q: How do I test a message burst without making the test slow? A: Dispatch the messages synchronously in a loop against the fake, then assert the settled state once. Because nothing is actually travelling over a network, a thousand messages costs milliseconds — which makes throughput and coalescing behaviour cheap to verify, unlike with a real feed.