Mocking Server-Sent Events and Streaming Responses
Streaming responses have gone from niche to everywhere: live dashboards push updates over server-sent events, AI features stream tokens as they are generated, large exports arrive as newline-delimited JSON. The client code that consumes them is more complex than a single await response.json() — it parses a framing format, renders partial state, handles a stream that ends early, reconnects, and cancels when the user navigates away. Testing it with a mock that returns the whole payload at once exercises none of that. This guide covers producing genuine streams from MSW v2 handlers with ReadableStream, emitting server-sent events chunk by chunk under the test’s control, asserting on intermediate UI states, and testing reconnection and cancellation. It sits under external service simulation.
Root Cause Analysis
Stream consumers break in the gaps between chunks. A parser that assumes each chunk contains whole events fails when the network splits an event across two reads — which it will, unpredictably, in production. A UI that renders only after the stream finishes looks correct in a test that delivers everything at once and appears frozen to real users. An error that arrives after half the content has rendered leaves the interface in a state nobody designed.
The mock that returns a complete body at once hides all of this. The client reads one chunk containing everything, parses it in one pass, and renders the final state; every intermediate state, every boundary condition and every mid-stream failure path is skipped. The test passes while the feature’s defining behaviour — incremental delivery — goes unexamined.
The fix is to make the mock stream for real and to let the test decide when each chunk arrives. A handler that returns a ReadableStream the test controls turns chunk timing, chunk boundaries and early termination into explicit test inputs.
Reproducible Setup
A client that consumes a server-sent event stream with fetch and a reader, buffering partial lines between reads.
// src/stream/read-sse.ts
export async function* readSse(res: Response): AsyncGenerator<{ id?: string; event: string; data: string }> {
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) return;
buffer += value;
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const fields = Object.fromEntries(block.split('\n').map((l) => [l.slice(0, l.indexOf(':')), l.slice(l.indexOf(':') + 1).trimStart()]));
yield { id: fields.id, event: fields.event ?? 'message', data: fields.data ?? '' };
}
}
}
// test/msw/stream.ts — a stream the test drives
export function controlledStream() {
const encoder = new TextEncoder();
let controller!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({ start(c) { controller = c; } });
return {
stream,
send: (raw: string) => controller.enqueue(encoder.encode(raw)),
event: (data: unknown, id?: string) =>
controller.enqueue(encoder.encode(`${id ? `id: ${id}\n` : ''}data: ${JSON.stringify(data)}\n\n`)),
close: () => controller.close(),
fail: (e = new Error('stream reset')) => controller.error(e),
};
}
Implementation
Step 1 — Return the controlled stream from an MSW v2 handler. The response headers declare an event stream, and the body is the stream the test will feed.
import { http, HttpResponse } from 'msw';
import { server } from '../../test/msw/server';
import { controlledStream } from '../../test/msw/stream';
function streamRoute() {
const s = controlledStream();
server.use(http.get('/api/answers/stream', () =>
new HttpResponse(s.stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' } }),
));
return s;
}
The controlled stream inverts the usual relationship between a test and a mock. Rather than the mock deciding what happens and the test observing, the test pushes each chunk at the moment it chooses, which is what allows it to assert between chunks. The same helper serves every streaming test in the suite, and it has no timing of its own — nothing arrives until the test says so, so there is nothing to race.
Step 2 — Test the parser against split chunks. Feed an event in two pieces and confirm it is emitted once, whole.
// src/stream/read-sse.test.ts
test('reassembles an event split across two reads', async () => {
const s = streamRoute();
const events: string[] = [];
const done = (async () => {
for await (const e of readSse(await fetch('/api/answers/stream'))) events.push(e.data);
})();
s.send('data: {"t":"Hel');
s.send('lo"}\n\n');
s.close();
await done;
expect(events).toEqual(['{"t":"Hello"}']);
});
Step 3 — Assert on intermediate UI states. Send one event, check the partial render, send the next — the test observes exactly what a user would see as the stream progresses.
test('renders the answer progressively as tokens arrive', async () => {
const s = streamRoute();
render(<StreamingAnswer question="What is a test pyramid?" />);
s.event({ t: 'A test pyramid ' });
expect(await screen.findByTestId('answer')).toHaveTextContent('A test pyramid');
expect(screen.getByRole('status')).toHaveTextContent('Generating…');
s.event({ t: 'balances cost and confidence.' });
s.close();
await waitFor(() => expect(screen.getByTestId('answer')).toHaveTextContent('A test pyramid balances cost and confidence.'));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
Step 4 — Test a stream that fails part-way. The content already shown should remain, and the user should be told the answer is incomplete rather than seeing a generic crash.
test('keeps partial content and flags it when the stream breaks', async () => {
const s = streamRoute();
render(<StreamingAnswer question="…" />);
s.event({ t: 'Partial answer' });
await screen.findByText(/Partial answer/);
s.fail();
expect(await screen.findByRole('alert')).toHaveTextContent('The answer was interrupted');
expect(screen.getByTestId('answer')).toHaveTextContent('Partial answer');
});
Step 5 — Test cancellation when the component unmounts. Navigating away should abort the request; the handler’s signal reveals whether it did.
test('aborts the stream when the user navigates away', async () => {
let aborted = false;
server.use(http.get('/api/answers/stream', ({ request }) => {
request.signal.addEventListener('abort', () => { aborted = true; });
return new HttpResponse(controlledStream().stream, { headers: { 'content-type': 'text/event-stream' } });
}));
const { unmount } = render(<StreamingAnswer question="…" />);
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
unmount();
await waitFor(() => expect(aborted).toBe(true));
});
Step 6 — Test reconnection with the last event identifier. An event-stream client that reconnects should send Last-Event-ID so the server resumes rather than repeating; the handler can inspect it on the second request.
test('resumes from the last received event after a reconnect', async () => {
const seen: Array<string | null> = [];
let first = true;
server.use(http.get('/api/feed', ({ request }) => {
seen.push(request.headers.get('last-event-id'));
const s = controlledStream();
if (first) { first = false; s.event({ n: 1 }, '41'); s.fail(); }
else { s.event({ n: 2 }, '42'); s.close(); }
return new HttpResponse(s.stream, { headers: { 'content-type': 'text/event-stream' } });
}));
await consumeFeedWithReconnect('/api/feed');
expect(seen).toEqual([null, '41']);
});
A final consideration for AI-backed features in particular: the stream often carries more than text — a final event with usage figures, a tool-call event, an error event in-band rather than as a transport failure. Each event type the client handles deserves one test that sends it through the controlled stream, because in-band errors in particular are easy to render as if they were part of the answer.
Verification
Confirm the parser test is meaningful by breaking the buffering: remove the carry-over of partial text between reads. The split-chunk test must fail — it is the only test that reaches that code.
npx vitest run src/stream --reporter=verbose
# ✓ reassembles an event split across two reads
# ✓ renders the answer progressively as tokens arrive
# ✓ keeps partial content and flags it when the stream breaks
# ✓ aborts the stream when the user navigates away
Then confirm cancellation reaches the network by removing the abort from the component’s cleanup. The unmount test must fail, which proves the request would otherwise keep running — and keep costing, for a metered AI endpoint — after the user has left.
Troubleshooting
Symptom: the stream never delivers anything in jsdom. Diagnosis: the environment lacks TextDecoderStream or a spec-compliant ReadableStream. Fix: run streaming tests in Vitest’s node environment or a browser mode, or polyfill the web streams in setup — Node 18+ ships them globally.
Symptom: the test hangs after the last assertion. Diagnosis: the stream was never closed, so the consumer’s read loop waits forever. Fix: always end each test’s stream with close() or fail(), and consider an afterEach that closes any stream left open.
Symptom: all events arrive in one render. Diagnosis: the test enqueued several events before the component had a chance to read, so React batched them. Fix: await a visible intermediate state after each event, as in Step 3, before sending the next.
Symptom: EventSource-based code cannot be intercepted. Diagnosis: some environments do not route EventSource through fetch. Fix: use MSW’s server-sent events support for EventSource, or put the connection behind a small interface and test the consumer with a fake; fetch-based readers, as here, are the most testable option.
FAQ
Should streaming responses be tested end to end?
Once, for the journey, with a real or recorded stream to confirm the browser, proxy and server agree on buffering. Everything else — parsing, partial rendering, failure handling — belongs at the component tier, where the controlled stream makes every state deterministic.
How do I test newline-delimited JSON instead of SSE?
The same controlled stream works; send JSON objects separated by newlines and split them across reads the same way. The parser’s buffering logic is identical in shape, and the split-chunk test is just as important.
What about backpressure?
Most UI consumers read as fast as data arrives, so backpressure rarely matters there. For consumers that process slowly — writing to storage, say — test that a slow consumer does not lose chunks by delaying between reads; the stream buffers until the reader catches up.
How do I test token-by-token AI output without flakiness?
Treat each token as an event from the controlled stream and assert only at chosen checkpoints, never on timing. Put any artificial typing animation behind reduced-motion or a test flag, so what the test observes is the data rather than an animation frame.
Related
- Back to External Service Simulation
- Simulating network errors and timeouts with MSW — failures before the stream starts.
- Simulating WebSocket connections in Playwright component tests — the bidirectional alternative.
- Asserting streaming Suspense boundaries — server-side streaming in React.