DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

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-Protocol when 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 auth message 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/ws
Enter fullscreen mode Exit fullscreen mode

For actual frames, wscat is the fastest REPL:

npx wscat -c wss://api.example.com/ws \
  -H "Authorization: Bearer $TOKEN" \
  -s graphql-transport-ws
Enter fullscreen mode Exit fullscreen mode

Connect 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 (or event/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" } }
Enter fullscreen mode Exit fullscreen mode

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:

  1. Connect and authenticate; expect the welcome/ack frame within a timeout.
  2. Subscribe; expect the subscription confirmation, then the initial snapshot.
  3. Trigger a change (often a plain REST call to the same resource).
  4. Expect exactly one update frame, matching the payload schema, within a bounded window.
  5. Unsubscribe; trigger another change; expect silence on that channel.
  6. 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");
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)