Skip to content

AI UI 不能只测截图:三类状态的契约测试清单 ​

一个聊天页的最终截图完全正确,不代表它能处理中止、重复事件、线程切换、重复命令或“服务端成功但响应丢失”。AI UI 最危险的 Bug,往往发生在两张截图之间。

🎯 本文目标

把 UI/对话/业务三类状态转成可执行不变量;用纯 reducer 测试、组件行为测试、协议夹具、集成测试和故障注入覆盖关键路径;给出一份可作为发布闸门的检查表。

本篇边界

这不是“三套 AI UI 谁赢”的产品横评,也不比较官网组件数量。本文验证的是任何方案都应满足的状态契约。具体框架可以替换,契约不能消失。


先测不变量,再测页面长什么样 ​

三类状态各有一句核心不变量:

text
UI:用户表达的临时意图不能被无关流式更新覆盖。
对话:每个事件最多应用一次,每个 run 只修改自己的 thread / branch。
业务:每个有副作用的 command 最多提交一次,最终结果以服务端事实收口。

截图适合验证布局和视觉回归,却看不到:

  • 用户正向上阅读时,下一 token 是否抢走滚动位置;
  • 重连重放同一 delta 后,文字是否重复;
  • 切换线程后,旧请求是否写入当前页面;
  • 数据库已提交但 HTTP 响应丢失时,前端是否危险地再次执行。

所以测试对象应是状态变化轨迹,最终 DOM 只是其中一个投影。


五层测试结构 ​

层级测什么速度最适合发现
纯函数 / reducer事件 → 状态最快重复、乱序、分支污染
组件行为用户动作 + 状态投影快滚动、草稿、IME、焦点
协议契约server fixture → decoder → reducer中字段漂移、未知事件、断帧
API / 集成DB、run、command中幂等、权限、版本冲突
E2E / 故障注入浏览器 + 网络 + 服务慢刷新、离线、竞态、恢复

不要把所有情况都塞进 E2E。能在 reducer 里稳定证明的性质,不必用 30 秒浏览器测试猜。


建一套确定性的假流 ​

真实模型输出慢、昂贵且不确定,不应成为状态测试的默认依赖。先维护协议夹具:

typescript
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. 自动滚动 ​

至少覆盖三条轨迹:

text
near bottom + 新 token       → 跟随到底部
用户向上滚动 + 新 token      → 保持阅读位置,显示“回到底部”
用户点击“回到底部” + 新 token → 恢复跟随

不要只断言 scrollTop === scrollHeight。更可靠的是断言“用户向上滚后,新事件不会调用滚到底部命令”,并单独集成验证实际容器。

2. 草稿与线程 ​

text
thread A 输入一半
  → 切到 B 并输入
  → 回到 A
  → A 的草稿仍在,且没有进入 messages

还应测试提交成功后只清当前 thread 草稿,提交失败时保留可编辑内容。

3. IME、附件和焦点 ​

  • 中文输入 composition 期间按 Enter 不提交;compositionend 后才按产品规则提交;
  • 附件上传失败不会删除其他已成功附件;
  • 删除附件会撤销预览 URL,避免内存泄漏;
  • 流式重渲染不会反复夺取输入框焦点;
  • reasoning 折叠状态以稳定 part ID 保存,不因新增 delta 自动展开。

这些测试的共同点:流式消息变化不能覆盖独立 UI 状态。


对话状态:用 reducer 证明事件轨迹 ​

重复事件不会重复文本 ​

typescript
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 不应静默吞掉 ​

typescript
it('stops at a sequence gap', () => {
  const state = seedAtSequence(7)
  expect(() => reduce(state, event(9, 'part.delta', payload)))
    .toThrow('expected 8')
})

中止、重试与分支 ​

需要验证:

  1. stop 后 run 进入 cancelled / interrupted,不再接受迟到 delta;
  2. retry 创建新 runId,不覆盖旧 run 的诊断信息;
  3. regenerate 创建同一父节点下的新回答分支;
  4. 编辑历史问题后,可见消息仍是一条从根到叶的路径;
  5. 切 thread 后,旧 thread 的完成事件只更新旧 thread;
  6. 刷新恢复先应用 snapshot,再应用其 sequence 之后的事件。

建议把“可见分支是一条连续路径”写成属性检查,而不是只测一个固定例子。


协议契约:故意把网络切得很难看 ​

TCP / fetch 不保证一次 read 对应一次事件。decoder 测试必须把同一 payload 按不同边界切分:

text
完整一帧
半个 UTF-8 字符处切开
`data:` 与 JSON 之间切开
两个事件合在一个 chunk
最后一帧没有额外空行

对每种切法,最终事件数组应完全相同。再增加:

  • 未知可选字段被忽略;
  • 未知事件类型按策略记录 / 忽略;
  • 不支持的协议主版本明确失败;
  • 非法 JSON 产生 PROTOCOL_INVALID,不渲染半可信工具参数;
  • 服务端 schema 与前端生成类型来自同一版本源或有契约校验。

协议测试最好保存“线上真实但已脱敏”的事件样本,防止 mock 永远只符合客户端自己的想象。


业务状态:别测“按钮只点了一次” ​

前端禁用按钮只能改善体验,不能证明命令只执行一次。集成测试应直接重复请求:

typescript
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 响应。示意:

typescript
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 故障点:

text
第一个 token 前断开
工具参数完成前断开
业务提交前断开
业务提交后、响应前断开
run complete 前刷新
恢复订阅时再次断开

时间测试:控制时钟,不赌机器速度 ​

重连退避、超时提示、节流渲染应使用 fake timers 验证逻辑:

typescript
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 只承担真正跨层的少量关键路径

结语:把状态边界写成系统必须守住的句子 ​

三类状态不是文章里的分类游戏,而是三组不同测试责任:

text
UI 测用户意图是否保留
对话测事件与运行是否一致
业务测副作用是否唯一且可确认

最终判断一套 AI UI 是否可靠,不是看它能否生成一张漂亮截图,而是:

在重复、乱序、中止、切换、断网和未知结果之后,系统是否仍能回到一个解释得清、查询得到、不会重复执行的状态。


📚 核对范围与官方资料

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人