我给 Agent 测试注入了 72 种谎话,它抓住了 44 种
先讲一个真实的假通过
想象你的 Agent 负责在咖啡店系统里下单。它调用接口,返回 applied,页面上出了取餐号 101。你的测试断言「下单成功」,绿了。CI 全绿,你合了代码。
但数据库里没有这张单。
这不是假设。这个场景在我们的仓库里有一个具体的名字:m-sql-phantom-success------一个故意写坏的实现,它拦截下单请求,根本不发给后端,然后对调用方谎报成功。它不是黑客工具,是我们自己造的「变异体」(mutant):把真实实现包一层,让它在某个环节撒一种特定的谎,再看测试能不能当场抓住。
为什么要主动给系统注入谎言?因为你无法用「通过了多少测试」判断测试本身有没有用。一个永远绿的测试,可能是代码真的对,也可能是断言在空转、观测是假的、或者实现学会了在测试面前表演。想知道测试有没有牙齿,唯一的办法是喂它谎言:能咬住的算数,咬不住的记下来。
72 种谎话的分类学
我们造了 72 个变异体,按谎话发生在哪一层分两类:执行层 22 个------你说「下单」,它答应得很好,但什么都没做;状态层 50 个------谎话藏在查询结果里。
执行层的典型:m-sql-phantom-success 拦截请求伪造成功;m-foreign-write-allowed 让你越权改别人的数据;m-no-reject 把所有该拒绝的请求都放行。
状态层的典型:m-sql-cross-user-leak 往你的订单列表里塞别人的订单;m-stale-oracle 让你永远看到第一次查询的旧数据;m-uncommitted-ghost 每次查询都变出一条数据库里不存在的幻影行;m-view-collection-bleed 把订单明细字段里填上操作日志的内容。
状态层里最阴的一小类是瞬时谎话 :只在中间某一步存在,首尾都干净。m-transient-menu-price 让菜单价格中途抖一下又改回来;m-mid-write-leak 在被拒绝的那一步之后留半条流水、收尾又抹掉。
口径先立规矩:44/46,不是 44/72
先说清分母,因为没有分母的百分比都是营销。
72 个变异体里,有 26 个的能力(capability)不在这套被测域的声明里------比如给一个没有「发货」概念的咖啡店注入「发货延迟」的谎,这种谎对它而言无法成立,我们叫「不适用」,不进分母。
剩下 46 个适用的,断言抓住 44 个,抓获率 95.7%。这个数字不是口号,是一条可以机器复核的口径:
scss
$ node scripts/certify-baseline.mjs --emit-limits
当前口径:72 个变异体,13 条案例,适用 46,抓到 44(95.7%),欠账 2,不适用 26。
CI 里有一个测试守卫盯着这行数字和 docs/LIMITS.md 的台账一致------数字漂了,测试红。
三个抓捕案例,各有各的教训
案例一:幻影成功(执行层)。 m-sql-phantom-success 拦截下单、伪造 applied,但观测通道保持真实------这模拟的是「LLM 说它做了,其实没做」。抓住它的不是某一条巧妙断言,而是 14 条平平无奇的断言(SQL 直读通道实测)------光 write-landed 一个案例就贡献五条:一条 count_delta(订单数必须 0→1,谎报时永远是 0)、一条状态、一条金额、两条明细。**教训:对写操作的验证,永远要有「写完之后世界长什么样」的独立观测,不能只信操作返回值。**这条变异体是我们做双通道验证(HTTP 执行 + SQL 直读数据库)的直接动因。
案例二:越权泄露(状态层)。 m-sql-cross-user-leak 往订单列表里塞一条别人的订单。第一版把它 push 到列表尾部------测试全绿,假通过 。为什么?因为真实后端的列表按创建时间倒序排,新单永远在头部,而测试断言用的是「第 0 行等于我的单」------往尾部塞东西碰不到它。更糟的是「订单数 +1」的断言也抓不到它:变异体在每次查询时注入,断言的差值照样成立。改成 unshift(抢头部)之后才被 orders[0] 断言杀死。教训:断言写「第 0 行」这种绝对位置时,要先问自己「坏实现会把它塞到哪」------位置敏感的断言和位置敏感的注入必须设计成一对。
案例三:只在中间存在的谎(状态层)。 m-mid-write-leak 在被拒绝的那一步之后留半条流水,收尾时又抹掉------净变化为零、终态干净,unchanged、count_delta、status_transition 全都看不见。抓住它的只有一条断言:window,把观察窗缩到那一步之后盯住中间。它的孪生兄弟 m-transient-menu-price(菜单价格中途抖一下又改回来)同样只被一条 every_step 杀死。全仓 72 个变异体里,只有这两条是仅靠时序断言抓住的 ,各自恰好一条。教训:只看首尾的断言对「过程造假」免疫------要抓过程,断言就得长在过程上。
漏网的 2 种不是丑闻,是台账
44/46 里那 2 个漏网的,我们没有藏:
m-pickup-no-offset:取餐号 = 100 + 订单号,这是实现约定而不是业务不变量。第二个实现完全可以约定 200 + 订单号,行为一样正确。断言它就等于把一个实现私有的数字钉进规格------是断言自己错了,不是测试没牙。m-unsorted-orders:列表顺序同理。mock 按插入序排,真实后端按时间倒序排,两者都对,「顺序」根本不该进规格。
这两条连同理由写在 scripts/certify-baseline.mjs 的 KNOWN_SURVIVORS 表里,CI 会验证它们仍然漏网(哪天它们突然被抓了,说明口径变了,也要红)。判断一个项目诚不诚实,不看它吹多少,看它敢不敢把「我抓不到什么、为什么抓不到」写成一等公民。
顺便检查了一下 13k star 的 e2e 框架
写这篇文章时我们核对了 e2e-inc/e2e(一个面向 web 和移动端的下一代 e2e 框架,13k star)的判决模型,packages/e2e/src/run/records.ts:
ts
export type ResultStatus = 'passed' | 'flaky' | 'failed' | 'timed-out' | 'interrupted' | 'skipped';
六档里没有「不知道」。summary 里 timed-out 与 failed 合进同一个桶(report/build.ts 的 case 'failed': case 'timed-out':),interrupted 虽有独立计数器、但同属「非通过」一侧------而超时和中断的本质是「没观察到结果」,不是「观察到了坏结果」。被测系统可能已经把请求执行完了(副作用可能已经发生),折进失败率之后,报告的分母就混进了「观测缺失」。
这在纯 UI 测试里是小问题(重跑就是了),但在 Agent 驱动的写操作 里是危险语义:Agent 下单超时,框架判 failed 并重跑------第二次下单就是真实的重复下单。我们的做法是第三档判决 unknown:观测缺失就是不知道,不知道就留在分母里,可判定率 = (pass + fail) / N,而且 unknown 案例带「不许重放」的标记------因为重试的前提是「确认没有副作用」,而你恰恰无法确认。(我们给这个框架提了 issue #1632 讨论这个语义,维护者的自动 triage 逐条核实了我们对源码的读法。)
这不是抬杠。判决词汇表的形状决定了报告能回答什么问题:六档回答「多少比例的用例失败了」,回答不了「多少比例的用例我们根本没测成」------而后者才是 Agent 时代最贵的未知数。
你可以现在就做的三个自测
不用装任何东西,三个问题判断你的测试有没有牙齿:
- 把被测实现里任意一行成功路径改坏(改金额、跳过校验、吞掉一次写入),有多少条测试会红? 一条都没有的话,你的测试在测「代码长这样」,不是「代码做对了」。
- 你的断言有没有独立于被测实现自己宣称的结果? 如果断言读的是同一个函数的返回值,你验证的是「它说它对了」,不是「它对了」。写操作要看副作用:数据库、状态表、下游调用。
- 测试失败时,报告能不能区分「代码错了」和「没测成」? 环境挂了、依赖超时、观测不到------这些混进 fail 分母的报告,回答不了「这次改动安全吗」。
三个都答不好,欢迎看看我们是怎么把这三个答案机器化的:72 个变异体、每条断言的准入门槛(新断言必须至少杀死一个变异体,否则不许进语言)、三值判决、双通道观测,全部开源,MIT。
agent-web:GitHub · Gitee · 零依赖 Node,一行看三种判决(钉 tag 是为了判决可复现):
bashnpx --yes "https://codeload.github.com/liuzheng6621/agent-web/tar.gz/refs/tags/v0.78.3" selftest想投稿你的「抓不到的谎话」,仓库开着
docs/mutants/和knowledge/两个通道。
本文所有数字出自 agent-web v0.78.3 的机器口径(certify-baseline --emit-limits 与 coffee 域 certify 实测),可复现脚手架见仓库 README。