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.

A deterministic integration test The test starts the server on port zero and reads the assigned port, connects a client, sends a join and awaits the next snapshot message, which resolves the promise, then stops the server. A deterministic integration test Test Test client Server (port 0) start, read assigned port connect(token) upgrade send join; await next(type=snapshot) snapshot promise resolves stop server No fixed port, no sleep — the test waits for exactly the event it needs
Wait on events, not on time.

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.

Integration suite of 60 tests on CI A suite using a fixed port and sleeps takes forty-two seconds with six flaky runs per hundred; switching to port zero halves the time; waiting on events brings it to about two seconds with no flaky runs. Integration suite of 60 tests on CI illustrative before/after of the two fixes suite seconds flaky runs per 100 0 20 40 60 42 Fixed port + sleeps 18 Port 0 + sleeps Port 0 + event waits
Both fixes speed tests up — the event waits also remove the flakiness.

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.

What to cover at integration level Which concerns belong in unit tests versus integration tests: business logic in units; upgrade authentication, close codes, broadcast recipients and message ordering in integration tests; backpressure and heartbeats partly in both. What to cover at integration level Unit tests Integration tests Handler business logic yes happy path only Upgrade auth and close codes hard to reach yes Broadcast recipients with fakes yes, multi-client Protocol ordering no yes Backpressure, heartbeats partly with short intervals Integration tests earn their keep on the seams between handlers
Test logic in isolation; test the protocol over a real socket.

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.

Back to Testing Real-Time Frontends.