详细阐述测试夹具设计如何避免diff不可读和重录全改的问题,涵盖格式设计、随机值处理、平台兼容性和数据脱敏四个维度。
Fixtures 被废弃通常出于两个原因之一:没人能看懂改动时的 diff,或者重新录制时会rewrite整个文件,没人能看出哪里变了。这两个都是格式问题,在第一个 fixture 写出来之前就已决定,而且都很容易一次性做对、后期修改代价高昂。
Review。审查者看到变更的 fixture 时,必须能从 diff 中看出模型的行为有哪些不同。如果 diff 是一行压缩后的 JSON,那审查结果只能是“看起来没问题”,fixture 就失去了它存在的意义。
Re-recording。在改动后重新运行录制器,必须产生一个只在行为确实不同的地方有差异的文件。随机 id、时间戳、token 计数都会破坏这一点——如果一个 fixture 每次录制都全部变化,那它就会被重新录制而无人去读。
Provider 变更。针对某个 API 录制的 fixture 以该 API 的线格式存储,一旦你接入第二个 provider 就彻底无法使用。存储一种中立格式会多花一个 adapter 的成本,但省去了后续全部重新录制的工作。
Being committed。真实对话包含真实数据。有明确脱敏位置的格式会被脱敏;没有的格式就会把客户地址写进仓库。
每个场景一个文件,文件名即场景名。一个简短的头部,然后是一个扁平的对话轮次列表,每轮标注是谁产生的以及内容是什么。
{
"meta": {
"scenario": "refund-damaged-item",
"recorded": "2026-08-04",
"provider": "chat-completions",
"model": "recorded-from-production",
"note": "policy tool returns eligible; happy path"
},
"turns": [
{ "from": "user", "text": "order 55219 arrived smashed" },
{ "from": "assistant", "calls": [
{ "ref": "c1", "tool": "get_order", "args": { "order_id": "55219" } }
] },
{ "from": "tools", "results": [
{ "ref": "c1", "output": { "sku": "MUG-01", "total_cents": 1499 } }
] },
{ "from": "assistant", "calls": [
{ "ref": "c2", "tool": "start_refund",
"args": { "order_id": "55219", "amount_cents": 1499, "reason": "damaged" } }
] },
{ "from": "tools", "results": [
{ "ref": "c2", "output": { "state": "pending" } }
] },
{ "from": "assistant", "text": "I've refunded EUR 14.99." }
]
}
四个设计决策在发挥作用。args 是解析后的对象,尽管 Chat Completions 传输的是字符串——因为 JSON 里嵌套 JSON 字符串在 diff 中完全无法阅读,而 adapter 可以用一行代码重新编码。ref 是 c1 而不是 call_9xKq2LmR,这样 id 在多次录制间是稳定的。工具结果是独立的轮次而非附加在调用上,所以交换的双方可以独立编辑。而 meta.note 是描述这个场景用途的说明文字——正是这个字段防止了 fixture 在清理时因无人知晓其覆盖范围而被删除。
用两个空格美化打印,保持对象 key 的顺序稳定。key 是排序还是保持插入顺序并不重要,重要的是录制器每次都做同样的事——不稳定的顺序会导致每次重新录制都产生全文件 diff。
Provider call ids — 每次运行都随机,会把每次录制都变成全文件变更。用出现顺序替换为 c1、c2。
Timestamps 和 request ids — 同样原因。如果某个轮次确实依赖时间,那是测试应该注入的值,而非 fixture 应该携带的值。
Token counts 和 latency — 它们随模型版本移动,属于成本记录而非行为 fixture。一个因计数变化了三个 token 就失败的 fixture 会让所有人学会忽略 fixture 失败。
个人数据 — 替换为稳定的假名而非删除,因为字段消失会改变循环所测试的形状。在录制器中执行脱敏,这样就不会被遗忘;在测试中断言没有任何 fixture 匹配你明显的模式。
Keys 和 tokens — 根本不应该出现在工具参数中,而拒绝写入包含此类内容的文件的录制器是廉价的第二道防线。
保留的内容:模型产生的空白和大小写完全不变。规范化这些会丢失你本想在 prompt 变更导致参数格式发生变化时看到的东西。
把模型轮次和工具结果分开的原因是,大部分价值来自重新组合它们。一个录制的对话支持一个同时使用双方的快乐路径测试;一个失败测试保留 assistant 轮次但将某个结果替换为错误;一个截断测试保留所有内容但把某个输出缩短到超过你的上限;一个脱敏测试向某个输出添加一个 secret 并断言它永远不会到达 prompt。
import fixture from "./fixtures/refund-damaged-item.json";
// Same conversation, one result replaced.
const withPolicyOutage = {
...fixture,
turns: fixture.turns.map((t) =>
t.from === "tools"
? { ...t, results: t.results.map((r) =>
r.ref === "c2"
? { ...r, output: { error: "upstream_timeout" }, is_error: true }
: r) }
: t),
};
在测试中派生变体,而非提交四个几乎相同的文件。一个提交的变体会在第一次有人只重新录制其中一个时就与父版本产生分歧,然后两个 fixture 都声称描述同一个对话却相互矛盾。
不要把期望放到 fixture 里。把断言放在数据旁边很诱人——列出必须运行过的工具列表、必须出现的最终字符串——但这摧毁了整个格式所依赖的区分。Fixture 描述发生了什么;测试描述应该发生什么。合并它们,重新录制就会悄悄改写你的期望,这就是测试套件在无人察觉的情况下变得毫无价值的方式。
重新录制是 fixture 套件保持价值或悄悄失去价值的时刻。把它做成一个带场景名的明确命令——绝不是一个重写所有失败项的标志,因为真正的回归就是这样被当作更新提交上去的。
一次只重新录制一个场景,明确命名,写入同一文件。
阅读 diff。如果唯一的变化是 id 或时间戳,那你的剥离就不完整;修复录制器而非接受那些噪音。
对于每一个真正的变化,在提交前决定它是改进还是回归。这是整个格式的核心要点:diff 必须足够小,问题才能回答。
更新 meta.recorded,如果行为发生了变化,也更新 note。描述了 fixture 不再包含的行为的 note 比没有 note 更糟糕。
把 fixture 放在仓库里而非对象存储中。它们很小,是审查者在代码变更旁边需要看到的东西,而一个不凭credentials就无法读取的 fixture 就是没人会去读的 fixture。同样的论点在更大规模的重放生产流量场景下也适用,虽然体量会迫使不同的答案,但审查问题并不会因此消失。