Use whenever the task is to test a WebSocket endpoint or real-time server: connect, send and receive messages, ping/pong, close codes, reconnect after the server drops, subprotocols and backpressure, with ws, Python websockets or Playwright.

/plugin install qa-realtime-protocols@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

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

  • Testing a real-time service: chat, notifications, collaborative editing, live dashboards.
  • Validating reconnection logic after server-side restart or network partition.
  • Pre-deployment gate: the close-code matrix is correct + ping/pong keepalive is wired.

Step 1 - Pick the client lib

StackLibrary
Nodews (npm install ws)
Browser e2ePlaywright page.on('websocket', …) for frame inspection
Pythonwebsockets
Gogithub.com/gorilla/websocket
Javaio.javalin/javalin-testtools or org.glassfish.tyrus/tyrus-client

Step 2 - Handshake assertions

Per RFC 6455 (opens in new window), the opening handshake requires:

  • HTTP GET, version 1.1+
  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Key: base64 16-byte nonce
  • Sec-WebSocket-Version: 13

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-patternWhy it failsFix
Skip handshake test (assume browser handles it)Server-side Sec-WebSocket-Accept bug shipsStep 2
Test only happy-path message exchangeReconnect/close-code bugs slip throughStep 5 + close-code recipe (references)
Use HTTP polling fallback as "good enough"Different code path; doesn't validate WSTest the actual WS path
Hard-code reconnect interval, no jitterThundering herd on server bounceExponential backoff + jitter
Skip subprotocol test in versioned APIsOld clients silently get v2 server response shapeStep 3

Limitations

  • RFC 6455 is the base spec. WebSocket-over-HTTP/2 (RFC 8441) changes the bootstrap; verify per stack if HTTP/2 is in play.
  • Browser autobahn-suite-style edge cases (fragmentation, extension negotiation) are out of scope here; use Autobahn TestSuite for full conformance.

References

  • RFC 6455 (opens in new window) - WebSocket protocol (handshake, frames, close codes)
  • server-sent-events-tests - one-way push alternative
  • grpc-streaming-test-author (qa-grpc) - typed RPC streaming alternative

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:

CodeMeaning
1000Normal closure
1001Endpoint going away
1002Protocol error
1006Abnormal closure (no close frame received)
1011Server 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
});
  • Three ways to get the realtime job running again while 8443 stays blocked
  • The gateway spends ninety seconds after every deploy doing nothing but handshakes
  • Chat 3.x renders blank message bodies and 2.9 stopped connecting altogether
  • People show as online for hours after they shut the laptop
  • Every deploy produces the same support thread