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

For actual frames, wscat is the fastest REPL:

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

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" } }

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:

CaseServer behavior to verify
Network dropClient reconnects with backoff and jitter; no thundering herd
Resume with last idServer replays missed frames from a stream position, or tells the client to refetch a snapshot
Auth expired mid-sessionDocumented close code (e.g. policy code 4401) prompting re-auth, not a silent half-open socket
Server restartClient 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.