Integration testing a WebSocket server #
Your WebSocket server’s handlers have unit tests, and production still breaks in the seams between them: the upgrade handler accepts a token the router then rejects, a broadcast reaches the sender twice, a close code changes during a refactor and every client starts reconnecting in a loop. Those bugs only appear when a real client talks to a real server over a real socket. Integration tests at that level have a reputation for being slow and flaky — fixed ports that collide in parallel runs, setTimeout(500) waits that are too short on CI and too long locally — but neither is inherent. With an ephemeral port per test file and a small promise-based client, they run in milliseconds and fail deterministically.
Root cause #
Flakiness in WebSocket integration tests comes from two sources. Shared resources: a server listening on a fixed port cannot run in two test files at once, so parallel test runners either serialize or collide with EADDRINUSE. Time-based waiting: sockets are asynchronous in both directions, so tests that send a message and then sleep before asserting are racing the server. On a loaded CI machine the sleep is too short; everywhere else it wastes time.
The fix for both is structural. Listen on port 0 so the OS assigns a free port, and read it back. Replace sleeps with promises that resolve on the specific event the test is waiting for — the next message matching a predicate, the close event with its code — with a timeout that fails the test with a clear message instead of hanging.
Resolution #
Export a factory from your server code that builds the HTTP server and WebSocket server without listening, so tests can start it on port 0. Pair it with a test client that queues incoming messages and exposes next(predicate) and closed() as promises.
// test/harness.ts
import type { AddressInfo } from 'node:net';
import WebSocket from 'ws';
import { createApp } from '../src/app'; // builds http.Server + WebSocketServer, no listen()
const WAIT_MS = 2_000; // generous; tests normally finish in ms
export async function startTestServer() {
const { server } = createApp({ jwtSecret: 'test-secret', redisUrl: undefined });
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r)); // OS picks a free port
const { port } = server.address() as AddressInfo;
return {
url: `ws://127.0.0.1:${port}/ws`,
stop: () => new Promise<void>((r) => server.close(() => r())),
};
}
export class TestClient {
private queue: any[] = [];
private waiters: { match: (m: any) => boolean; resolve: (m: any) => void }[] = [];
readonly ws: WebSocket;
constructor(url: string, token?: string) {
this.ws = new WebSocket(token ? `${url}?token=${token}` : url);
this.ws.on('message', (data) => {
const msg = JSON.parse(String(data));
const i = this.waiters.findIndex((w) => w.match(msg));
if (i >= 0) this.waiters.splice(i, 1)[0].resolve(msg);
else this.queue.push(msg); // keep for a later next()
});
}
opened() { return once(this.ws, 'open', 'open'); }
send(msg: object) { this.ws.send(JSON.stringify(msg)); }
// Resolve with the first (queued or future) message matching the predicate.
next(match: (m: any) => boolean = () => true): Promise<any> {
const i = this.queue.findIndex(match);
if (i >= 0) return Promise.resolve(this.queue.splice(i, 1)[0]);
return withTimeout(new Promise((resolve) => this.waiters.push({ match, resolve })), 'next message');
}
closed(): Promise<{ code: number; reason: string }> {
return withTimeout(new Promise((resolve) =>
this.ws.once('close', (code, reason) => resolve({ code, reason: String(reason) }))), 'close');
}
// Assert nothing matching arrives within a short window (e.g. no self-echo).
async none(match: (m: any) => boolean, windowMs = 100) {
await new Promise((r) => setTimeout(r, windowMs));
if (this.queue.some(match)) throw new Error('unexpected message received');
}
}
function withTimeout<T>(p: Promise<T>, what: string): Promise<T> {
return Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`timed out waiting for ${what}`)), WAIT_MS))]);
}
function once(ws: WebSocket, ev: 'open', what: string) {
return withTimeout(new Promise<void>((r) => ws.once(ev, () => r())), what);
}
// test/room.integration.test.ts
import { afterAll, beforeAll, expect, it } from 'vitest';
import { startTestServer, TestClient } from './harness';
import { signTestToken } from './tokens';
let srv: Awaited<ReturnType<typeof startTestServer>>;
beforeAll(async () => { srv = await startTestServer(); });
afterAll(() => srv.stop());
it('broadcasts to other room members but not back to the sender', async () => {
const alice = new TestClient(srv.url, signTestToken('alice'));
const bob = new TestClient(srv.url, signTestToken('bob'));
await Promise.all([alice.opened(), bob.opened()]);
alice.send({ type: 'room.join', room: 'r1' });
bob.send({ type: 'room.join', room: 'r1' });
await Promise.all([alice.next((m) => m.type === 'snapshot'), bob.next((m) => m.type === 'snapshot')]);
alice.send({ type: 'chat.send', room: 'r1', text: 'hi' });
const got = await bob.next((m) => m.type === 'chat.message');
expect(got).toMatchObject({ text: 'hi', author: { id: 'alice' } });
await alice.none((m) => m.type === 'chat.message'); // no echo to sender
});
it('rejects an expired token with a distinct close code', async () => {
const c = new TestClient(srv.url, signTestToken('carol', { expiresIn: -10 }));
expect(await c.closed()).toMatchObject({ code: 4001 });
});
The none helper is the one place a short wait is legitimate: proving that something does not happen always requires a window. Keep it small and use it sparingly. Everything else waits on events.
Replace external dependencies with in-process equivalents where the test is about your server’s behaviour, not the dependency: an in-memory pub/sub adapter instead of Redis, a fixed clock instead of Date.now(). Keep one smaller suite that runs against real Redis in a container, to test the fan-out path described in scaling WebSocket broadcast with Redis pub/sub.
Edge cases #
Server-side timers. Heartbeat and idle-timeout logic runs on intervals of tens of seconds. Make those intervals configurable in createApp and set them to milliseconds in tests, or use fake timers on the server side — carefully, since ws itself uses real timers for handshake timeouts.
Test isolation. One server per test file is usually the right balance. State that leaks between tests in a file (rooms, presence) should be namespaced per test, for example by using a unique room id per test.
Closing clients. Close every TestClient at the end of each test (or track them and close in afterEach), otherwise server.close() waits for them and the suite hangs at teardown.
Verification #
Run the suite with parallel file execution and repetition to prove it is isolated and deterministic:
npx vitest run test/*.integration.test.ts --pool=forks --poolOptions.forks.singleFork=false
for i in $(seq 1 25); do npx vitest run test/room.integration.test.ts --reporter=dot || exit 1; done
Then check the tests’ sensitivity: change the broadcast to include the sender, or change 4001 to 1008, and confirm the corresponding test fails with a readable message. A test that still passes after the behaviour changes is not testing it.
Operational checklist #
FAQ #
How do I test a WebSocket server in Node.js? #
Start the server on port 0 in the test, connect real ws clients to it, and assert on the messages and close events they receive, using promises that resolve on specific events. Stop the server after the test file.
Should integration tests use a real Redis? #
Most tests should use an in-memory adapter so they are fast and hermetic. Keep a smaller set running against real Redis in a container to test the multi-node fan-out path, since that is where Redis-specific behaviour matters.
How do I test heartbeats without waiting 30 seconds? #
Make the interval configurable and set it to a few milliseconds in tests. Then simulate a dead client by pausing its socket and assert the server terminates it within a few intervals.
Can these tests run in the same suite as frontend tests? #
They can share tooling, but keep them separate: they need a Node environment, not jsdom, and they are slower than component tests. A separate Vitest project or workspace entry keeps configuration clean.
Related #
- Contract Testing WebSocket Messages — replaying recorded sessions on this harness.
- Playwright Tests for WebSocket Apps — the browser end of the stack.
- Building a WebSocket Message Router in TypeScript — code worth testing this way.
- Avoiding Reconnect Loops on Auth Failure — why close codes deserve assertions.
Back to Testing Real-Time Frontends.