Testing WebSocket Connections: Jest, Playwright, and the ws Library
WebSocket testing is one of those areas where developers often wing it — manually opening a browser console, firing a few messages, and calling it done. That works until it doesn't: a race condition in reconnection logic, a dropped message during high load, or a broken event sequence that only surfaces in production. This guide covers the full testing stack for WebSocket applications, from pure unit tests through integration tests to full browser-level Playwright assertions.
The Testing Pyramid for WebSocket Apps
WebSocket applications need tests at three distinct levels:
- Unit tests — test message handlers in isolation, mock the socket object
- Integration tests — spin up a real ws server and client, verify actual message exchange
- E2E tests — use Playwright to intercept WebSocket traffic in a real browser
Each level catches different bugs. Unit tests catch logic errors in handlers. Integration tests catch framing issues, connection lifecycle bugs, and sequencing problems. Playwright tests catch issues that only appear when a real browser negotiates the WebSocket upgrade.
Unit Testing WebSocket Handlers with Mock Clients
The fastest tests mock the WebSocket object entirely. Here's a typical chat server handler:
// handlers/chat.js
function handleMessage(ws, rawMessage, broadcast) {
let message;
try {
message = JSON.parse(rawMessage);
} catch {
ws.send(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (!message.text || message.text.trim() === '') {
ws.send(JSON.stringify({ error: 'Empty message' }));
return;
}
broadcast({ type: 'message', text: message.text, ts: Date.now() });
}
module.exports = { handleMessage };Test it with a mock ws object — no server needed:
// handlers/chat.test.js
const { handleMessage } = require('./chat');
function makeMockWs() {
return {
sent: [],
send(data) { this.sent.push(JSON.parse(data)); },
readyState: 1, // OPEN
};
}
describe('handleMessage', () => {
test('broadcasts valid messages', () => {
const ws = makeMockWs();
const broadcast = jest.fn();
handleMessage(ws, JSON.stringify({ text: 'hello' }), broadcast);
expect(broadcast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'message', text: 'hello' })
);
expect(ws.sent).toHaveLength(0); // sender doesn't get an echo
});
test('rejects malformed JSON', () => {
const ws = makeMockWs();
const broadcast = jest.fn();
handleMessage(ws, 'not json', broadcast);
expect(ws.sent[0]).toEqual({ error: 'Invalid JSON' });
expect(broadcast).not.toHaveBeenCalled();
});
test('rejects empty messages', () => {
const ws = makeMockWs();
handleMessage(ws, JSON.stringify({ text: ' ' }), jest.fn());
expect(ws.sent[0]).toEqual({ error: 'Empty message' });
});
});This runs in milliseconds, has zero I/O, and tests every branch. The mock pattern is straightforward: capture calls to send, assert on what was sent.
Integration Testing with Real ws Connections
Unit tests cover logic but miss protocol issues. For integration tests, spin up a real server:
// server/index.js
const WebSocket = require('ws');
function createServer(port) {
const wss = new WebSocket.Server({ port });
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('message', (data) => {
let msg;
try { msg = JSON.parse(data); } catch { return; }
// Broadcast to all other clients
for (const client of clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ ...msg, from: 'server' }));
}
}
});
ws.on('close', () => clients.delete(ws));
});
return wss;
}
module.exports = { createServer };Integration test with real connections:
// server/index.test.js
const WebSocket = require('ws');
const { createServer } = require('./index');
let server;
beforeAll(() => {
server = createServer(0); // port 0 = random available port
});
afterAll(() => {
server.close();
});
function connect(port) {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('open', () => resolve(ws));
});
}
function nextMessage(ws) {
return new Promise((resolve, reject) => {
ws.once('message', (data) => resolve(JSON.parse(data)));
ws.once('error', reject);
});
}
test('broadcasts message to other connected clients', async () => {
const port = server.address().port;
const sender = await connect(port);
const receiver = await connect(port);
const messagePromise = nextMessage(receiver);
sender.send(JSON.stringify({ text: 'integration test' }));
const received = await messagePromise;
expect(received).toMatchObject({ text: 'integration test', from: 'server' });
sender.close();
receiver.close();
});
test('does not echo back to sender', async () => {
const port = server.address().port;
const client = await connect(port);
let gotMessage = false;
client.on('message', () => { gotMessage = true; });
client.send(JSON.stringify({ text: 'solo' }));
await new Promise((r) => setTimeout(r, 100));
expect(gotMessage).toBe(false);
client.close();
});The port: 0 trick is important — it lets the OS pick a free port, preventing test-to-test interference when running in parallel.
Testing Reconnection Logic
Reconnection is where most WebSocket bugs hide. Test it by deliberately closing the server mid-connection:
// client/reconnect.js
function createReconnectingClient(url, options = {}) {
const { maxRetries = 5, delay = 1000 } = options;
let ws, retries = 0;
const handlers = { message: [], open: [], close: [] };
function connect() {
ws = new WebSocket(url);
ws.on('open', () => {
retries = 0;
handlers.open.forEach((h) => h());
});
ws.on('message', (data) => {
handlers.message.forEach((h) => h(data));
});
ws.on('close', (code) => {
handlers.close.forEach((h) => h(code));
if (retries < maxRetries) {
retries++;
setTimeout(connect, delay * retries);
}
});
}
connect();
return {
on: (event, handler) => handlers[event].push(handler),
close: () => ws.close(),
};
}test('reconnects after server closes connection', async () => {
const wss = new WebSocket.Server({ port: 0 });
const port = wss.address().port;
let openCount = 0;
const client = createReconnectingClient(`ws://localhost:${port}`, { delay: 50 });
client.on('open', () => openCount++);
// Wait for initial connection
await new Promise((r) => setTimeout(r, 100));
expect(openCount).toBe(1);
// Kill all server connections
wss.clients.forEach((c) => c.close());
// Wait for reconnect (delay: 50ms * 1 retry = 50ms + buffer)
await new Promise((r) => setTimeout(r, 300));
expect(openCount).toBe(2);
client.close();
wss.close();
});Playwright WebSocket Interception
Playwright 1.23+ supports WebSocket route interception. This lets you assert on messages sent by the browser without running a real backend:
// playwright/websocket.spec.js
const { test, expect } = require('@playwright/test');
test('chat sends and receives messages', async ({ page }) => {
const wsMessages = [];
// Intercept WebSocket traffic
await page.routeWebSocket('ws://localhost:3000/chat', (ws) => {
ws.onMessage((message) => {
wsMessages.push(JSON.parse(message));
// Echo back a server response
ws.send(JSON.stringify({ type: 'ack', id: JSON.parse(message).id }));
});
});
await page.goto('http://localhost:3000');
await page.fill('[data-testid="message-input"]', 'Hello Playwright');
await page.click('[data-testid="send-button"]');
// Assert the browser sent the right message
await expect.poll(() => wsMessages.length).toBeGreaterThan(0);
expect(wsMessages[0]).toMatchObject({ type: 'chat', text: 'Hello Playwright' });
// Assert UI shows the ack
await expect(page.locator('[data-testid="message-status"]')).toContainText('delivered');
});For asserting on messages received by the browser, use page.on('websocket', ...):
test('displays server push messages', async ({ page }) => {
let serverSocket;
page.on('websocket', (ws) => {
ws.on('framesent', (frame) => console.log('sent:', frame.payload));
ws.on('framereceived', (frame) => {
console.log('received:', frame.payload);
});
});
await page.goto('http://localhost:3000');
// Trigger a server-side push (via API call)
await page.request.post('/api/broadcast', {
data: { message: 'server push' }
});
await expect(page.locator('[data-testid="notification"]'))
.toContainText('server push');
});Testing Message Sequencing
Order matters for WebSocket protocols. Test that messages arrive in the right sequence:
test('processes messages in order', async () => {
const port = server.address().port;
const client = await connect(port);
const received = [];
client.on('message', (data) => received.push(JSON.parse(data)));
// Send three messages rapidly
client.send(JSON.stringify({ seq: 1, text: 'first' }));
client.send(JSON.stringify({ seq: 2, text: 'second' }));
client.send(JSON.stringify({ seq: 3, text: 'third' }));
// Wait for all three
await new Promise((r) => setTimeout(r, 200));
expect(received).toHaveLength(3);
expect(received.map((m) => m.seq)).toEqual([1, 2, 3]);
client.close();
});Error Handling Tests
Test what happens when the server sends an error frame or the connection drops unexpectedly:
test('handles server error gracefully', async () => {
const port = server.address().port;
const client = await connect(port);
const errors = [];
client.on('error', (err) => errors.push(err));
// Force an abrupt close from the server side
server.clients.forEach((c) => c.terminate());
await new Promise((r) => setTimeout(r, 100));
// Client should have received a close event, not crash
// The error array may be empty if ws handles it as a close event
expect(client.readyState).toBe(WebSocket.CLOSED);
});CI Configuration
Run WebSocket integration tests with a proper timeout — they involve real I/O:
// jest.config.js
module.exports = {
testTimeout: 15000,
projects: [
{ displayName: 'unit', testMatch: ['**/*.test.js'], testPathIgnorePatterns: ['/integration/'] },
{ displayName: 'integration', testMatch: ['**/integration/**/*.test.js'], testTimeout: 30000 }
]
};WebSocket tests are inherently timing-sensitive. Use expect.poll() in Playwright and generous-but-bounded timeouts in Jest rather than arbitrary setTimeout sleeps where possible.
What to Test and What to Skip
Always test: message parsing errors, connection lifecycle (open/close/error), broadcasting logic, reconnection with backoff, authentication on upgrade.
Skip: the WebSocket protocol itself (the ws library handles that), TLS handshake behavior (test with real certs in staging), and browser-specific WebSocket quirks (Playwright covers those).
The combination of fast unit tests for handler logic, integration tests for the wire protocol, and Playwright tests for the browser experience gives you a testing layer that catches real bugs without becoming a maintenance burden.