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.

Why chunk boundaries matter to a stream parser A whole-body mock delivers every event in one read, while a real network can split one event across two reads; a parser that assumes whole events per read handles the first and corrupts the second, so tests must split events deliberately. Whole-body mock — one read data: {"t":"Hel"} data: {"t":"lo"} data: {"t":" world"} Real network — an event split across reads data: {"t":"Hel"} data: {"t ":"lo"} data: {"t":" world"} a parser must buffer the partial event — only a split-chunk test proves it does
The whole-body mock never splits an event, so it can never catch the most common streaming parser bug.

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');
});
The states a streaming UI passes through A streaming answer moves from waiting to receiving to complete, or from receiving to interrupted if the stream fails, or to cancelled if the user navigates away; each state needs its own assertion and each transition is driven by the controlled stream. waiting receiving partial render complete interrupted cancelled close() fail() unmount
Each transition is a method on the controlled stream, which is what makes every state reachable on demand.

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.

What each streaming test covers Split-chunk tests cover parsing, progressive tests cover partial rendering, failure tests cover interrupted states, and abort and reconnect tests cover resource cleanup and resumption. split chunks parser buffering progressive partial rendering mid-stream failure interrupted state abort, reconnect cleanup, resumption
Four small tests cover the behaviours a whole-body mock skips entirely.

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.