End-to-End Testing SSE with Playwright Permalink to this section
Part of Testing & Load Testing SSE Endpoints, under Backend Stream Generation & Connection Management.
Unit tests with a fake EventSource prove your reducer is correct. They cannot prove that the browser’s real EventSource dispatches your named events, reconnects with the right Last-Event-ID after the server closes the connection, sends cookies with withCredentials, or that the UI shows “reconnecting” at the right moment. Playwright drives a real browser, so it can. This guide builds deterministic end-to-end tests for streaming UIs: controlling what the server sends and when, forcing reconnects, and asserting on what the user sees.
Symptom & Developer Intent Permalink to this section
- Streaming features pass every unit test and break in the browser: listeners never fire, or reconnects lose events.
- E2E tests for live features are flaky, passing or failing depending on timing.
- Tests use
page.waitForTimeout(3000)and still fail on slow CI runners. - Nobody has tested what the UI shows during a reconnect.
- Tests leave streams open and interfere with each other.
The intent is a small set of reliable browser tests for streaming behaviour: first render from a stream, live updates, reconnect with resume, a fatal error state, and cleanup on navigation.
Root Cause Analysis Permalink to this section
Flakiness in streaming E2E tests has two sources. The test cannot control when the server sends events, so it sleeps and hopes; and it cannot observe when the browser has received them, so it sleeps again. Both are fixed by making the test the author of the events and waiting on observable UI state instead of time.
Two server-side tools make this possible: a test control endpoint that publishes an arbitrary event (enabled only in test builds), and a disconnect endpoint that closes all open streams, forcing the browser to reconnect. Alternatively, Playwright’s page.route can fulfil the stream request with a canned body, which is ideal for testing parsing and reconnect without any server logic.
Step-by-Step Resolution Permalink to this section
Step 1 — Add test-only control endpoints Permalink to this section
// Enabled only when NODE_ENV === 'test'. Never deploy these.
if (process.env.NODE_ENV === 'test') {
app.post('/__test/publish', express.json(), (req, res) => {
hub.publish(req.body); // { id, event, data }
res.sendStatus(204);
});
app.post('/__test/drop-streams', (_req, res) => {
for (const s of openStreams) s.destroy(); // simulate a network drop
res.sendStatus(204);
});
}
Step 2 — Assert on the first render and live updates Permalink to this section
// notifications.spec.js
import { test, expect } from '@playwright/test';
test('a published notification appears in the bell', async ({ page, request }) => {
await page.goto('/app');
await expect(page.getByTestId('stream-status')).toHaveText('live'); // stream is open
await request.post('/__test/publish', {
data: { id: '101', event: 'notification', data: JSON.stringify({ id: 101, text: 'Build passed' }) },
});
await expect(page.getByRole('status')).toContainText('Build passed');
await expect(page.getByTestId('bell-count')).toHaveText('1');
});
Waiting for the status indicator to read live before publishing guarantees the subscription exists, which removes the most common race. expect(locator) retries until the assertion passes, so there is no sleep anywhere.
Step 3 — Test reconnect and resume Permalink to this section
test('reconnects and receives events published during the drop', async ({ page, request }) => {
await page.goto('/app');
await expect(page.getByTestId('stream-status')).toHaveText('live');
await request.post('/__test/publish', { data: { id: '1', event: 'notification', data: '{"id":1,"text":"first"}' } });
await expect(page.getByText('first')).toBeVisible();
// Record the Last-Event-ID the browser sends on its next connection.
const reconnect = page.waitForRequest((r) => r.url().endsWith('/api/stream') && r.headers()['last-event-id']);
await request.post('/__test/drop-streams');
await expect(page.getByTestId('stream-status')).toHaveText('reconnecting');
await request.post('/__test/publish', { data: { id: '2', event: 'notification', data: '{"id":2,"text":"second"}' } });
expect((await reconnect).headers()['last-event-id']).toBe('1');
await expect(page.getByText('second')).toBeVisible(); // replayed after reconnect
await expect(page.getByText('first')).toHaveCount(1); // not duplicated
});
This single test proves that the real EventSource resends Last-Event-ID, that the server replays, that the client deduplicates, and that the UI shows the reconnecting state.
Step 4 — Test parsing and fatal errors with page.route Permalink to this section
For client-only concerns, fulfil the stream from the test with a canned body. When the body ends, EventSource reconnects — so the handler can serve different bodies on successive requests:
test('a 401 on reconnect shows the signed-out state', async ({ page }) => {
let calls = 0;
await page.route('**/api/stream', (route) => {
calls += 1;
if (calls === 1) {
return route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: 'retry: 100\n\nid: 1\nevent: notification\ndata: {"id":1,"text":"hello"}\n\n',
});
}
return route.fulfill({ status: 401, body: '' }); // EventSource treats this as fatal
});
await page.goto('/app');
await expect(page.getByText('hello')).toBeVisible();
await expect(page.getByTestId('stream-status')).toHaveText('signed out');
});
The short retry: 100 keeps the test fast. page.route delivers the body all at once, so it is for parsing and state transitions, not for timing behaviour.
Step 5 — Check cleanup on navigation Permalink to this section
test('leaving the page closes the stream', async ({ page }) => {
await page.goto('/app');
const closed = page.waitForEvent('requestfinished', (r) => r.url().endsWith('/api/stream'))
.catch(() => null);
await page.goto('/settings'); // a route without the stream
const streams = await page.evaluate(() => window.__openEventSources?.size ?? 0);
expect(streams).toBe(0);
});
Expose a small test hook (window.__openEventSources) from the stream module in test builds so leaks are directly observable, as discussed in preventing EventSource memory leaks in React.
Step 6 — Test the watchdog and offline state with fake time Permalink to this section
Silence watchdogs and backoff timers run on intervals of seconds to minutes. Playwright’s clock API lets a test install fake timers in the page and fast-forward them, so a test of “reconnect after 45 seconds of silence” takes milliseconds:
test('silence watchdog forces a reconnect', async ({ page, request }) => {
await page.clock.install();
await page.goto('/app');
await expect(page.getByTestId('stream-status')).toHaveText('live');
const reopened = page.waitForRequest((r) => r.url().endsWith('/api/stream'));
await request.post('/__test/pause-heartbeats'); // server stops sending comments
await page.clock.fastForward('00:46'); // past 2 × 20 s heartbeat + margin
await reopened; // the watchdog reconnected
await expect(page.getByTestId('stream-status')).toHaveText('live');
});
For the offline indicator, context.setOffline(true) cuts the page’s network; assert the indicator appears, restore with setOffline(false), and assert it disappears once the stream reopens. Together these tests cover the parts of the connection UX that users notice most and that are hardest to reproduce by hand.
Validation & Monitoring Permalink to this section
Run the streaming specs with --repeat-each=20 locally before trusting them in CI; any flake that appears is a missing wait on an observable state. In CI, keep streaming specs in their own project with fullyParallel: false if they share a server, or give each worker its own server instance.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can Playwright stream a mocked response slowly?
route.fulfill sends the whole body at once. For timing-sensitive behaviour, run a real test server and drive it through control endpoints; use route.fulfill for parsing and state transitions.
How do I simulate the browser going offline?
context.setOffline(true) cuts the network for the page. Combined with a server-side drop it lets you test the offline indicator and the reconnect after setOffline(false).
Should E2E tests run against a real broker?
Usually not. An in-memory hub behind the same interface keeps tests fast and deterministic; broker integration is better covered by service-level integration tests.
Can I read the EventStream panel from Playwright?
Not directly, but you can observe the stream request and its headers with waitForRequest, and expose received events through a test hook on window if you need to assert on raw frames.