作者揭示多 Agent 项目中人类审批门的 Playwright 测试全部通过但实际未测到真实路径的问题,根源是测试自己构造了待审批状态。修复方案是将假数据下移到网络层,用真实的 SDK 客户端跑测试。
在一个私有的多 Agent 项目中,Agent 的提案需要经过人工审批关卡。该关卡的 Playwright 测试一直全绿,且持续了相当长的时间。
它们全绿,是因为它们从未真正运行过它们声称要测试的东西。
这些关卡测试将模型输出直接植入应用状态:写入一个待审批提案卡片,点击批准,断言卡片变为已审批。干净、快速、确定——但从一开始就建立在谎言之上。这些测试只证明了待审批提案的消费者是正常工作的,却从未执行过生成提案的那条路径。
所以,当一次重构将生产者改为写入预审批提案——悄悄地在过程中丢弃了审批守卫——整个测试套件依然全绿。因为测试本身就是植入状态的那一方,植入的状态看起来完全符合测试的预期。
这就是我现在所谓的"绿色剧场"失败模式:测试套件自己制造证据,再去检查证据。
诚实的修复是把伪造下沉到能够承载它的最底层:网络线缆层。我将其提取为 llm-wire-stub——一个在 Playwright 网络边界上运行的 Anthropic Messages API 脚本。
被测应用无需修改地运行:一个真实的 @anthropic-ai/sdk 客户端、真实的 MessageStream、真实的 SSE 解码、真实的工具循环。stub 用 context.route 拦截 api.anthropic.com,以文档化的流式信封格式响应:
message_start → (content_block_start → …delta… → content_block_stop)* → message_delta → message_stop
一个属性比任何其他都重要:错误形状必须大声失败。如果一个 stub 发出的信封应用半容忍地接受了,那不过是在下一层继续绿色剧场。SDK 自己的累加器是执行者——如果缺少 message_start,它会抛出异常(当前 SDK 的消息是"Unexpected event order"),而 finalMessage() 会在 message_stop 从未到达时拒绝。该断言是被演示的,而非被声称的:tests/envelope.test.ts 将 stub 的字节通过真实 SDK 传输,并展示精确重建,然后输入故意损坏的流并展示 SDK 抛出异常。
这部分改变了我对测试的认知:stub 记录应用发送了什么,而不只是它被展示什么。每个被拦截的调用都变成一条 RecordedRequest——模型、系统提示、消息(包括 tool_result 块)、工具名、api key。
测试的响应侧说的是"应用可以渲染它被给予的内容"。请求侧说的是"应用问了正确的问题"。私有测试套件历史上有两个 bug 让我开始在意——两者对任何响应侧断言都不可见:
价格谎言。UI 芯片显示的成本数字与实际网络传输的内容不一致。每个响应侧测试都通过了,因为响应本身没问题;谎言在于没有人读取的出站流量中。
上下文泄漏。一个 Agent 的输出本应到达下一个 Agent 的提示,却悄然没有。一个从未看到上游输出的节点可以用一个断言证明——requests[n].messages 中缺少一条消息——而我用其他方式不知道还有什么办法能发现它,除非盯着日志看。
这两个轶事是私有历史;你无法从公开仓库复现它们。但你可以检查机制:e2e 测试规范中,一次工具轮转让 SDK 循环发起第二次请求,断言第二次请求体,并展示应用产生的 tool_result 沿着网络线上传回去。
这一点付出了一次发布代价,所以它有自己的章节。
私有测试套件中有一个测试断言模型请求次数与向用户报价相符——姑且称之为"请求数 == 报价数"。正确的断言,合理的思路。它在一个完整的发布周期内都通过了,尽管计量是错误的。
为什么?当时的 stub 只能生成纯文本轮次。它无法模拟 tool_use 块,所以 SDK 的工具循环从未触发,因此没有任何测试运行曾经发起过第二次请求。"请求数 == 报价数"的两边都trivial地是 1。断言是正确的,但 fixture 使其变成了空话。
教训:你的断言只能和 fixture 能表达的一样强。一个无法产生第二轮的 fixture 会悄悄地把每个多轮断言变成一个重言式——没有失败,没有警告,没有任何需要审查的东西。
所以 ScriptedTurn 增加了一个 tool 字段,以真实 API 的方式流式传输 input_json_delta 片段,以及——同一教训的另一面——一个 error 字段。一个只能成功的 stub 会因其 omission 让每个消费者的错误路径都变成绿色剧场。一个 error 轮次用文档化的 Anthropic 错误 JSON 响应,真实 SDK 将其作为可捕获的 RateLimitError 浮出水面,完全和生产环境一样:
const stub = await stubAnthropic(context, [
{ error: { status: 429, type: "rate_limit_error", message: "Rate limited." } },
]);
// …驱动 UI;断言应用显示其速率限制状态,而非崩溃…
(一个如果跳过会花半天的小心事项:真实 SDK 默认重试 429 和 5xx,所以 error 轮次脚本要么同时模拟重试,要么用 maxRetries: 0 运行客户端。两个仓库内演示都做了后者。)
npm install -D llm-wire-stub
注册表 tarball 预装了 dist/,所以不需要安装脚本也不需要 flags——这在 npm v12 上很重要,因为 git 依赖和生命周期脚本默认是关闭的。(github:egnaro9/llm-wire-stub 安装也可以通过 prepare hook 工作,但在 npm 12 上需要新的 --allow-git 和脚本允许——注册表安装是更干净的路径。)
import { test, expect } from "@playwright/test";
import { stubAnthropic } from "llm-wire-stub";
test("the agent answers from the scripted wire", async ({ context, page }) => {
const stub = await stubAnthropic(context, [
{ text: "First scripted answer." },
{
text: "Filing a card now.",
tool: { name: "create_card", input: { title: "prove the loop" } },
},
{ text: "Card filed. Done." },
]);
await page.goto("/"); // your app, unmodified
// …驱动 UI;应用的真实 SDK 客户端访问 stub…
expect(stub.requests).toHaveLength(3); // 应用实际发送的内容
expect(stub.overflow).toBe(0); // 没有未脚本化的模型调用
});
overflow 计算运行到脚本末尾之后的请求——一个意外的额外模型调用会在断言中显示出来,而不是藏起来。
const stub = await stubAnthropic(context, (req) =>
JSON.stringify(req.messages).includes("alpha")
? { text: "for alpha" }
: { text: "for beta" }
);
stub.hold(); // 响应现在阻塞
// …在 UI 中触发两次发送…
await expect.poll(() => stub.requests.length).toBe(2); // 两者都在进行中
stub.release(); // 两者都完成
如果生产者序列化了它的调用,第二个请求在第一个仍在 pending 时永远无法到达线缆——所以在 hold 下的两个 recorded request 是并发的证明,而非时序巧合。注意函数式脚本:数组形式将答案按键映射到到达顺序,这在并发下是一场彩票;函数式按键映射到提问者。
sseBody 和 errorBody 是框架无关的;vitest 测试套件将它们与普通自定义 fetch 一起使用。)tool_use 块;失败是 error 变体。没有其他可表达的内容,这是故意的。github.com/egnaro9/llm-wire-stub — MIT,9 个 vitest 测试(通过真实 SDK 的信封测试,包括大声失败的演示)和 10 个 Playwright 测试(一个浏览器 fixture 驱动真实 SDK 的工具循环对抗 stub 的线缆层)。上述私有产品的 bug 是其出处;stub 所声称的所有功能都有一个你可以运行的测试来演示。