关于我
AI 与开发
AI 时代,单测的价值点到底在哪?

AI 时代,单测的价值点到底在哪?

AI 让写单测变得极其容易,覆盖率一路刷高,但一条测试立不立得住,取决于它的验证基准是否独立于被测实现。本文讲清 AI 时代单测的真正价值点在哪。

这个问题现在越来越难回答,不是因为单测没用了,而是因为写单测这件事变得太容易了。几句 Prompt 就能生成一批,团队纷纷跟进、大量补测,覆盖率一路刷高。可越做越犯嘀咕,几个疑问接连冒出来:

  • 业务代码一改,单测就跟着报错,这时候到底该改哪边——是改回业务代码,还是改测试让它通过?
  • AI 写业务代码,又写测试来验证它,这不就是自己验自己、把同一句话重说了一遍?那这测试到底证明了什么?
  • 覆盖率刷到 90%,是不是就安全了?

这三个疑问指向的其实是同一个问题:单测写起来这么容易、覆盖率也够高,可它究竟在保护什么?什么样的测试才真的管用?

先给出本文的判断:价值点没有消失,但它不在大多数人以为的地方——它藏在"这条测试的验证基准是不是独立于被测实现"里。AI 时代最容易踩的坑,是造出一批看着独立、其实抄自实现的测试。这个判断怎么来的,下面聊聊我自己这段时间的一些思考。

1. AI 只改变了「写」这一段,跑和维护才是长期成本

单测的成本分三段:写、跑、维护。AI 大幅降低的只是第一段。第二段(跑)本就趋近于零——一套测试跑一遍是机器的事,几乎不花人力;第三段(维护)反而因为 AI 能大量生成而变贵了:一大批 AI 生成的测试要长期养着,业务一改就得跟着改一片。

AI 之前:三段成本都压在人身上 AI 之后:只有「写」被抹平 人工逐条编写 近乎免费 维护随业务演进人工改 Prompt 批量生成 ≈ 0 仍近乎免费 维护生成越多 · 反而更贵

从这张图能读出一个容易被忽略的判断:写单测的成本被 AI 抹平了,但维护和验证这两段没有跟着降。 真正的负担在后面两段。开篇那个"一改就报错、改来改去像在空转"的困境,根子就在维护这一段——你生成得越多,要跟着业务改的测试就越多,而这些改动大多是在维持一堆并没有防住什么的测试。

问题正出在这里:生成的成本是降下来了,可"写得多、跑得勤"并不等于"防住了该防的东西"。一堆随手生成、却没在保护任何东西的测试,比没有测试更糟——它们还给你一种"有防护"的错觉。所以真正该追问的不是"测试写起来快不快",而是这些测试到底在为谁把关:AI 时代的单测,究竟是写给谁看的?

2. 单测的第一读者,已经从人变成了 AI

过去单测有两类消费者——CI 里自动跑,人排错时偶尔翻看。人这一侧一直很弱,大多数人不读别人写的测试,出了问题才看一眼。AI 进来之后,消费结构变了:AI 改代码时会主动、高频、完整地读测试,而且它读的目的和人不一样。人是出了问题才把测试当排错线索;AI 是改代码之前,把测试当"这段代码应该满足什么"的规格来读。因为注释会过期、变量名未必名副其实,而测试是唯一"可执行、一说谎就失败"的文档。

由此可以得出:在 AI 协作的代码库里,单测的第一读者就是 AI,人退居其次。 它的价值点也随之从"给人兜底的验证脚本",换成了"给 AI 立规矩的可执行契约"。

单测第一读者从人变成 AI

而由此推导,过去评判单测好坏的一些标准也得跟着改。过去一些被奉为最佳实践的写法可能变成负累,而另一些原本不太受关注的点,反而变得关键起来。最典型的一条是:那些"对人友好"的测试写法,对人是可选的,对 AI 却是必选的。

拿命名举例。一个断言写成 expect(calculateDiscount(100, 'vip')).toBe(80),配的测试名是 test calculateDiscount——对人来说,这也能读,大不了排错时点进函数看一眼。但对 AI 来说,这个命名没有声明任何业务意图,它没法判断 80 是"正确答案"还是"当前实现恰好算出来的现状"。它只能假设"现状即正确",于是重构时理直气壮地把这条断言改掉。同一个测试,名字换成 VIP 用户享 8 折,100 元结算为 80 元,AI 才知道这是一条不可违反的契约。

这本质上是一个可读性问题——命名一旦省掉业务意图,AI 就会误判契约、做出错误重构。而由此也能推导出一些给 AI 生成测试时该显式写进 Prompt 的基本约束:

为指定文件里的函数生成单元测试,严格遵守以下约束:

1. 测试名声明业务规则,不要写成"函数名 + should work"。
   例如用"VIP 用户享 8 折"而不是"test calculateDiscount"。
2. 每个测试只验证一件事,只放一个核心断言,便于失败时精确定位。
3. 断言写"应该是什么"的期望值,不要把当前实现跑出来的输出回填进去。
   如果你无法独立判断期望值应该是多少,标注出来,不要猜一个填上。

上下文:目标文件路径、所用测试框架。

这里有个差别需要解释:为什么同样一条可读性建议,对人是可选的,对 AI 就成了必选?

但 AI 不会因为测试名里少了业务规则就停下来问。命名一旦省掉业务意图,它就默认拿"实现现在算出来的结果"去补——把当前行为当成正确规格,而不是像人那样翻实现、问同事把意图补回来。

所以在 AI 协作下,可读性的性质变了:它不再是"给人的礼貌",而成了给 AI 的意图接口。你写进测试名和断言里的业务规则,就是 AI 能读到的全部规格;没写进去的,它只能从实现里猜,而猜的结果往往是把 bug 当成契约固化下来。对人可选、对 AI 必选,不是因为 AI 更"挑剔",而是两者补全意图的默认方式不同:人靠旁路补意图,AI 靠"实现即正确"补现状。

3. 验证基准独立于实现,测试才成立

既然 AI 读不到测试之外的意图、只能拿实现现状去补,那要是连测试里的验证基准本身也是 AI"看着实现"写出来的,会怎么样?这时测试说的"对",其实是实现自己定的。

先看一个场景。假设有一个折扣函数,AI 既写了实现,又"照着实现"写了断言:

// AI 先写了实现,calculateDiscount(100, 'vip') 恰好算出 80
// 然后照着这个结果回填断言
test('calculateDiscount', () => {
  expect(calculateDiscount(100, 'vip')).toBe(80)
})

这条测试是通过的。但把实现改错——比如 VIP 折扣写成了 9 折,calculateDiscount(100, 'vip') 算出 90——AI 照样会把断言改成 toBe(90),于是它还是通过。实现错了,测试跟着错,依旧一路通过。

那么这条测试其实只证明了一件事:代码做了它自己做的事,至于这件事对不对,它一个字都没说。而"代码做了它自己做的事"这句话,对任何一段代码都成立,不管那段代码对错。

为什么会这样?因为实现和测试走的是同一条路。实现回答"怎么算出结果",测试回答"结果应该满足什么"——理想状态下这是同一个契约的两副面孔,一副是构造性的(给出过程),一副是声明性的(给出验证基准)。这两副面孔如果是各自独立想出来的,再彼此吻合,才有意义:好比两个人分头算同一道题,答案对上了,才敢信这题算对了。测试防住 bug 的机理正在于此——不是测试比实现"更对",而是两条独立的推导,同时犯下同一个错的概率很低。

而"照实现反推断言"恰恰违背了这个思路——测试不再是独立想出来的第二条推导,而成了实现的投影。它俩必然吻合,但这种吻合毫无意义,同一个错误会同时出现在两边。说到底,这是"一个视角伪装成两个"。

照实现反推=一个视角伪装成两个

那么既然验证基准非独立于实现不可,是不是回到 TDD 的老路——先写测试、再写实现——就万事大吉了?毕竟实现还不存在时,测试无从照抄。这个方向没错,但常见的理解把因果搞反了。先写测试之所以管用,不在"写在前面"这个动作,而在你写测试时手上没有实现可抄,只能自己把"什么叫对"重新想一遍。这里的关键点是:测试和实现分开想,验证基准别照着实现凑。至于谁先谁后,反倒不重要——AI 生成实现几乎零成本,先让它产出一版实现、再回头补测试也行,只要补测试时你依据的是职责而不是那版实现。反过来,就算严格先写测试,断言若只是把脑子里那版实现提前默写一遍,验证基准照样没能独立于实现。

这些讨论最终都落在同一个技术条件上:验证基准必须独立于被测实现。为什么这个条件非满足不可?因为一段代码"对不对",代码自己说了不算。它需要一个独立于代码之外的、对"正确"的定义。由此可以推导出单测的本质——

单测是把"什么叫对"这件事,从人脑里、从口头约定里,固化成一份可执行、可重复、机器能自动判定的规格。 它给"正确"下一个独立于实现的、可执行的定义,"验证代码"只是这个定义的用途。

这份规格要成立,验证基准(判断对错的依据)必须先独立于被测实现。 注意这是必要条件,不是充分条件:第 5.3 节会看到,两个独立来源也可能互相矛盾。验证基准若是从被测代码里推出来的,它定义的只是"代码现在的样子",谈不上定义"对"——正好是本节开头那个例子。

回到现实,AI 确实把写单测的成本降到了几乎为零,可省下来的力气,很多人转头就投在追覆盖率上——生成一大批测试、把数字刷到 90%、100%。问题是这些测试多数只是在复刻实现:断言照着代码算出来的结果回填,跑起来自然全部通过,却几乎没有一条验证基准是独立想出来的——数字再高,防护也跟不上。

4. 空转的测试有三种常见形态

这种验证基准照实现回填、测试与实现出自同一来源的测试,可以统称为空转的测试——它把实现又复述了一遍,没有形成独立的验证基准。这三种形态沿测试的生命周期排列:生成时断言抄实现(4.1)、度量时覆盖率失真(4.2)、维护时把真 bug 的报警压掉(4.3)。机制上是同一件事——验证基准抄了实现,或者失败信号被消解掉了。

空转的测试:看着有防护,实则没验证

4.1 照实现反推的断言

第 3 节 calculateDiscount 的例子就是这种形态:AI 先运行实现拿到 80,再把 80 回填成断言的期望值。实现算错到哪,断言就跟到哪,对错误毫无察觉能力——断言不是独立想出来的验证基准,而是实现结果的一份拷贝。

mock 的滥用是同一机制的变体,只是把参照物从"被测代码"换成了"我假想的依赖行为"。AI 生成 mock 时,通常照着依赖的当前实现来编返回值——测的不是依赖真实的样子,而是"我以为它会这样返回"。这里的边界要划清楚:mock 只用来隔离真正不可控的外部世界(网络、时钟、文件系统),不要 mock 自己的模块。给自己的模块打 mock,除了让验证基准抄了一份假设,还会埋下维护负担——被 mock 的模块日后一改,mock 里写死的返回值不会跟着变,依赖它的那批测试就会集体失真:该报错的不报、不该报的乱报。

照实现反推的断言最麻烦的地方是它藏得深,批量生成时人根本来不及逐条看。可以让 AI 自己反省式地把它逼现形:

逐条检查以下测试文件里的断言,对每一条回答两个问题:

1. 这条断言的期望值,是从当前实现的运行结果推算出来的,
   还是来自需求/规格/业务规则等实现之外的依据?
2. 如果实现算错了,这条断言还能不能发现?
   如果答案是"不能",说明它只是在复读实现,请标记出来。

只做诊断,先不要改动任何代码。

4.2 覆盖率刷到 100%

覆盖率几乎是单测语境里唯一一个标准的量化指标,也因此最容易被片面追求。但它的含义被严重高估了:覆盖率只回答"这行代码被执行过没有",不回答"这行代码的行为被验证过没有"。一个测试完全可以把某行跑一遍、却不对输出做任何有意义的断言,覆盖率照样加一分。所以数值高不代表真拦得住问题。

如果验证基准恰好是实现自己,那么 100% 覆盖率只是 100% 地复读了实现,覆盖率越高,越容易让人误以为防护到位。

到了 AI 时代,这个数值本身已经不再携带信息。过去覆盖率好歹要靠人一条条写测试堆上去,数字高多少还能间接反映投入;现在 AI 能几乎零成本地生成一大批"执行到了却什么都不验证"的测试,把覆盖率撑到任意高度。数字要多高有多高,却和"测得好不好"彻底脱钩。所以覆盖率是观测工具,不是优化目标——它唯一还有效的用法是反向看:哪里覆盖率低,说明那块完全没测到,值得去看一眼;但高覆盖率反推不出任何"测得好"的结论。

4.3 flaky 修复,别把真 bug 压成通过

一个不稳定测试(时而通过、时而失败)报错时,AI 最省事的修法是加个 retry、拉长 timeout、或者放宽断言。这三招都能让它通过,但共同点是只动测试、不碰实现——把报警的测试改到不报警,而不去查它为什么报警。

问题在于 flaky 有两种成因:

一种是测试本身写得脏——依赖了执行顺序、共享了可变状态、断了真实时钟或随机数。这时该修的是测试,让它确定化。另一种是测试在如实暴露实现里的真 bug——竞态、未处理的异步、资源泄漏。这时的失败是有效信号,该修的是实现。加 retry 等于把一个真 bug 的失败信号压掉。

AI 那三招(retry / timeout / 放宽断言)对这两种成因无差别地压成通过,于是第二种——真 bug——被系统性地掩盖了。这比普通冲突更难发现:普通冲突至少还在报错,flaky 被"修复"后是稳定的通过。

所以处理 flaky 的第一原则是不许先压成通过,必须先归因:

以下测试不稳定(时而通过、时而失败)。禁止用加 retry、加 sleep、拉长 timeout、
放宽断言、加 skip 中任何一种方式让它通过。

请先定位根因,分类到以下之一,并给出判断依据:
- 测试间状态泄漏(共享了可变状态、依赖执行顺序)
- 时序/时间依赖(用了真实时钟、真实随机数、真实网络延迟)
- 外部不确定性(依赖了不可控的外部服务)
- 真实并发 bug(实现里的竞态、未处理的异步、资源泄漏)

若归因是前三类,让测试确定化(注入固定时钟、隔离状态);
若归因是最后一类,不要碰测试,报告这是实现的 bug,交人确认。
归因不清就升级,不要擅自选一个方向压成通过。

5. 如何写出有效的单测

认出了空转测试的几种形态,接下来的问题是怎么让测试真正管用。分三步走,对应测试的三个环节:生成的时候,让 AI 别照着实现写断言;评估的时候,用变异测试查断言到底拦不拦得住 bug;冲突的时候,判断测试报错该改哪边。

5.1 生成侧:让 AI 从设计初衷推,而非照实现抄

前面反复说验证基准要独立于实现,但落到 AI 生成这一步,有个绕不开的现实:单测本就是对代码的白盒测试,而 AI 时代的正常节奏是先有实现——让 AI 生成一版实现几乎零成本,大多数函数也根本没有一份现成的需求文档可对。所以"对着文档写断言"在多数场景里不成立,真正可行的方式是:实现已经在那了,但生成测试时不让 AI 看实现体,只给它函数签名和这个函数「本该做什么」,逼它把期望值从设计初衷里推出来,而不是从实现里读出来。

这里的"设计初衷"不必是正式文档。它可以是你脑子里"这个函数就是干这件事的"那句话、code review 时的口头约定、函数上方的一段注释,恰好有需求文档时那更好——关键只有一条:它描述的是"应该做什么",独立于"现在的代码怎么做"。有了它,基本动作就固定下来:

为 <目标文件> 里的 <函数名> 生成单元测试。约束:

1. 不要读、不要参考该函数的实现体,只依据它的签名和下面这段
   "它应该做什么"来推断期望值:<一句话描述这个函数的职责与规则>。
2. 断言的期望值必须能从这段职责描述推出来,
   不许运行实现、拿返回值回填。
3. 若你无法从职责描述推断某个输入下的期望值,标注出来,不要猜。
4. 若测试跑出来和实现不一致,不要改测试迁就实现,报告这处冲突。

第 1 条"不看实现体"和第 4 条"冲突时报告、别迁就"是这段 Prompt 的骨架——前者切断了照实现回填的通道,后者把 AI 从"让测试通过"的默认目标里拽出来。但光有骨架不够:不同类型的被测对象,"从签名和职责推期望值"的难度差得很远,得分场景看。

纯函数最好办。 输入定了输出就定,没有隐藏状态,签名和职责几乎就框定了行为。对 calculateDiscount(amount, level) 这种,给一句"VIP 打 8 折、普通用户不打折、金额非负",AI 就能推出一批不看实现也成立的断言,还能顺带覆盖边界(0 元、负数、未知 level)。这类函数是独立验证基准收益最高的地方,值得优先、重点测。

换到带副作用或外部依赖的函数,难点就从"推期望值"转移到"划依赖边界"。一个函数要读数据库、发请求、看时钟,它的"正确"就不只取决于入参。这时先讲清哪些依赖该 mock:只 mock 真正不可控的外部世界(网络、时钟、文件系统),自己的模块不要 mock——理由第 4.1 节讲过,给自己模块打 mock 等于把验证基准又抄了一份假设。Prompt 里要把这条边界显式写死:

为这个带外部依赖的函数生成测试。mock 规则:
- 只 mock 网络请求、时钟、文件系统这类外部不可控依赖,
  mock 的返回值依据接口契约/文档写,不许照被 mock 模块的当前实现回填。
- 不要 mock 本项目自己的模块,让它们真实执行。
断言仍只依据"这个函数应该做什么"来写,不看它的实现体。

UI 组件又是另一种情况,关键是分清"行为契约"和"实现细节"。组件测试最容易滑进空转,是因为快照测试(snapshot)天然照抄渲染结果——它把当前输出存成基准,下次比对,本质就是"实现即验证基准",第 4.1 节那套毛病一个不落。正确的做法是测组件对用户可见的行为契约(点了按钮触发什么、传了 loading 显示什么),而不是测 DOM 结构长什么样:

为这个组件生成测试,用 Testing Library 的语义查询
(getByRole / getByText),断言用户可见的行为:
- 给定 props 下应该出现/消失什么、点击后触发什么回调。
不要用快照测试,不要断言具体 DOM 结构或 class 名——
那些是实现细节,会随重构失真。

以上三类都是"从职责能推出期望值"的场景。但总有推不出来的时候——职责描述本身很模糊,或者行为依赖复杂状态,一句话说不清"应该输出什么"。这时有两个兜底手段,不必强求,能用则用。

一是 property(不变量):不去断言"某个输入对应某个具体输出",而是断言"不管输入是什么,都得满足某条性质"。排序结果一定非降、序列化再反序列化要等于原值、加密再解密要还原——这类性质不依赖任何一个具体输出,AI 反推实现时编不出来也绕不过去。它适用面窄,只在纯逻辑、有清晰数学性质的地方收益高,业务规则复杂的地方多半提炼不出干净的不变量,不必硬凑。

二是开独立会话交叉验证:另起一个会话,只喂函数签名和职责、明确不给实现,让它独立写一版断言,再和原有测试比对差异。差异点就是两个视角对"什么叫对"理解不一致的地方,值得重点排查。它弱于真正的职责推导(毕竟第二个视角也可能错),但强于照实现抄——因为它硬隔出了一个没看过实现的视角。

上面这套规则每次生成单测都要重讲一遍,并不划算。更好的做法是把它固化成一个 Skill,以后每次让 AI 写测试直接调用,规则不再散落在临时 Prompt 里。可以让 Claude Code 把本节的约束提炼成一个 Skill:

在 .claude/skills/ 下创建一个生成单元测试的 Skill,把下面这套规则固化进去:

- 默认不读被测函数的实现体,只依据签名和"它应该做什么"推断期望值,
  严禁运行实现、拿返回值回填断言;推不出期望值就标注,不许猜。
- 按被测对象分场景处理:纯函数直接依据职责推期望值并覆盖边界;
  带外部依赖的函数只 mock 网络/时钟/文件系统,不 mock 本项目模块;
  UI 组件用语义化查询断言可见行为,禁止快照测试和断言 DOM 结构。
- 测试名声明业务规则,不写成"函数名 + should work"。
- 测试与实现冲突时,报告冲突,不改测试去迁就实现。

Skill 描述里写清适用场景(生成或补写单测时自动触发),
让它以后能被复用。

有了这个 Skill,生成侧的规则就从"每次现讲"变成"一次固化、反复调用",这也是把散落的最佳实践收敛成团队资产的常规手段。

5.2 评估侧:用变异测试查断言能不能检出错误

有了独立来源,还得知道断言到底能不能检出错误。这里引入变异测试(mutation testing)——故意往代码里注入 bug(把 > 改成 >=、把 + 改成 -、删掉一个分支,每改一处生成一个"变异体"),然后跑整套测试,看它能不能让测试失败。变异体让代码错了、测试却通过,说明这处行为根本没被有效验证。被检出(对应测试失败)的变异体占比,就是 mutation score。

变异测试:注入 bug 看断言能否检出

变异测试主要用于度量断言能不能检出错误。 它对付"断言太弱"这类很有效:一条只断言"返回了个数字""没抛异常"、却不验具体值的空断言,根本检不出注入的变异体,报告里会把它暴露出来。

但它查不出空转测试的另一类——断言写得很精确、期望值却是照实现抄来的。这种断言能检出变异体(毕竟它盯死了当前实现的输出,代码一变异就对不上),所以在变异测试面前它是"合格"的。换句话说,mutation score 高只能说明断言写得细,不能说明它的验证基准独立于实现——别拿它当"有没有独立验证基准"的判据。

变异测试的时间成本很高——N 个变异体要把测试套件跑 N 遍,大项目动辄几小时。所以它不适合每次提交全量跑,更合适的用法是只对核心逻辑(算法、金额、权限这类 bug 代价高的模块)跑,或只对本次改动增量跑。

落到 JS/TS 项目,StrykerJS 是这个领域几乎唯一活跃维护的开源实现,对 Jest / Vitest / Mocha 都有适配,可以用它对上面这些核心模块做局部验证。它的接入依赖项目用的是哪个测试框架(Jest / Vitest / Mocha)、哪个包管理器,与其记具体命令,不如让 AI 按当前项目的实际情况搭:

在当前项目接入 StrykerJS 做变异测试。先识别项目用的测试框架和包管理器,
据此安装对应依赖、生成 stryker 配置文件,并把 mutate 范围限定在核心逻辑
模块(算法、金额、权限这类),不要全量。配好后跑一次,确认能正常产出
mutation report。

举个具体例子看它怎么起作用。假设有个判断能否下单的函数,规则是"金额大于 100 才放行":

function canCheckout(amount) {
  return amount > 100
}

// 测试只覆盖了一个远离边界的用例
test('金额 500 可以下单', () => {
  expect(canCheckout(500)).toBe(true)
})

这条测试是通过的,覆盖率也算这行"测到了"。但 StrykerJS 会把 > 变异成 >=,生成一个 return amount > 100return amount >= 100 的变异体。用 amount = 500 去跑,两个版本都返回 true,测试照样通过——这个变异体**存活(Survived)**了,报告里会明确标出来:

Survived mutants:
  #1  src/checkout.ts:2:10
      - return amount > 100
      + return amount >= 100

存活的变异体等于在说:边界值 100 到底放不放行,这条测试根本没验证。补一个边界用例,才能把它检出:

test('金额恰好 100 不能下单', () => {
  expect(canCheckout(100)).toBe(false)  // >= 变异体在这里被检出
})

这就是变异测试比覆盖率多出来的那层信息:覆盖率只告诉你这行被执行过,变异测试告诉你这行的行为改错了、测试能不能发现。

跑完让 AI 读报告、针对存活的变异体补断言:

读 StrykerJS 的 mutation report,列出所有存活的变异体(survived mutants)。
对每一个:说明它对应源码里哪一处改动、为什么现有测试没能检出它,
然后补一条断言把它检出——补的断言必须来自职责/规格,不许为了消除变异体
而把当前实现的输出回填成期望值。

5.3 冲突:出现问题时应该改哪边?

现在可以正式回答开篇那个"改哪边"的疑问了。答案先看哪一侧的验证基准独立于实现,而不是急着在"改测试"和"改实现"里二选一。

测试来自职责/property,实现是纯生成的 实现经人工确认,测试是 AI 后补的 两侧都无独立验证基准 两侧都有独立验证基准却矛盾 单测报错:实现和测试打架 哪一侧的验证基准独立于实现? 改实现禁止动测试 改测试后补测试可能推歪了 停下 · 升级交人或去拉独立验证基准 停下 · 报职责冲突这是规格问题,AI 无权裁决

测试失败,只说明实现和测试不一致,不能简单判定为哪一边的问题。判断改哪边,用的还是生成时那个标准:哪一侧的验证基准独立于实现,就以哪一侧为准。这本质上是个权限问题,不是能力问题:AI 有能力让任何一边通过,但它没有权限在缺乏独立依据时决定"哪边对"——那个决定权属于意图的来源,不属于执行者。

要让这个判定可操作,AI 得知道每条测试有没有独立验证基准,这个信息不能靠猜。一个办法是给测试打一行来源标记,标明它的验证基准从哪来,我在这里把这个标记写成 @oracle

// @oracle: 职责-VIP 享 8 折
// 依据是函数职责,冲突时改实现,不改测试
test('VIP 用户享 8 折,100 元结算为 80 元', () => {
  expect(calculateDiscount(100, 'vip')).toBe(80)
})

// @oracle: none (AI 从实现反推,未经独立确认)
// 冲突时不可仅凭此测试改实现,需升级
test('空购物车返回 0', () => {
  expect(calculateDiscount(0, 'vip')).toBe(0)
})

要强调的是,@oracle 是本文推导出来的一个约定,不是现成的标准,也不是一套注释制度。 它的作用只是帮人建立"这条测试的验证基准从哪来"这个心智模型。而且它有一个绕不开的递归问题:想靠"来源标记"判断谁是可信的验证基准,可"这个标记本身对不对"又是一个"谁说了算"的问题。@oracle 默认由 AI 生成,而 AI 完全可能把一条从实现反推的断言,标成"来自职责"。所以这行标记不能自动生效——它必须经过一次独立校验,才算数。

这里的关键词是"独立":校验一定要来自"生成这批测试的会话之外"的视角。原因很简单——生成单测的那个上下文里已经装了太多信息(实现细节、之前的推导、写好的断言),很难再客观审视自己产出的标记是否可信,需要换一个干净的上下文来评估。至于这个独立视角由谁承载,人和 AI 都行:可以是人在 review 时重点盯这批 @oracle,也可以另开一个没看过实现、没参与生成的 subagent 或 Claude Code 实例,只喂测试和职责描述,专门核标记的血统——哪些确实能追溯到职责、哪些其实是从实现反推的。两者可以叠加:让独立上下文先筛出可疑项,人再重点确认这些,不必逐条自排。

把这套判断写进 Prompt,关键是拦住 AI"直接改测试让它通过"这个默认动作:

单元测试失败时,按以下顺序判定,不要默认改测试让它通过:

1. 读失败测试的 @oracle 标记,确定验证基准来源:
   - 来源是职责/规格/property → 测试携带独立意图,优先改实现,禁止改测试。
   - 来源是 none 或从实现反推 → 检查实现有没有人工确认痕迹;
     实现已确认则改测试,实现也未确认则进第 3 步。
2. 若两条测试都有独立来源却互相矛盾 → 停下,报"职责冲突",
   附各自依据的职责/规格出处,交人裁决。
3. 若冲突两侧都无独立验证基准 → 停下,不要改任何一边。
   输出:失败的测试、当前实现、你对"正确行为应该是什么"的两种推断及理由,
   请求人工提供判断依据。

任何情况下,都不允许在没有独立依据时,仅仅为了让测试通过而修改断言。

给存量测试补 @oracle 时,同样要把血统的不确定性交回给人:

为以下测试文件里的每个测试补一行 @oracle 来源标记。
能明确追溯到职责/规格/property 的,写出具体依据。
无法确认来源、或明显是从实现反推的,一律标 "none/待确认",
不要替它认定血统。产出的标记全部视为待人复核的草稿。

这套血统标记的真正用处,是给每条测试的通过标上一个置信度,供冲突时决策。置信度高的测试(能追溯到职责/规格),它的通过才是可信的护栏——冲突时应当让实现去满足它,不许反过来改测试迁就实现;置信度低的(标了 none、或从实现反推的),它的通过说明不了什么,冲突时可以酌情修正测试本身。也就是说,@oracle 不改变"测试跑没跑通",它改变的是"这次通过/失败该被当成多强的信号"。

6. 人得保留对代码的结构性认知

冲突时该改哪边,最终要人来拍板;而人能拍板的前提,是对代码有框架性的认知。这是整个自动化闭环里一条不能让渡的底线——只有守住它,人才能在关键处做最后的决策,比如实现和单测冲突时判断该改哪边。一旦丢掉这层认知,人就失去了独立判断的依据,AI 说什么是什么,兜底也就无从谈起。

这里说的不是逐行读懂实现(那正是该交给 AI 的),而是框架性的认知——系统分成哪几个模块、边界和职责是什么、核心数据怎么流、关键不变量在哪、哪些地方 bug 代价最高。它不要求深,但要求有骨架:能在脑子里画出系统的大致地图,知道一个改动大概会波及哪里。

AI 时代代码迭代越来越快,一个现实的趋势是:很多人会渐渐把控制权整个让渡出去,需求丢给 AI、代码交给 AI、连对错也让 AI 说了算。问题在于,AI 擅长写、跑、改这些执行动作,但"改哪边才对"取决于意图,而意图只在人这里——它真正解决不了的,是冲突里"哪边对"的裁决。真正遇到 AI 自己也解决不了的问题时,如果人对自己的产品、自己的代码没有足够的框架性认知,就没有能力接手判断,整个协作就卡死在那里。所以哪怕交付节奏再快,人也得守住对代码的结构性认知——这是 AI 帮不上、也替不了的一层。

落到实践,这条要求指向一个具体动作:人不必逐行 review,但可以定期让 AI 产出并维护一份结构地图,人来读这份地图而不是读全部实现——顺带,地图里写下的关键约束,还能反过来当测试的 @oracle 来源。

分析当前项目的整体结构,产出一份面向人类维护者的结构地图,包含:
- 模块划分与各自的职责边界
- 模块间依赖与核心数据流(用 Mermaid 图)
- 关键业务不变量和约束(这些可作为测试的判断依据)
- 标注哪些模块 bug 代价最高、最需要人重点理解

输出到项目文档目录,后续随代码演进增量更新。

7. 价值点没有消失,只是换了个地方

AI 时代单测的价值点到底在哪?绕了一圈,可以回答了。

首先要理解两个变化:单测的第一目标用户从人变成了 AI,判断测试好坏的标准从"有没有测试"变成了"验证基准独不独立于实现"。这两个变化其实是同一件事——单测是一份给"什么叫对"下的、验证基准独立于实现的可执行规格:读者换成 AI,变的是这份规格写给谁看;基准独不独立,决定的是这份规格立不立得住。

AI 让单测的撰写成本降到了几乎为零,但量大并不等于管饱。真正有效、能长期维护的单测体系,靠的不是生成得多快、覆盖率刷得多高,而是每一条测试的验证基准都独立于它所测的实现。守住这条原则,单测才不会退化成复读实现的空转测试,才能在 AI 高频重构代码时,充当一道可信的本地回归信号——尤其在 bug 代价高的核心模块里,验证基准越独立,这道信号越值得信任。

On this page