Playwright Tests for WebSocket Apps #
Unit tests with a fake socket prove your state machine is correct. They cannot prove that the nginx upgrade block is right, that the sticky cookie survives the handshake, that two browser tabs see each other’s edits, or that the app recovers when the network genuinely disappears. Those need a browser and a real deployment — and they need to be written very differently from unit tests, because the temptation to waitForTimeout is overwhelming and every second you sleep is a second of flakiness you have bought.
Root cause #
End-to-end real-time tests fail for two reasons, and neither is the application.
Fixed waits. await page.waitForTimeout(1000) assumes a message arrives within a second. On a loaded CI runner it sometimes does not, and the test fails despite the app being correct. The opposite is just as common: the wait succeeds, the app is broken, and the test passes because the assertion ran against stale state that happened to look right.
Shared state between tests. Real-time apps are stateful by definition. A test that creates a room and leaves it populated changes what the next test sees, and the failure appears in a test that did nothing wrong. Unlike a REST suite, you cannot rely on a database reset alone — connections, presence entries and subscriptions all outlive the page.
The fix for the first is auto-retrying assertions, which Playwright gives you for free through expect(locator) and expect.poll. The fix for the second is a unique room, user or namespace per test, generated from the worker index so parallel workers cannot collide.
Resolution #
Two contexts, one room, no sleeps. This is the canonical real-time end-to-end test: a change made in one browser must appear in another.
import { test, expect, type Page } from '@playwright/test';
// A unique room per test AND per worker, so parallel runs cannot collide.
function roomFor(testInfo: { workerIndex: number; testId: string }): string {
return `e2e-${testInfo.workerIndex}-${testInfo.testId}`;
}
test('an edit in one tab appears in the other', async ({ browser }, testInfo) => {
const room = roomFor(testInfo);
const alice = await browser.newContext();
const bob = await browser.newContext(); // separate context = separate storage + session
const a = await alice.newPage();
const b = await bob.newPage();
await Promise.all([a.goto(`/room/${room}`), b.goto(`/room/${room}`)]);
// Wait for the app's own readiness signal, never for a duration.
await expect(a.getByTestId('ws-status')).toHaveText('Live');
await expect(b.getByTestId('ws-status')).toHaveText('Live');
await a.getByRole('textbox', { name: 'Message' }).fill('hello from alice');
await a.getByRole('button', { name: 'Send' }).click();
// Auto-retrying assertion: polls until it passes or the timeout expires.
await expect(b.getByTestId('messages')).toContainText('hello from alice');
await alice.close();
await bob.close();
});
test('recovers when the network drops and returns', async ({ page, context }, testInfo) => {
await page.goto(`/room/${roomFor(testInfo)}`);
await expect(page.getByTestId('ws-status')).toHaveText('Live');
await context.setOffline(true);
await expect(page.getByTestId('ws-status')).toHaveText('Reconnecting');
await context.setOffline(false);
// The reconnect is subject to backoff, so give this assertion a longer budget
// rather than sleeping — it still passes the moment the socket returns.
await expect(page.getByTestId('ws-status')).toHaveText('Live', { timeout: 15_000 });
await expect(page.getByTestId('missed-count')).toHaveText('0');
});
For the cases where you need to control what the server sends — an error frame, a 1013 close, a malformed payload — Playwright can intercept the socket itself:
test('shows a maintenance banner on a 1013 close', async ({ page }) => {
await page.routeWebSocket('**/ws', (ws) => {
// Do not connect to a real server: answer entirely from the test.
ws.onMessage(() => ws.send(JSON.stringify({ t: 'error', reason: 'maintenance' })));
setTimeout(() => ws.close({ code: 1013, reason: 'try later' }), 100);
});
await page.goto('/room/demo');
await expect(page.getByRole('alert')).toContainText('temporarily unavailable');
});
routeWebSocket is the piece that makes hostile-server scenarios testable at all. Without it, producing a 1013 at will means a server-side test hook, which is a production code path existing purely for tests.
Verification #
Prove the suite is stable before you trust it as a gate.
# Repeat each test three times with tracing on the first retry
npx playwright test --repeat-each 3 --trace on-first-retry
# Run at the parallelism CI will use — collisions only appear under concurrency
npx playwright test --workers 4
# Inspect a failure after the fact; real-time bugs rarely reproduce on demand
npx playwright show-trace test-results/**/trace.zip
The trace is the reason to run Playwright at all for these tests: it captures the network log including WebSocket frames, so a failure tells you whether the message was never sent, never delivered, or delivered and not rendered. That distinction is otherwise near-impossible to make from a screenshot.
Operational checklist #
FAQ #
How many end-to-end tests should a real-time app have? #
Enough to cover each integration boundary once: the upgrade through the real proxy, two-client fan-out, an offline-and-back cycle, and authentication rejection. That is typically four to eight tests. Everything about the state machine — backoff, cleanup, message handling, malformed input — belongs in the fast layer described in mocking WebSockets in Vitest, where it runs in milliseconds and cannot flake.
Does setOffline really close the WebSocket? #
Yes. Playwright’s offline emulation cuts network access at the browser level, and an open WebSocket is terminated as it would be on a real network loss — the client observes a 1006 close, which is precisely the path you want to exercise. Restoring connectivity does not automatically reconnect; that is your reconnect logic doing its job, which is the point of the test.
Can I assert on the WebSocket frames themselves? #
Yes, with page.on('websocket'), which exposes framesent and framereceived events. It is useful for debugging and occasionally for asserting that a heartbeat is actually being sent, but be careful about coupling tests to wire format — an envelope change should not break a UI test. Prefer asserting on what the user sees, and reserve frame assertions for protocol-level checks.
How do I test presence across many users? #
Open several contexts in a loop and assert on the presence list from one of them. Keep the number small — three or four is enough to prove the logic — and remember to close every context in a finally, because a leaked context keeps its connections open and will skew the next test’s presence count. The flapping edge cases are better covered server-side, as in fixing presence flapping and ghost users.
Should end-to-end tests run against production? #
Against a production-like environment, ideally with the same proxy and load-balancer configuration, because those are exactly the layers the test exists to cover. Running against production itself is defensible as a small smoke suite with dedicated test accounts and rooms, but keep it separate from the pull-request gate: a real deployment’s transient blip should not block a merge.
Related #
- Testing Real-Time Frontends — the layering that decides what belongs here versus in unit tests.
- Mocking WebSockets in Vitest — the fast layer where most assertions should live.
- Configuring nginx for WebSocket Upgrades — the proxy configuration these tests are really validating.
- Handling WebSocket Disconnects Gracefully — the UI states the offline test asserts on.
- Queueing WebSocket Messages While Offline — the behaviour the missed-count assertion is checking.
Back to Testing Real-Time Frontends