AI UI 不能只测截图:三类状态的契约测试清单
一个聊天页的最终截图完全正确,不代表它能处理中止、重复事件、线程切换、重复命令或“服务端成功但响应丢失”。AI UI 最危险的 Bug,往往发生在两张截图之间。
🎯 本文目标
把 UI/对话/业务三类状态转成可执行不变量;用纯 reducer 测试、组件行为测试、协议夹具、集成测试和故障注入覆盖关键路径;给出一份可作为发布闸门的检查表。
本篇边界
这不是“三套 AI UI 谁赢”的产品横评,也不比较官网组件数量。本文验证的是任何方案都应满足的状态契约。具体框架可以替换,契约不能消失。
先测不变量,再测页面长什么样
三类状态各有一句核心不变量:
UI:用户表达的临时意图不能被无关流式更新覆盖。
对话:每个事件最多应用一次,每个 run 只修改自己的 thread / branch。
业务:每个有副作用的 command 最多提交一次,最终结果以服务端事实收口。截图适合验证布局和视觉回归,却看不到:
- 用户正向上阅读时,下一 token 是否抢走滚动位置;
- 重连重放同一 delta 后,文字是否重复;
- 切换线程后,旧请求是否写入当前页面;
- 数据库已提交但 HTTP 响应丢失时,前端是否危险地再次执行。
所以测试对象应是状态变化轨迹,最终 DOM 只是其中一个投影。
五层测试结构
| 层级 | 测什么 | 速度 | 最适合发现 |
|---|---|---|---|
| 纯函数 / reducer | 事件 → 状态 | 最快 | 重复、乱序、分支污染 |
| 组件行为 | 用户动作 + 状态投影 | 快 | 滚动、草稿、IME、焦点 |
| 协议契约 | server fixture → decoder → reducer | 中 | 字段漂移、未知事件、断帧 |
| API / 集成 | DB、run、command | 中 | 幂等、权限、版本冲突 |
| E2E / 故障注入 | 浏览器 + 网络 + 服务 | 慢 | 刷新、离线、竞态、恢复 |
不要把所有情况都塞进 E2E。能在 reducer 里稳定证明的性质,不必用 30 秒浏览器测试猜。
建一套确定性的假流
真实模型输出慢、昂贵且不确定,不应成为状态测试的默认依赖。先维护协议夹具:
export async function* fakeStream<T>(
events: T[],
delayMs = 0,
): AsyncGenerator<T> {
for (const event of events) {
if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs))
yield structuredClone(event)
}
}
export const orderAnswer = [
event(1, 'message.start', { role: 'assistant' }),
event(2, 'part.start', { partId: 'p1', kind: 'text' }),
event(3, 'part.delta', { partId: 'p1', delta: '订单' }),
event(4, 'part.delta', { partId: 'p1', delta: '已发货' }),
event(5, 'part.complete', { partId: 'p1' }),
event(6, 'run.complete', { finishReason: 'stop' }),
]同一套 fixture 应能喂给 reducer、mock server 和浏览器测试。这样失败时可以先判断是协议、状态还是 DOM 问题。
必须准备的变体:
- 正常完成;
- 每个字符一个 delta 与整句一个 delta;
- 中途
run.error; - 缺少 part complete;
- 同一 event 重复两次;
- sequence 缺口 / 乱序;
- 两个 tool call 交错;
- 未知新事件类型。
UI 状态:测用户意图是否被尊重
1. 自动滚动
至少覆盖三条轨迹:
near bottom + 新 token → 跟随到底部
用户向上滚动 + 新 token → 保持阅读位置,显示“回到底部”
用户点击“回到底部” + 新 token → 恢复跟随不要只断言 scrollTop === scrollHeight。更可靠的是断言“用户向上滚后,新事件不会调用滚到底部命令”,并单独集成验证实际容器。
2. 草稿与线程
thread A 输入一半
→ 切到 B 并输入
→ 回到 A
→ A 的草稿仍在,且没有进入 messages还应测试提交成功后只清当前 thread 草稿,提交失败时保留可编辑内容。
3. IME、附件和焦点
- 中文输入 composition 期间按 Enter 不提交;
compositionend后才按产品规则提交; - 附件上传失败不会删除其他已成功附件;
- 删除附件会撤销预览 URL,避免内存泄漏;
- 流式重渲染不会反复夺取输入框焦点;
- reasoning 折叠状态以稳定 part ID 保存,不因新增 delta 自动展开。
这些测试的共同点:流式消息变化不能覆盖独立 UI 状态。
对话状态:用 reducer 证明事件轨迹
重复事件不会重复文本
import { describe, expect, it } from 'vitest'
describe('conversation reducer', () => {
it('applies an event at most once', () => {
const delta = event(3, 'part.delta', {
partId: 'p1',
delta: '已发货',
})
const once = reduce(seedAtSequence(2), delta)
const twice = reduce(once, delta)
expect(textOf(twice, 'p1')).toBe('已发货')
expect(twice.lastSequence).toBe(3)
})
})sequence gap 不应静默吞掉
it('stops at a sequence gap', () => {
const state = seedAtSequence(7)
expect(() => reduce(state, event(9, 'part.delta', payload)))
.toThrow('expected 8')
})中止、重试与分支
需要验证:
- stop 后 run 进入 cancelled / interrupted,不再接受迟到 delta;
- retry 创建新
runId,不覆盖旧 run 的诊断信息; - regenerate 创建同一父节点下的新回答分支;
- 编辑历史问题后,可见消息仍是一条从根到叶的路径;
- 切 thread 后,旧 thread 的完成事件只更新旧 thread;
- 刷新恢复先应用 snapshot,再应用其 sequence 之后的事件。
建议把“可见分支是一条连续路径”写成属性检查,而不是只测一个固定例子。
协议契约:故意把网络切得很难看
TCP / fetch 不保证一次 read 对应一次事件。decoder 测试必须把同一 payload 按不同边界切分:
完整一帧
半个 UTF-8 字符处切开
`data:` 与 JSON 之间切开
两个事件合在一个 chunk
最后一帧没有额外空行对每种切法,最终事件数组应完全相同。再增加:
- 未知可选字段被忽略;
- 未知事件类型按策略记录 / 忽略;
- 不支持的协议主版本明确失败;
- 非法 JSON 产生
PROTOCOL_INVALID,不渲染半可信工具参数; - 服务端 schema 与前端生成类型来自同一版本源或有契约校验。
协议测试最好保存“线上真实但已脱敏”的事件样本,防止 mock 永远只符合客户端自己的想象。
业务状态:别测“按钮只点了一次”
前端禁用按钮只能改善体验,不能证明命令只执行一次。集成测试应直接重复请求:
it('commits a command once', async () => {
const command = {
commandId: 'cmd-fixed-1',
orderId: 'A42',
action: 'cancel',
expectedVersion: 7,
}
const [a, b] = await Promise.all([
api.execute(command),
api.execute(command),
])
expect(a.resultId).toBe(b.resultId)
expect(await countAuditRows('cmd-fixed-1')).toBe(1)
expect(await orderVersion('A42')).toBe(8)
})必须覆盖的业务轨迹:
| 场景 | 断言 |
|---|---|
| 未授权用户伪造 tool call | 服务端拒绝;不能只靠前端隐藏按钮 |
| 同一 command 并发两次 | 只提交一次,返回相同最终结果 |
expectedVersion 过期 | 返回冲突,不覆盖他人修改 |
| DB 已提交、响应被切断 | UI 进入 unknown,通过 commandId 查询收口 |
| 用户拒绝审批 | 不创建执行事务,记录拒绝事实 |
| tool 参数非法 | schema 校验失败,不进入业务服务 |
| 审计写入失败 | 按系统策略回滚或可靠补偿,不能假装成功 |
最重要的一条是“提交成功但响应丢失”。如果系统只测普通 500,就没有覆盖最危险的重复执行来源。
用 Playwright 注入断网和响应
Playwright 的 page.route() / browserContext.route() 可以拦截、修改、履行或中止请求,适合测试 401、500、连接失败和固定 API 响应。示意:
test('does not lose the draft when submission fails', async ({ page }) => {
await page.route('**/api/chat', route => route.abort('connectionreset'))
await page.goto('/chat/order')
const composer = page.getByRole('textbox')
await composer.fill('查询订单 A42')
await page.getByRole('button', { name: '发送' }).click()
await expect(page.getByText('发送失败')).toBeVisible()
await expect(composer).toHaveValue('查询订单 A42')
})对流式响应,普通 route.fulfill({ body }) 往往一次性交付 body,不能完整模拟每帧时序。更稳妥的选择是启动一个测试用 stream server,按 fixture 和测试控制信号逐帧发送;Playwright 负责刷新、切页、离线和 DOM 断言。
建议的 E2E 故障点:
第一个 token 前断开
工具参数完成前断开
业务提交前断开
业务提交后、响应前断开
run complete 前刷新
恢复订阅时再次断开时间测试:控制时钟,不赌机器速度
重连退避、超时提示、节流渲染应使用 fake timers 验证逻辑:
vi.useFakeTimers()
startReconnectLoop()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(2_000)
expect(connect).toHaveBeenCalledTimes(3)不要写“等待 3 秒,希望按钮出现”的测试。真实时间会让 CI 变慢且抖动;E2E 只保留少量跨层时序验证。
一份可以直接采用的发布闸门
UI 状态
- [ ] 用户上滚后流式输出不抢滚动;回到底部可恢复跟随
- [ ] IME composition 期间不误提交
- [ ] 每个 thread 的草稿、附件失败和折叠状态符合生命周期约定
- [ ] 流式更新不丢焦点,不用数组下标作为 part key
对话状态
- [ ] 重复事件只应用一次,缺口可检测
- [ ] stop 后迟到事件不会复活旧 run
- [ ] retry / regenerate 创建正确的新 run 或 branch
- [ ] 两个 thread 并发生成互不污染
- [ ] snapshot + 增量事件恢复后与不断线结果一致
- [ ] 未知协议事件有明确降级
业务状态
- [ ] 权限与 schema 在服务端复验
- [ ] command ID 幂等,并发重复只提交一次
- [ ] 版本冲突不会覆盖新数据
- [ ] 提交后丢响应能进入 unknown 并查询收口
- [ ] 审批、执行和最终结果均可审计
测试质量
- [ ] 默认测试不调用真实模型
- [ ] fixture 有正常、错误、重复、乱序和中断版本
- [ ] 线上协议样本已脱敏并进入回归集
- [ ] 失败日志包含 threadId、runId、eventId / commandId
- [ ] E2E 只承担真正跨层的少量关键路径
结语:把状态边界写成系统必须守住的句子
三类状态不是文章里的分类游戏,而是三组不同测试责任:
UI 测用户意图是否保留
对话测事件与运行是否一致
业务测副作用是否唯一且可确认最终判断一套 AI UI 是否可靠,不是看它能否生成一张漂亮截图,而是:
在重复、乱序、中止、切换、断网和未知结果之后,系统是否仍能回到一个解释得清、查询得到、不会重复执行的状态。
📚 核对范围与官方资料
- Playwright:Network、Mock APIs
- Vitest:Mocking Timers
- 流式测试服务器与状态契约是通用测试设计;示例 selector、接口和事件需按项目替换后执行。