You do not need a live LLM to reproduce most bugs in an AI chat interface.
These failures are ordinary UI-state problems:
- the first token arrives but the spinner remains;
- a tool finishes but its card still says Running;
- Stop aborts the request, yet a buffered token appears afterward;
- retry duplicates the partial answer;
- an error removes text the user already received.
Make the stream injectable, then test those event sequences deterministically.
Define an application-level event contract
type ChatEvent =
| { type: 'token'; messageId: string; value: string }
| { type: 'tool.started'; id: string; name: string }
| { type: 'tool.completed'; id: string; summary: string }
| { type: 'completed'; messageId: string }
| { type: 'failed'; messageId: string; message: string }
type ChatStream = (
prompt: string,
signal: AbortSignal,
) => AsyncIterable<ChatEvent>
The React component receives a ChatStream. Production supplies an SSE or fetch-backed implementation. The component test supplies a controlled source.
A small controlled stream
function createTestStream<T>() {
const queued: T[] = []
const waiting: Array<(value: IteratorResult<T>) => void> = []
let closed = false
const iterator: AsyncIterableIterator<T> = {
[Symbol.asyncIterator]() { return this },
next() {
if (queued.length > 0) {
return Promise.resolve({ value: queued.shift()!, done: false })
}
if (closed) return Promise.resolve({ value: undefined, done: true })
return new Promise((resolve) => waiting.push(resolve))
},
}
return {
stream: async function* () { yield* iterator },
push(value: T) {
const resolve = waiting.shift()
resolve ? resolve({ value, done: false }) : queued.push(value)
},
close() {
closed = true
waiting.splice(0).forEach((resolve) =>
resolve({ value: undefined, done: true }),
)
},
}
}
Now the test determines exactly when each event arrives.
Test progressive rendering
it('renders one growing message and a completed tool card', () => {
const source = createTestStream<ChatEvent>()
cy.mount(<ChatPanel createStream={source.stream} />)
cy.findByRole('textbox', { name: 'Message' }).type('Find options')
cy.findByRole('button', { name: 'Send' }).click()
cy.then(() => source.push({
type: 'token', messageId: 'm1', value: 'I found ',
}))
cy.findByTestId('assistant-m1').should('have.text', 'I found ')
cy.then(() => source.push({
type: 'tool.started', id: 't1', name: 'search_options',
}))
cy.findByTestId('tool-t1').should('have.attr', 'data-status', 'running')
cy.then(() => source.push({
type: 'tool.completed', id: 't1', summary: '3 results',
}))
cy.findByTestId('tool-t1').should('have.attr', 'data-status', 'complete')
cy.then(() => {
source.push({ type: 'token', messageId: 'm1', value: 'three.' })
source.push({ type: 'completed', messageId: 'm1' })
source.close()
})
cy.findByTestId('assistant-m1').should('have.text', 'I found three.')
cy.findByRole('button', { name: 'Stop generating' }).should('not.exist')
})
Cypress queries retry while React processes the events. No fixed sleeps or model calls are needed.
Test late events after cancellation
Cancellation is a good adversarial case because transports can have buffered data.
cy.findByRole('button', { name: 'Stop generating' }).click()
cy.findByRole('status').should('contain.text', 'Stopped')
cy.then(() => source.push({
type: 'token',
messageId: 'm1',
value: 'this must be ignored',
}))
cy.findByTestId('assistant-m1').should('not.contain.text', 'must be ignored')
The reducer or stream consumer should ignore events belonging to an aborted generation. Also test duplicate tool completions, failure after partial text, retry, and unmount while streaming.
Test the failure state without deleting useful evidence
A transport can fail after several tokens have rendered. Unless the product intentionally discards them, the partial answer should remain visible and be marked incomplete:
cy.then(() => {
source.push({ type: 'token', messageId: 'm1', value: 'Partial answer' })
source.push({
type: 'failed',
messageId: 'm1',
message: 'Connection lost',
})
})
cy.findByTestId('assistant-m1').should('have.text', 'Partial answer')
cy.findByRole('alert').should('contain.text', 'Connection lost')
cy.findByRole('button', { name: 'Retry' }).should('be.enabled')
Use accessible roles for status, alert, progress, and controls so the test protects keyboard and screen-reader behavior as well as component state. A streaming interface that visually updates but never announces completion is still broken for part of its audience.
Do not overuse cy.clock()
cy.clock() is useful for retry countdowns, debounce, or a delayed “still working” message. It controls timer APIs. It does not automatically control arbitrary Promises, async iterators, or server delivery.
Control the stream for causality; control the clock only for behavior that genuinely depends on time.
Keep a separate provider contract
This component test does not prove that the real provider emits your ChatEvent schema. Keep a smaller integration test for the adapter:
- provider frames parse correctly;
- error frames map to
failed; - abort closes the request;
- secrets and raw prompts are not unintentionally logged.
Model-quality evaluation is another suite again. Separating UI behavior, transport compatibility, and semantic quality keeps failures understandable.
The useful test matrix is event-oriented: success with tokens, success with a tool, failure before the first token, failure after partial output, user cancellation, duplicate events, and retry. It is much smaller and more reproducible than sampling many model sentences.
Which of your current “LLM test” failures could be reproduced as a five-event fixture instead?
Top comments (0)