Contract testing WebSocket messages #
The frontend test suite is green, the backend test suite is green, and production breaks: the server now sends createdAt as an ISO string, the client still parses it as a number, and every timestamp renders as “Invalid Date”. Each side was tested against its own idea of the protocol — the frontend against mocks that send numbers, the backend against assertions that expect strings — and nothing tested that the two ideas match. REST APIs have OpenAPI and a mature contract-testing ecosystem. WebSocket protocols usually have nothing, which is why message-shape regressions are among the most common real-time outages. This page builds three layers of contract checks that fit a WebSocket protocol.
Root cause #
A WebSocket protocol is an implicit contract spread across two codebases: the shapes of messages each side sends, the order in which they arrive, and the meaning of each type. Unit tests on either side encode one side’s assumptions. Mocks encode them too — a mock server written by the frontend team sends what the frontend team thinks the server sends. When the real server changes, the mock does not, and the tests keep passing.
WebSocket protocols add two hazards REST does not have. Push messages are sent without a request, so there is no endpoint to point a contract test at; the contract is “whenever X happens, the server sends a message shaped like Y”. And long-lived clients mean the server must remain compatible with old client versions for hours or days after a deploy, as discussed in versioning WebSocket message schemas, so the contract has more than one consumer version at a time.
Resolution #
Layer 1: one schema package. Define every message in a shared package of Zod schemas, and make the server validate outbound messages (in tests and in development), the client parse inbound messages, and the mocks construct messages through the same schemas. A change to a message shape then breaks compilation or a test on whichever side has not been updated.
// packages/protocol/src/messages.ts — imported by server, web client and test mocks
import { z } from 'zod';
export const ChatMessage = z.object({
type: z.literal('chat.message'),
seq: z.number().int().nonnegative(),
id: z.string(),
author: z.object({ id: z.string(), name: z.string() }),
text: z.string().max(4000),
createdAt: z.string().datetime(), // ISO 8601 — the contract, in one place
});
export const Snapshot = z.object({
type: z.literal('snapshot'),
seq: z.number().int().nonnegative(),
messages: z.array(ChatMessage),
});
export const ServerMessage = z.discriminatedUnion('type', [ChatMessage, Snapshot]);
export type ServerMessage = z.infer<typeof ServerMessage>;
// Test helper: mocks must build messages through the schema, so they cannot drift.
export function fixture<T extends z.ZodTypeAny>(schema: T, value: z.input<T>): z.infer<T> {
return schema.parse(value);
}
Layer 2: replay recorded sessions. Shared schemas cannot help when an old client version, compiled against an old schema, is still connected. For those, record real sessions per client version — the frames the client sent, in order — and replay them against the new server in CI, asserting that every server reply and push still parses under that version’s schema.
// server/test/replay.test.ts
import { readdirSync, readFileSync } from 'node:fs';
import WebSocket from 'ws';
import { startTestServer } from './harness';
const SESSIONS_DIR = 'test/sessions'; // e.g. v41-chat.jsonl, v42-chat.jsonl
for (const file of readdirSync(SESSIONS_DIR)) {
it(`replays ${file} without contract violations`, async () => {
const { url, stop } = await startTestServer();
const version = file.split('-')[0]; // v41
const schemas = await import(`@acme/protocol-${version}`); // pinned old package
const frames = readFileSync(`${SESSIONS_DIR}/${file}`, 'utf8').trim().split('\n');
const ws = new WebSocket(url, [`app.${version}`]);
const received: unknown[] = [];
ws.on('message', (d) => received.push(JSON.parse(String(d))));
await new Promise((r) => ws.once('open', r));
for (const f of frames) ws.send(f);
await new Promise((r) => setTimeout(r, 500)); // let replies arrive
for (const msg of received) expect(() => schemas.ServerMessage.parse(msg)).not.toThrow();
ws.close(); await stop();
});
}
Recording is cheap: log inbound frames for a small sample of sessions in a staging environment, strip personal data, and commit a handful per client version. Publish the protocol package with a version per release so old schemas stay importable.
Layer 3: consumer expectations. For push messages, have each client declare what it relies on — “after room.join, I expect a snapshot with messages[].author.name” — as a small JSON or TypeScript file generated from the client’s parsers. The server’s CI loads every supported client’s expectation file and verifies it with a scripted scenario. This is the consumer-driven contract idea from Pact, applied to message sequences; Pact itself supports asynchronous message contracts if you prefer an off-the-shelf tool.
Edge cases #
Optional versus required. Adding a required field to a message the client sends breaks old clients; adding a required field to a message the server sends is safe for old clients only if they ignore unknown fields. Contract tests should run old-client parsers in non-strict mode, as the real clients do, or they report false failures.
Ordering contracts. Some expectations are about sequence — “a snapshot always precedes the first chat.message after joining”. Encode these as scenario assertions in layer 3, not as schema rules.
Mocks built by hand. Any mock that constructs messages as object literals bypasses the schema. Enforce the fixture helper with a lint rule in test directories, so MSW handlers and other fakes cannot drift.
Verification #
Prove each layer catches what it should by introducing a deliberate break in a branch: change createdAt to a number on the server. Layer 1 should fail the server’s outbound-validation test; with the shared package updated too, layer 2 should fail on replays from older client versions. Then add a new optional field and confirm nothing fails — contract tests that block harmless additive changes train teams to ignore them.
In production, validate a small sample of outbound messages against the current schema and count failures by type:
const SAMPLE_RATE = 0.001;
export function sendChecked(ws: import('ws').WebSocket, msg: ServerMessage) {
if (Math.random() < SAMPLE_RATE) {
const r = ServerMessage.safeParse(msg);
if (!r.success) metrics.contractViolations.inc({ type: (msg as any).type });
}
ws.send(JSON.stringify(msg));
}
Operational checklist #
FAQ #
Can I use Pact for WebSocket contracts? #
Pact supports asynchronous message contracts, which fit individual WebSocket messages: the consumer declares the message it expects and the provider proves it can produce it. It does not model connection lifecycles or ordering directly, so combine it with scenario tests for sequences.
Is AsyncAPI useful here? #
Yes, as documentation and a source for generated schemas. AsyncAPI describes channels and message payloads for event-driven APIs, including WebSockets. Generating the shared Zod or JSON Schema definitions from it keeps documentation and tests aligned.
How many recorded sessions do I need? #
A few per client version, covering the main flows (join, send, receive, reconnect) is usually enough. The goal is to exercise each message type the version uses, not to reproduce traffic volume.
Who owns the contract? #
Treat the protocol package like a public API: changes are reviewed by both frontend and backend owners, and removals follow the deprecation process in versioning WebSocket message schemas.
Related #
- Versioning WebSocket Message Schemas — the compatibility rules contracts enforce.
- Validating WebSocket Messages with Zod — runtime validation with the same schemas.
- Mocking WebSockets with MSW — mocks built from fixtures.
- Integration Testing a WebSocket Server — the harness replay runs on.
Back to Testing Real-Time Frontends.