The Complete Guide to Testing WebSocket APIs
WebSocket testing is one of those areas where developers confidently say "it works fine" until they hit production and discover their chat app drops messages under load, silently ignores malformed frames, or lets unauthenticated clients subscribe to private channels. REST APIs have decades of tooling and conventions. WebSockets are comparatively wild west.
This guide covers everything you need to actually test WebSocket APIs — not just "connect and send a message" but the full lifecycle, including the parts that bite you later.
What the Protocol Actually Does
Before writing a single test, understand what you're testing. WebSocket is a persistent, full-duplex TCP connection that starts life as an HTTP request (the upgrade handshake) and then becomes something else entirely.
The handshake is HTTP. A WS connection begins with a GET request containing Upgrade: websocket and Connection: Upgrade headers. The server responds with 101 Switching Protocols. If authentication lives in HTTP headers or cookies, it happens here — there's no per-message auth in the base protocol.
Frames, not bytes. WebSocket sends data in frames. Text frames carry UTF-8 strings. Binary frames carry raw bytes. Control frames (ping, pong, close) handle connection management. Your application code typically sees complete messages, but the framing layer matters when testing large payloads or network interruptions.
No request/response pairing. Unlike HTTP, there's no inherent correlation between a message you send and a response you receive. The server can push messages at any time. This makes test assertions harder — you're writing event-driven assertions, not "call function, check return value."
Close codes matter. WS defines close codes (1000 = normal, 1001 = going away, 1008 = policy violation, etc.). Many bugs hide in incorrect close codes that clients silently ignore.
Tools for Manual and Exploratory Testing
wscat
wscat is a command-line WebSocket client. Install it once and use it constantly:
npm install -g wscat
# Connect to a server
wscat -c ws://localhost:3000/ws
# Connect with auth headers
wscat -c ws://localhost:3000/ws -H "Authorization: Bearer your-token"
# Connect to secure WS
wscat -c wss://api.example.com/ws
# Set a subprotocol
wscat -c ws://localhost:3000/ws --subprotocol "chat"Once connected, type messages and press Enter. Responses appear inline. Before writing any automated test, use wscat to understand what the server actually does. What does it send on connect? What happens if you send malformed JSON? What close code does it use when it rejects auth?
Postman and Insomnia
Both support WebSocket connections. Postman lets you save connections, script pre-request auth flows, and write response assertions. For teams already living in Postman, this is the path of least resistance. Insomnia's WS support is leaner but usable for exploratory work.
For automated pipelines, neither is great — they're GUI tools. But for documenting expected behavior and sharing reproducible test cases across a team, they're valuable.
websocat
websocat is more powerful than wscat for scripting. It can pipe stdin/stdout to a WebSocket:
# Pipe a file's contents to a WebSocket
cat messages.txt | websocat ws://localhost:3000/ws
# Use in a shell script
echo '{"type":"subscribe","channel":"prices"}' | websocat ws://localhost:3000/wsTesting the Connection Lifecycle
The connection lifecycle has four distinct phases, each with its own failure modes.
1. Handshake and Auth
The most common bug: the server accepts any connection during the handshake and only validates auth after the first message. This creates a window where a connected client can observe server-initiated pushes before being rejected.
Test this explicitly:
// Test: unauthenticated connection should be rejected at handshake
test('rejects connection without auth token', (done) => {
const ws = new WebSocket('ws://localhost:3000/ws');
ws.on('close', (code, reason) => {
expect(code).toBe(1008); // Policy violation
done();
});
ws.on('error', (err) => {
// Also acceptable — server may refuse the TCP connection
done();
});
// Should never open
ws.on('open', () => {
done(new Error('Should not have connected without auth'));
});
});2. Open State and Initial Messages
Many WebSocket servers send an initial message on connect (session ID, config, initial state). Test that this message arrives and has the expected shape:
test('sends welcome message on connect', (done) => {
const ws = new WebSocket('ws://localhost:3000/ws', {
headers: { Authorization: `Bearer ${validToken}` }
});
ws.on('message', (data) => {
const msg = JSON.parse(data);
expect(msg.type).toBe('welcome');
expect(msg.sessionId).toMatch(/^[a-f0-9-]{36}$/);
ws.close();
done();
});
});3. Normal Message Exchange
Send a message, assert the response. The tricky part is that "the response" might not be the next message — the server might send other things in between. Write your message handler to filter by type rather than assuming position.
4. Disconnection and Cleanup
Test both client-initiated and server-initiated closes. Does the server clean up subscriptions? Does reconnecting after a close work correctly? Does the server handle abrupt disconnects (not graceful WS close, just TCP drop)?
Message Validation
If your server accepts JSON messages, test what happens with:
- Valid JSON, wrong schema (missing required fields, wrong types)
- Valid JSON that passes schema but contains invalid values (negative IDs, timestamps in wrong direction)
- Malformed JSON (the string "not json")
- Empty messages
- Very large messages (test your frame size limits)
- Binary frames when text is expected
test('returns error message for malformed JSON', (done) => {
const ws = new WebSocket('ws://localhost:3000/ws', {
headers: { Authorization: `Bearer ${validToken}` }
});
ws.on('open', () => {
ws.send('this is not json');
});
ws.on('message', (data) => {
const msg = JSON.parse(data);
expect(msg.type).toBe('error');
expect(msg.code).toBe('PARSE_ERROR');
// Should NOT close the connection — one bad message shouldn't kill the session
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
done();
});
});Most servers either crash on invalid input or return generic error responses that make debugging hard. Testing this explicitly forces you to define your error response contract.
Error Handling and Edge Cases
Network interruption simulation. The ws.terminate() method (in the ws Node.js library) kills the connection at the TCP level without a proper WS close frame. Use this to simulate abrupt network loss.
Ping/pong. If your server sends pings, verify they're answered. If your server expects pings from the client and disconnects idle connections, test the timeout behavior:
test('disconnects idle connection after timeout', (done) => {
const ws = new WebSocket('ws://localhost:3000/ws', {
headers: { Authorization: `Bearer ${validToken}` }
});
ws.on('close', (code) => {
expect(code).toBe(1001); // Going away
done();
});
// Don't send any messages or pings — wait for server to disconnect
}, 35000); // Set timeout longer than server's idle timeoutRapid reconnection. Connect, disconnect immediately, reconnect. Does the server handle this without leaking connections or state from the previous session?
Message ordering. Send multiple messages in quick succession. Does the server process them in order? Does it process them at all, or does the buffer fill and some get dropped?
Auth Testing
Auth deserves its own section because WS auth is often bolted on as an afterthought.
Token in query string. Common but bad — tokens appear in server logs, proxy logs, browser history. Test it works, then add a note to your security review.
Token in handshake headers. The correct approach. Test that the connection is rejected before the handshake completes (HTTP 401 response, connection never upgrades).
Token expiry mid-session. If a JWT expires while the WS connection is open, what happens? Most servers either let it continue forever (bad) or abruptly close the connection (annoying but at least secure). Test whichever behavior is intended.
Token passed in first message. Some systems don't support headers on the handshake (browser WebSocket API limitations in some environments) and instead require the client to send auth as the first message. If your server does this, verify:
- Sessions that never send the auth message get disconnected after a timeout
- Sessions that send invalid auth get disconnected with an appropriate close code
- Sessions don't receive any data before auth is confirmed
Load Considerations
You don't need a full load test to catch basic scaling issues. A few things to verify at modest scale:
Connection limit. How many concurrent connections can the server handle? This is often limited by OS file descriptor limits, not actual capacity. On Linux: ulimit -n shows the current limit, ulimit -n 65536 raises it.
Memory per connection. Each WS connection holds state in memory. A server that handles 1000 concurrent connections comfortably might OOM at 10,000. Profile memory growth as connection count increases.
Broadcast cost. If your server broadcasts a message to N connected clients, that's N serialization operations and N network writes. The per-message cost that's invisible at 10 connections becomes a bottleneck at 10,000. Test with a realistic connected-client count.
For actual load testing, k6 has solid WebSocket support. The quick smoke test: open 100 connections from a test script and confirm the server stays healthy and latency doesn't degrade significantly.
Test Checklist Before Shipping
Before shipping any WebSocket feature:
- Unauthenticated connections are rejected at handshake (not after first message)
- Invalid tokens get a meaningful close code
- Server sends expected initial message on connect
- All message types return correct responses
- Malformed JSON returns an error response, not a server crash
- Schema validation errors return actionable error messages
- Connection survives brief network interruptions (if reconnect is expected)
- Server-initiated close uses correct close codes
- Subscriptions are cleaned up on disconnect
- Reconnection after close works correctly
- Memory doesn't leak with many connect/disconnect cycles
WebSocket testing isn't fundamentally harder than REST testing — it just requires thinking about state and time more explicitly. The connection has a lifecycle. Messages don't have inherent request/response pairing. These aren't obstacles; they're just the shape of the problem. Once you've written tests that match that shape, you'll catch the class of bugs that "worked fine in manual testing" and then silently broke in production.