Testing Streaming AI Interfaces with Cypress Without Asserting Every Token A developer outlined a testing approach for streaming AI interfaces that avoids asserting on individual token chunks, instead normalizing transport events into a typed AgentEvent stream and a small user-visible state machine. The method uses a synthetic event source exposed on the window so Cypress tests can control causality without calling a model, and prioritizes edge cases like stale run events, cancellation, partial failures, replayed events, and interrupted streams over the happy path. A streaming AI test becomes brittle the moment it expects this exact sequence: Hel → Hello → Hello, I → Hello, I can help Chunk boundaries are transport details. They can change with buffering, provider behavior, network timing, or a client-library upgrade while the visible result remains correct. The UI still has deterministic responsibilities. It must show progress, distinguish a tool call from ordinary text, ignore stale events, support cancellation, and end in a coherent state. Test those contracts instead of every token. Do not let the component treat an incoming byte stream as an undocumented collection of callbacks. Normalize it first: type AgentEvent = | { type: "started"; runId: string } | { type: "text delta"; runId: string; text: string } | { type: "tool started"; runId: string; name: string } | { type: "tool completed"; runId: string; name: string } | { type: "completed"; runId: string } | { type: "failed"; runId: string; code: string }; The renderer can derive a small set of user-visible states: idle → connecting → streaming → using tool → streaming → complete └──────────────→ failed That state machine is the stable testing surface. “Two chunks arrived instead of three” usually is not. For component or end-to-end tests, put the transport behind an interface. In test builds, expose a synthetic event source: declare global { interface Window { agentTestStream?: { emit event: AgentEvent : void }; } } Now Cypress controls causality without calling a model: js it "shows a tool phase and completes the answer", = { cy.visit "/assistant?stream=test" ; cy.get " data-testid=ask " .click ; cy.window .then { agentTestStream } = { agentTestStream .emit { type: "started", runId: "run-1" } ; agentTestStream .emit { type: "text delta", runId: "run-1", text: "Checking the policy. ", } ; agentTestStream .emit { type: "tool started", runId: "run-1", name: "search policy", } ; } ; cy.get " data-testid=status " .should "contain", "Searching policy" ; cy.window .then { agentTestStream } = { agentTestStream .emit { type: "tool completed", runId: "run-1", name: "search policy", } ; agentTestStream .emit { type: "text delta", runId: "run-1", text: "Returns are accepted within 30 days.", } ; agentTestStream .emit { type: "completed", runId: "run-1" } ; } ; cy.get " data-testid=answer " .should "contain", "Checking the policy" .and "contain", "30 days" ; cy.get " data-testid=status " .should "contain", "Complete" ; } ; This verifies progressive rendering and the final semantic landmarks. It does not care how the provider would divide the sentence. The highest-value cases are usually not the happy path. Start run-1 , then start run-2 . Emit a late delta from run-1 and assert that it is ignored. Every event should carry a run identifier, and the renderer should accept only the active one. After the user clicks Stop, emit another delta. The UI must remain cancelled and must not append the text. Also assert that the transport's abort function was called. Partial prose should not leave the interface looking complete. Emit tool started , then failed , and assert that the error is visible while the incomplete answer is clearly marked. Reconnects can replay events. Give events stable IDs or sequence numbers and assert that a duplicate delta is not rendered twice. Close the transport after several deltas but before completed or failed . The UI should move to an explicit interrupted state instead of presenting partial prose as a finished answer. This catches a class of bugs that an HTTP status assertion cannot see. cy.intercept is useful for controlling the initial request, authentication errors, HTTP status, and response delay. It is less useful when the test needs precise control over a long series of application-level stream events. An injected adapter keeps the browser behavior real while making the event schedule deterministic. Keep one integration test against the actual streaming endpoint to verify framing and parsing. Keep most UI state tests provider-free and synthetic. That split makes failures easier to classify: protocol problem or rendering problem. At the protocol layer, include fixtures where one logical event is split across network chunks and several events arrive in one chunk. Your parser must reconstruct frames before the UI reducer sees them. The component test can then remain deliberately unaware of TCP, SSE, or fetch buffering. A robust streaming test usually checks: It should rarely check every intermediate string. For accessibility, assert that status changes are announced once and that rapidly arriving deltas do not flood a live region. The visual answer may update continuously while assistive technology receives milestone events such as “searching,” “approval required,” and “complete.” Streaming interfaces are asynchronous state machines wearing a chat UI. Once tests target that state machine, they become both stricter about real bugs and less sensitive to irrelevant token timing.