How to test WebSocket APIs: handshakes, auth, reconnection, and repeatable scenarios
REST tools train you to think in request-response pairs. WebSocket APIs are conversations: after one HTTP upgrade handshake, either side can send a frame at any time, messages have types and sequence numbers, subscriptions start and stop over the same channel, and the interesting bugs are all about ordering, timing, and reconnection state. A client that can "connect and send JSON" passes the demo and fails in production. This is the testing workflow that actually covers a WebSocket API, from manual exploration to CI.
Step 1: verify the handshake
Before a single application message, the connection is an HTTP/1.1 upgrade request. The things to verify are ordinary HTTP things that ordinary WebSocket tools hide:
- The upgrade request hits the right URL with the right subprotocol header (
Sec-WebSocket-Protocolwhen your API negotiates one, e.g.graphql-transport-ws). - Auth works. Three patterns exist and the API should document which: credentials in the handshake (Authorization header or a short-lived ticket in the query string), a first application-level
authmessage after connect, or per-message tokens. Query-string tokens are common for browser clients but leak into logs; prefer a one-time ticket exchanged for the connection. - The server responds
101 Switching Protocols, not 200, and rejects bad credentials at upgrade time with a normal 401/403 rather than accepting then silently closing.
curl can do the handshake check (it will upgrade and then sit on the socket):
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Authorization: Bearer $TOKEN" \
https://api.example.com/wsFor actual frames, wscat is the fastest REPL:
npx wscat -c wss://api.example.com/ws \
-H "Authorization: Bearer $TOKEN" \
-s graphql-transport-wsConnect with bad credentials on purpose and confirm the failure mode — a clean close frame with a documented code tells clients what happened; a dropped TCP connection does not.
Step 2: define the message contract
WebSocket APIs fail testability when messages are unstructured JSON blobs. A testable protocol has, at minimum:
- A
type(orevent/action) discriminator on every frame in both directions. - A client-generated request id echoed on the matching response, so concurrent requests can be matched out of order.
- A documented error frame shape, not just a closed socket.
- Explicit subscription lifecycle: subscribe → initial snapshot or ack → updates → unsubscribe, with server-initiated messages clearly named.
Example exchange:
// client -> server
{ "type": "subscribe", "id": "req-1", "channel": "project:p_123" }
// server -> client
{ "type": "subscribed", "id": "req-1", "channel": "project:p_123" }
{ "type": "project.updated", "channel": "project:p_123", "data": { "status": "ready" } }This contract belongs in the API documentation even though OpenAPI's native scope is HTTP. The practical convention used in spec-driven workspaces is to document WebSocket channels alongside the OpenAPI document with an x-protocol-style extension describing direction, message types, and payload schemas — the same approach used for SSE — so the message catalog renders in docs, drives mocks, and feeds tests instead of living in a README that rots.
Step 3: write scenarios, not one-shot sends
The unit of WebSocket testing is a scenario with ordered expectations:
- Connect and authenticate; expect the welcome/ack frame within a timeout.
- Subscribe; expect the subscription confirmation, then the initial snapshot.
- Trigger a change (often a plain REST call to the same resource).
- Expect exactly one update frame, matching the payload schema, within a bounded window.
- Unsubscribe; trigger another change; expect silence on that channel.
- Close cleanly; expect the documented close code.
Assertions that catch real defects:
- Exactly-once delivery. Duplicate updates are the most common WebSocket bug, usually from double subscriptions after reconnect.
- Request/response correlation. Two requests in flight must resolve to the right ids; a server that answers only the latest request passes manual testing and fails under load.
- Ordering. Created-then-updated must not arrive reversed; assert on a sequence, not a set.
- Unknown messages. Send a malformed frame and an unknown type; expect a documented error frame, not a dropped connection.
- Backpressure. Subscribe to a high-rate channel and confirm the server batches or drops according to its documented policy rather than ballooning memory.
Step 4: test reconnection deliberately
Reconnection is where WebSocket integrations actually break, and it is never covered by happy-path tools. Cover four cases:
| Case | Server behavior to verify |
|---|---|
| Network drop | Client reconnects with backoff and jitter; no thundering herd |
| Resume with last id | Server replays missed frames from a stream position, or tells the client to refetch a snapshot |
| Auth expired mid-session | Documented close code (e.g. policy code 4401) prompting re-auth, not a silent half-open socket |
| Server restart | Client eventually reconnects; subscriptions are re-established; no duplicate channels |
A half-open connection — the client thinks it is alive, the server forgot it — is the classic ghost bug. Heartbeats (protocol-level pings or application-level ping frames) with a timeout that forces reconnect should be in the contract and the test.
Step 5: automate it
For CI, use a WebSocket client library in your language of tests (websockets in Python, ws in Node) wrapped so scenarios read as sequences:
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
await expectFrame(ws, { type: "welcome" }, 2000);
ws.send(JSON.stringify({ type: "subscribe", id: "r1", channel: "project:p_123" }));
await expectFrame(ws, { type: "subscribed", id: "r1" }, 2000);
await api.patch("/v1/projects/p_123", { name: "Renamed" });
const update = await expectFrame(ws, { type: "project.updated" }, 5000);
assert.equal(update.data.name, "Renamed");expectFrame should match on type and correlation id, ignore unrelated frames (or collect them for ordering checks), and fail with a readable timeout showing what did arrive — "expected project.updated, got [heartbeat, heartbeat]" is a debuggable failure; "timed out" is not.
Run the same scenarios against a mock that emits frames from the documented message schemas before the channel exists, then against staging — the spec-driven workspace approach keeps the message catalog, the mock, and the scenarios in one place, so a schema change updates all three.
Tooling landscape
- wscat / websocat: manual REPLs, the curl of WebSockets.
- Postman / Insomnia / Hoppscotch: GUI frame timelines, good for exploration, weak for sequence assertions in CI and disconnected from an OpenAPI contract.
- Language libraries + your test runner: where real automation lives.
- Spec-driven workspaces (Powerduck): document the channel and message schemas next to the OpenAPI document, generate frame-accurate mocks, and run open-subscribe-trigger-expect scenarios against mock and staging — alongside the HTTP, SSE, and gRPC surfaces rather than in a separate tool.
The demo shows multi-protocol debugging from one spec, and the quickstart covers local setup.
What to read next: how to test Server-Sent Events is the simpler streaming case and shares most of the scenario patterns, and debug every protocol in one workspace explains when to choose SSE, WebSocket, or gRPC.