# 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):

``` bash
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:

``` bash
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:

``` jsonc
// 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:

| 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:

``` javascript
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](https://www.powerduck.com/app/?ref=powerduck.com) shows multi-protocol debugging from one spec, and the [quickstart](https://www.powerduck.com/docs/overview/quickstart?ref=powerduck.com) covers local setup.

**What to read next:** [how to test Server-Sent Events](https://www.powerduck.com/blog/how-to-test-sse-server-sent-events/) is the simpler streaming case and shares most of the scenario patterns, and [debug every protocol in one workspace](https://www.powerduck.com/blog/debug-every-protocol-in-one-workspace/) explains when to choose SSE, WebSocket, or gRPC.

