websocket-tests
Test WebSocket protocol behavior - opening handshake (HTTP Upgrade with Sec-WebSocket-Key + Sec-WebSocket-Version: 13), control frames (ping 0x9 / pong 0xA / close 0x8), close-frame status codes (1000 normal, 1001 going-away, 1006 abnormal, 1011 server error), subprotocol negotiation, backpressure, and reconnect with jitter. Works with ws (Node), websockets (Python), or Playwright frame inspection per language. Use when a feature holds a long-lived WebSocket open and reconnect, close-code, or backpressure behavior is unverified.
Install with skills.sh (any agent)
npx skills add testland/qa --skill websocket-testswebsocket-tests
Per RFC 6455 (opens in new window), tests must cover the handshake, control frames, close codes, and subprotocol negotiation - not just message round-trips.
When to use
Step 1 - Pick the client lib
| Stack | Library |
|---|---|
| Node | ws (npm install ws) |
| Browser e2e | Playwright page.on('websocket', …) for frame inspection |
| Python | websockets |
| Go | github.com/gorilla/websocket |
| Java | io.javalin/javalin-testtools or org.glassfish.tyrus/tyrus-client |
Step 2 - Handshake assertions
Per RFC 6455 (opens in new window), the opening handshake requires:
Test that an upgrade is performed (101) and that the accept header matches:
import { WebSocket } from 'ws';
import crypto from 'crypto';
test('handshake completes with correct accept header', async () => {
const ws = new WebSocket('ws://localhost:8080/');
await new Promise((res, rej) => {
ws.once('upgrade', (msg) => {
expect(msg.statusCode).toBe(101);
// server derives accept: base64(sha1(key + RFC 6455 GUID))
const expectedKey = crypto
.createHash('sha1')
.update(ws._req?.getHeader('Sec-WebSocket-Key') + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
expect(msg.headers['sec-websocket-accept']).toBe(expectedKey);
res(null);
});
ws.once('error', rej);
});
ws.close();
});Step 3 - Subprotocol negotiation
The Sec-WebSocket-Protocol header negotiates a subprotocol; the server selects one or none.
test('server picks v2 subprotocol when offered', async () => {
const ws = new WebSocket('ws://localhost:8080/', ['chat-v1', 'chat-v2']);
await new Promise((r) => ws.once('open', r));
expect(ws.protocol).toBe('chat-v2');
ws.close();
});
test('server rejects unknown subprotocol', async () => {
const ws = new WebSocket('ws://localhost:8080/', ['unknown-v99']);
await new Promise((res) => ws.once('close', (code) => { expect(code).toBe(1002); res(null); }));
});Step 4 - Ping/pong keepalive
Control frames include ping (0x9), pong (0xA), close (0x8); payloads <= 125 bytes.
test('client receives pong within 5s of ping', async () => {
const ws = new WebSocket('ws://localhost:8080/');
await new Promise((r) => ws.once('open', r));
const pongReceived = new Promise((resolve) => {
ws.on('pong', () => resolve(true));
});
ws.ping('keepalive');
const got = await Promise.race([
pongReceived,
new Promise((r) => setTimeout(() => r(false), 5000)),
]);
expect(got).toBe(true);
ws.close();
});Deeper recipes
The close-frame status-code matrix (1000/1001/1002/1006/1011) and the backpressure / large-message (maxPayload, 1009) recipes are in references/websocket-test-recipes.md.
Step 5 - Reconnect with jitter
Reconnect logic should exponential backoff + jitter (not RFC 6455 itself, but field-tested practice):
test('client reconnects within 30s after server bounce', async () => {
const ws = createReconnectingClient('ws://localhost:8080/');
await waitForState(ws, 'open');
await bounceServer();
const reconnected = await waitForState(ws, 'open', { timeout: 30_000 });
expect(reconnected).toBe(true);
});Cross-ref error-budget-tests for SLO-driven reconnect budget.
Step 6 - Playwright frame inspection (browser e2e)
test('app sends auth frame on open', async ({ page }) => {
page.on('websocket', (ws) => {
ws.on('framesent', (event) => {
const data = JSON.parse(event.payload as string);
if (data.type === 'auth') {
expect(data.token).toBeTruthy();
}
});
});
await page.goto('https://localhost:3000/dashboard');
});Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Skip handshake test (assume browser handles it) | Server-side Sec-WebSocket-Accept bug ships | Step 2 |
| Test only happy-path message exchange | Reconnect/close-code bugs slip through | Step 5 + close-code recipe (references) |
| Use HTTP polling fallback as "good enough" | Different code path; doesn't validate WS | Test the actual WS path |
| Hard-code reconnect interval, no jitter | Thundering herd on server bounce | Exponential backoff + jitter |
| Skip subprotocol test in versioned APIs | Old clients silently get v2 server response shape | Step 3 |
Limitations
References
WebSocket test recipes
View source (opens in new window)WebSocket test recipes
Close-frame status codes and backpressure / large-message recipes. All codes and frame limits are per RFC 6455 (opens in new window).
Close-frame status code matrix
Standard close codes:
| Code | Meaning |
|---|---|
| 1000 | Normal closure |
| 1001 | Endpoint going away |
| 1002 | Protocol error |
| 1006 | Abnormal closure (no close frame received) |
| 1011 | Server error |
Test the matrix per scenario:
test('server sends 1011 on internal error', async () => {
const ws = new WebSocket('ws://localhost:8080/');
await new Promise((r) => ws.once('open', r));
ws.send(JSON.stringify({ trigger: 'crash' }));
const code = await new Promise((r) => ws.once('close', (c) => r(c)));
expect(code).toBe(1011);
});
test('graceful shutdown sends 1001', async () => {
// server initiates shutdown; client observes 1001
const ws = new WebSocket('ws://localhost:8080/');
await new Promise((r) => ws.once('open', r));
await fetch('http://localhost:8080/admin/shutdown', { method: 'POST' });
const code = await new Promise((r) => ws.once('close', (c) => r(c)));
expect(code).toBe(1001);
});Verify: assert the observed close code equals the code the scenario should trigger; if it is 1006 (abnormal) instead, the server dropped the socket without a close frame - fix the server shutdown path before trusting the code matrix.
Backpressure / large message tests
Control frame payloads are <= 125 bytes; data frames have no upper bound but implementations apply limits. Test the server's maxPayload config:
test('server rejects message > maxPayload', async () => {
const ws = new WebSocket('ws://localhost:8080/');
await new Promise((r) => ws.once('open', r));
const big = 'a'.repeat(2 * 1024 * 1024); // 2 MB
ws.send(big);
const code = await new Promise((r) => ws.once('close', (c) => r(c)));
expect(code).toBe(1009); // Message too big
});Related skills
grpc-streaming-tests
Test gRPC streaming RPCs - Server-streaming (server returns sequence), Client-streaming (client sends sequence), Bidirectional (both sides stream independently). Cover deadline + cancellation + flow control + status codes (CANCELLED, DEADLINE_EXCEEDED) + metadata. Use ghz for load, grpcurl for ad-hoc, language-native test stubs for unit/integration. Use when a service exposes server-, client-, or bidirectional-streaming RPCs and deadline, cancellation, or partial-stream status-code behavior is unverified.
mqtt-tests
Test MQTT v5.0 with Mosquitto broker in CI + paho-mqtt clients - QoS 0 / 1 / 2 delivery semantics, retained messages, Last Will and Testament (LWT), shared subscriptions ($share/group/topic), $SYS topic introspection. Critical for IoT, embedded, and M2M systems where wire-level guarantees matter. Use when a product speaks MQTT on the wire and QoS 1 / 2 redelivery, retained-message state, or LWT behavior needs a broker-backed test - including smoke-testing a new broker auth / ACL / persistence config.
server-sent-events-tests
Test Server-Sent Events (SSE) flows, one-way server-to-client push only (not bidirectional, use websocket-tests for client-to-server messaging): `EventSource` API on the browser side (`onmessage`, `onerror`, `readyState` 0/1/2), event stream format (`data:`, `event:`, `id:`, `retry:`), `Last-Event-ID` reconnect-with-replay header, content-type `text/event-stream`, and HTTP/1.1 connection-pool limits. Use Playwright for browser-side, raw HTTP client for server-side stream tests. Use when a feature pushes updates over `text/event-stream` and the reconnect interval, `Last-Event-ID` replay, or per-origin connection ceiling has no coverage.
sse-load-tests
Load-tests SSE endpoints at scale with k6 - measures concurrent-stream capacity, connection churn, and server memory pressure. Covers the HTTP/1.1 6-connection-per-origin browser ceiling vs HTTP/2 multiplexing, a custom k6 SSE client built on ReadableStream, and threshold gates for TTFB and data throughput. Use when validating whether a server can sustain N concurrent EventSource connections without connection starvation or memory growth.
stomp-amqp-tests
Tests STOMP over WebSocket (Spring, ActiveMQ, RabbitMQ Web STOMP) and AMQP 0-9-1 (RabbitMQ Java client) - frame connect/subscribe/send/ack sequences, ack modes (auto/client/client-individual), exchange and queue declarations, binding routing, Testcontainers RabbitMQ broker, and delivery assertion. Use when validating enterprise Spring or RabbitMQ messaging stacks before deploy.
webhook-replay-tests
Tests inbound webhook receivers for replay-attack resistance: capture incoming webhook payloads + headers, replay against the receiver under test, validate the Standard Webhooks signature scheme (svix-id + svix-timestamp + svix-signature, HMAC-SHA256 over `{id}.{timestamp}.{payload}`), svix-id idempotency dedup, and 5-minute timestamp-window enforcement by signing fixtures at runtime. Does NOT cover outbound delivery, retry-on-5xx, or failure-event exhaustion - those belong to an outbound webhook delivery harness. Use when testing the receiving side of a webhook integration.