End-to-End Testing an AI Chat Widget With Cypress
9 min read · updated August 11, 2026
Cypress can stub the chat endpoint in one line. What it cannot do is hand your widget a body in timed pieces, and a test written as if it could will pass while asserting nothing about streaming at all.
The StaticResponse you actually get
cy.intercept() replies with a StaticResponse. Cypress’s own documentation lists the fields: statusCode, headers, body (an object, string or ArrayBuffer), fixture, forceNetworkError, delay — described as a minimum latency added before the response — and throttleKbps, a maximum transfer rate. There is no field that takes a list of chunks and no callback that is invoked per chunk.
const frames = [
{ choices: [{ delta: { content: "The invoice " } }] },
{ choices: [{ delta: { content: "is overdue." } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
];
const sse =
frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("") +
"data: [DONE]\n\n";
it("renders a streamed answer", () => {
cy.intercept("POST", "/api/chat", {
statusCode: 200,
headers: { "content-type": "text/event-stream" },
body: sse,
}).as("chat");
cy.visit("/support");
cy.getBySel("composer").type("where is my invoice");
cy.getBySel("send").click();
cy.wait("@chat");
cy.getBySel("message-assistant").should(
"have.text",
"The invoice is overdue.",
);
});This is a real and useful test. It exercises the SSE parser, the reducer and the render, and it will fail if any of the three breaks. What it does not exercise is anything that depends on the stream being incomplete at some moment.
One detail decides whether it works at all: body must be a string here, not an object. Pass an object and Cypress will serialise it as JSON and set a JSON content type, which is not what an SSE parser is expecting and produces a failure that looks like a parser bug rather than a fixture bug. Setting content-type explicitly, as above, is the other half of the same precaution — a widget that branches on the response content type will take the JSON branch and never start streaming.
Why a chunked body does not survive
The nearest thing to chunking is throttleKbps, which spreads the same bytes over a longer wall-clock period. It is a byte rate, not a frame boundary, so you cannot say “deliver exactly two frames, then pause”. A test that throttles to some low number and then asserts the partial text is asserting on where the throttle happened to land relative to a JSON boundary, which is a coin flip that changes with the length of your fixture. That is how a suite acquires a test that fails once a fortnight for no reason anyone can reproduce.
The second reason is the transport. If your widget uses EventSource rather than fetch, it opens a long-lived GET that the interceptor answers once and closes — which the widget sees as the server hanging up, and it will reconnect. You get an intercept that fires repeatedly and a test that appears to duplicate messages.
It is worth being precise about what this costs, because the answer is less than it first appears. Everything that depends only on the finished stream — the parsed text, the tool chip, the enabled composer, the message count, every error branch — is fully testable through the intercept. What is out of reach is intermediate state: the typing indicator while tokens are arriving, a stop button that has to interrupt a live read, auto-scroll as a bubble grows, and any debounce that fires between chunks. That is a short list, but it contains most of what users actually report about a streaming interface, which is why the next section exists rather than being a footnote.
Stubbing the transport on window
For anything incremental, replace the transport in the application window before the app boots. Cypress exposes that hook directly, and because the stub is a plain class you control every emission from the test body.
class FakeEventSource {
static instances = [];
constructor(url) {
this.url = url;
this.listeners = {};
FakeEventSource.instances.push(this);
}
addEventListener(type, fn) {
(this.listeners[type] ||= []).push(fn);
}
close() {
this.closed = true;
}
// Test-side helper: push one SSE frame into the widget.
emit(data) {
const event = { data: JSON.stringify(data) };
for (const fn of this.listeners.message || []) fn(event);
}
}
it("shows a typing indicator until the stream finishes", () => {
cy.visit("/support", {
onBeforeLoad(win) {
win.EventSource = FakeEventSource;
},
});
cy.getBySel("composer").type("where is my invoice{enter}");
cy.window().then(() => {
const es = FakeEventSource.instances.at(-1);
es.emit({ choices: [{ delta: { content: "The invoice " } }] });
});
cy.getBySel("typing").should("be.visible");
cy.getBySel("message-assistant").should("have.text", "The invoice ");
cy.window().then(() => {
const es = FakeEventSource.instances.at(-1);
es.emit({ choices: [{ delta: {}, finish_reason: "stop" }] });
});
cy.getBySel("typing").should("not.exist");
cy.getBySel("send").should("be.enabled");
});The cost is honest: the real EventSource parser is now out of the test, so keep the intercept-based test above as well. One test covers parsing an SSE byte stream, the other covers the state machine. Neither covers both, and pretending one does is how a widget ships with a typing indicator that never clears.
Two details make the stub behave like the real object rather than nearly like it. Give it the readyState constants and move through them, because clients commonly queue outbound messages until the transport reports open, and a fake that never reports open queues silently forever and looks like a hung widget. And keep a static list of instances, as above, so the test can reach the one the widget created — a stub the test cannot get a handle on is a stub that can only ever produce a blank screen.
Asserting on the request, not the reply
The strongest assertions in a chat-widget suite are about what the widget sent, because that is fully determined by your code. Alias the intercept and inspect it:
cy.wait("@chat").its("request.body.messages")should have the length you expect after three turns — this catches a history window that silently drops the system message.- The system prompt appears exactly once, and its version header matches the build. Pair with testing that the system prompt is not overwritten.
- Two rapid clicks on send produce one request, not two. Use
cy.get("@chat.all").should("have.length", 1). - No request body carries the raw contents of an uploaded file when the user only attached a reference.
Request assertions have a property the response assertions do not: they cannot rot. The body your widget sends is entirely your code, so an assertion about it is as stable as a unit test, and it is the only part of a chat suite you can safely make strict. Everything downstream of the model is a probability distribution and has to be asserted loosely; everything upstream can be asserted exactly. Put the strictness where it survives.
Error paths that are easy here
The one area where Cypress’s intercept is better than a hand-made stub is failure injection, because forceNetworkError destroys the browser connection outright — a closer imitation of a dropped mobile connection than a fake object resolving a rejected promise.
- Intercept once with
forceNetworkError: true, then a second time with a good response, and assert the widget’s retry actually resends and does not lose the user’s text. - Reply
statusCode: 429with aretry-afterheader and assert the widget waits rather than hammering. Related: simulating a 429 from every provider. - Reply 200 with a body that is valid SSE but whose JSON is malformed in the third frame. The correct behaviour is to keep the first two frames and mark the message incomplete.
- Use
delaylonger than your client-side timeout and assert the timeout path, not a spinner that runs forever.
onBeforeLoad hook are current Cypress surface at the time of writing. Streaming support has been a long-standing request, so check the intercept documentation before assuming a chunk API still does not exist.