AI 写的测试全绿却拦不住 bug:怎么验证测试本身是真的
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
多数人把这件事归错因了:以为是模型不会写测试,其实是你从来没验过测试。 让 AI 补一批单测,它交出几十个用例、覆盖率从三成拉到八成、CI 一片绿灯,你就默认这批测试在保护你。但测试和业务代码有个根本区别——业务代码写错了会有人报障,测试写错了没有任何人会报障,它只会安静地一直绿着。绿色是它的默认状态,不是它的工作成果。AI 生成测试之所以特别容易假通过,是因为它在优化一个可观测的目标:让测试跑过去。把断言写松、把依赖全 mock 掉、只走最顺的那条路,这三件事都能百分百达成那个目标,而且成本最低。
所以问题不该问”AI 写的测试质量好不好”,该问”我有没有一个动作,能证明这条测试在被测逻辑坏掉时会变红”。下面按排查顺序来:先把假通过分成四类并给出各自的判别法,再给验证动作,最后说什么时候该停手。
这篇只谈测试本身的有效性验证。如果你要处理的是 agent 输出不稳定、改一句提示词全套用例就飘的回归问题,那是另一套方法,看 Agent 怎么做回归测试;如果你想让工具在提交阶段帮你挑出弱断言,那属于评审环节的选型,看 AI 代码评审工具怎么选。本篇的位置在这两者之间:测试已经写完、已经绿了、你要判断它值不值得信。
一、先分因:四类假通过,现象各不相同
不要一上来就通读几十个测试文件,那是最慢的路。先按现象把嫌疑范围收窄。
第一类,断言空转。 测试跑了被测函数,但断言没有约束任何有意义的东西。典型形态是断言结果非空、断言返回值类型正确、断言列表长度不小于零、断言过程没抛异常。这类测试的特征是:被测逻辑无论算出什么值,它都能过。快照类断言也常落进这一类——默认配置下首次运行时快照文件是被自动生成出来的,没有可比对的基线,这一次自然是绿的;如果生成的那次结果就是错的,这条测试从诞生起就在锁定错误行为。不少框架提供了「CI 模式下缺失快照直接判失败」的开关,但开关名和默认值各家不同,需要按你用的框架当前文档确认,别假设它默认开着。
第二类,把被测逻辑 mock 掉。 这是最隐蔽的一类。AI 为了让测试跑通,会把所有外部依赖打桩,但打桩范围一失控就会盖住被测对象自己。常见的两种走法:一是打桩的目标其实是被测函数内部调用的那个核心计算函数,于是测试验证的是”我让它返回 42,它就返回了 42”;二是打桩返回值直接等于期望值,断言变成同义反复。还有一种是打桩时没有按真实签名约束,被测函数的参数改了名、少传了一个参数,桩照样接受,测试照样绿。
第三类,只测 happy path。 输入合法、依赖正常、分支只走成功那一支。空输入、边界值、异常路径、并发重入、超时和重试,一律没有。这类测试不是无效的,它有价值,只是它给出的安全感和它的实际覆盖严重不匹配——你以为八成覆盖率意味着八成风险被兜住,实际上出问题的分支恰好都在剩下那两成里。
第四类,测试根本没跑。 这一类最容易被忽略,因为它连”跑过”都是假的。文件名或函数名不符合收集规则,测试从来没被收集;用例被标了跳过或预期失败,长期没人清理;异步测试没有正确挂上运行插件,协程被创建出来但没有被执行,函数体里的断言一行都没走到。这些情况下终端输出依然是绿的,只是括号里的数字比你以为的小。
二、判别表:现象、成因、验证、动作
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 故意把被测函数改坏,测试仍然全绿 | 断言空转,或被测逻辑被打桩盖住 | 手工改一行被测代码(反转边界条件、返回值置空),重跑该文件 | 重写断言,钉到具体返回值和状态变更上;删掉盖住被测对象的桩 |
| 测试代码里桩的装配比断言还长 | 打桩范围过大,测的是桩不是逻辑 | 逐个看每个桩的目标是否属于被测模块本身 | 只对进程外依赖打桩(网络、时钟、文件、数据库),同模块内部函数不打 |
| 行覆盖率高,分支覆盖率明显低 | 只测 happy path | 开分支覆盖统计,读未覆盖清单里那些「某行跳到某行」的分支条目 | 按分支补用例,优先补有 return、raise、早退的那些 |
| 用例数量和你预期的差很多 | 测试没被收集,或被跳过 | 看运行摘要里收集数与跳过数,逐个文件列出用例名 | 修命名和配置;清理长期跳过的标记,该删就删 |
| 改了被测函数签名,测试没红 | 桩没按真实签名约束 | 把桩换成带签名校验的方式,重跑 | 全套桩统一改为签名约束模式 |
| 异步测试瞬间通过、耗时接近零 | 协程未被执行 | 在测试体里故意写一个必失败的断言,看是否变红 | 补齐异步运行配置,确认失败断言能被捕获 |
| 快照测试从建立起就没红过 | 首次生成即锁定,可能锁的是错值 | 人工读一遍快照内容,核对业务预期 | 删掉快照重新生成前先核对;高价值路径改成显式断言 |
表里第一行是这套排查的主干,其余各行都是它的细化。如果你只有十分钟,就做第一行。
三、最小验证动作:故意把代码改坏
验证测试有效性只有一个可靠原理——让被测逻辑坏掉,看测试是否变红。这件事不需要引入任何新工具,手工就能做,而且比读代码快得多。
选一个你最在意的函数,改一行,让它语义明确地错。比如把一个大于改成大于等于,把折扣计算的返回值直接改成零,把一个校验分支的条件取反,或者把返回值改成空。然后只跑覆盖这个函数的测试文件:
python -m pytest tests/test_order.py -q
# 若仍然全绿,说明这条路径上没有真断言
git checkout -- src/order.py
三种结果对应三种判断。测试变红且报错位置指向你改的那个语义,说明这条测试是有效的。测试变红但报错来自完全无关的地方(比如某个桩的调用次数校验),说明它测的是实现细节而不是行为,改动一重构就会误报,这是另一种毛病。测试仍然全绿,说明这个函数在测试套件里其实是裸奔的,覆盖率数字覆盖到了它,断言没有。
改坏的位置要挑得有代表性。优先挑三类:钱和额度相关的计算、权限与可见性判断、外部输入的校验与清洗。这三类出错的代价远高于其他地方。
做完手工版之后,如果你想把它常态化,可以引入变异测试类的工具——它做的事就是自动化地批量改坏代码再跑测试,输出哪些改动没被任何测试抓住。要注意它的代价来自机制本身:工具会按变异算子在代码里生成大量变异体,每个变异体都要重跑一遍与之相关的测试,总耗时大致是「变异体数量 × 单次相关测试耗时」,所以必然比跑一遍测试套件贵得多。具体贵多少取决于代码规模、启用的算子种类,以及工具是否支持只跑覆盖到该行的测试、是否支持并行与增量(只对本次 diff 变异),这些能力各工具差别很大,以所选工具的官方说明为准。实际用法上别放进每次提交的流水线,限定到核心模块、按周或按发布节奏跑一次更现实;先看它报出的「存活变异体」清单,那就是你的测试当前抓不到的改动类型。
配合看覆盖率时,把口径从行覆盖换成分支覆盖,并且看未覆盖行的具体清单:
python -m pytest --cov=src --cov-branch --cov-report=term-missing
行覆盖只能证明这行被执行过,证明不了它的结果被检查过。分支覆盖能暴露”只走了成功那一支”,这正好对应第三类假通过。
四、mock 边界:只对进程外依赖打桩
第二类假通过要靠一条明确的边界规则来防,而不是靠 review 时的直觉。
我的判断是:只对进程外的东西打桩。网络请求、数据库、消息队列、文件系统、当前时间、随机数、第三方 SDK 的出口——这些打桩是必要的,因为它们不确定、慢,或者会产生副作用。同一个进程内、你自己写的函数和类,原则上不打桩,让它们真实执行。一旦你开始打桩自己模块内部的函数,测试的语义就从”这段逻辑算得对不对”退化成”这段逻辑按我设想的顺序调用了几个东西”。
配套的三个习惯:
第一,桩一律带签名约束。让打桩机制去校验参数个数、名称和类型,这样被测函数改签名时测试会红。不带签名约束的桩会接受任何调用,是签名漂移的温床。Python 侧对应的是打桩时按真实对象自动生成签名(autospec 这一类参数),TypeScript 侧则主要靠类型层面把桩约束成和真实函数同型,两条路都要在项目里定成默认写法,具体参数名和行为按你所用版本的文档确认。
第二,断言写在状态和返回值上,不写在调用次数上。“调用了一次某方法”这种断言是对实现的描述,重构必坏。只有一个例外值得保留:验证副作用只发生一次(比如扣款、发消息)的幂等性检查,那时调用次数本身就是业务要求。
第三,用一个粗暴的比例做体检。数一下测试文件里桩的装配行数和断言行数的比例,如果装配远多于断言,这个测试基本可以判定为在测桩。
# 粗略摸一下打桩密度和可疑断言,只作定位用,不作结论
grep -rnE "assert (True|.* is not None|len\(.*\) >= 0)" tests/
grep -rc "patch(" tests/ | sort -t: -k2 -nr | head
这两条命令只是把可疑点找出来给你看,判断还得人做。真正要盯的是第一条命令的输出,那些断言几乎都可以直接判为空转。
五、把验证固化进流程,而不是靠人自觉
一次性体检解决不了持续生成的问题。你只要还在让 AI 补测试,弱测试就会持续进来。三个成本不高的卡点:
改动范围先约束住。 让 AI 补测试时,明确要求它不许改被测代码。实践中最坏的情况不是测试写得弱,而是它为了让测试通过顺手改了业务逻辑——比如放宽了一个校验、给一个函数加了默认返回。这类改动混在几十个新增测试文件里极难发现。提交前单独看一眼被测目录的 diff:
git diff --stat -- src/
新增测试的那次提交,这条命令的输出理想情况应该是空的。关于怎么把 AI 的改动范围整体框住,可以看 AI 改动范围失控。
CI 里加一条弱断言扫描。 把上面那种正则做成一个脚本,命中就让流水线失败并打印文件行号。它不聪明,但拦得住最常见的几种空转写法,而且零维护成本。
评审时按行为读,不按语法读。 看一条测试,不看它写得整不整齐,只问一句:如果被测逻辑在这里算错,这条断言会不会红。回答不了就当它不存在。测试进不进生产防线,标准和业务代码是一样的,判断依据可以参考 AI 写的代码能上生产吗。
六、什么情况下别再折腾
这套排查有明确的止损点,超过就别在原地修了。
同一条测试让 AI 修三轮还是不红,删掉重写。 让模型修一条弱测试,它的最短路径往往是把断言换成另一种写法,而不是真正加强约束。三轮之后你花的时间已经超过自己写一条的成本,而且新写的那条你信得过。
打桩装配远超被测逻辑的规模时,换测试层级。 这说明这个函数的依赖太多,单元测试的性价比已经归零。往上退一层,用真实依赖或轻量替身(内存数据库、本地起的假服务端)跑集成测试,或者对外部接口改用契约测试。少量真跑的集成测试比一堆全是桩的单测有用得多。
覆盖率已经很高但线上照样出事,别再往覆盖率里加投入。 这是明确的信号:你的测试在验证已知路径,而事故来自未知路径。这时该把精力挪到关键业务流的端到端冒烟、线上错误监控和可观测性上,用真实流量暴露问题,再把每次真实事故回灌成一条测试。这条路的收敛速度比补覆盖率快。
回滚点:整批 AI 生成的测试如果已经开始拖慢流水线又抓不到问题,整批 revert,只保留人工确认过的少数。 判断依据很实在——过去三个月里,这批测试有没有红过一次并且那次红是真问题。一次都没有,它就不是防线,是维护负担。测试套件的价值不在数量,在它红的时候你信它。
还有一种情况是别用测试解决: 需求本身还在剧烈变动的探索期代码。这时钉死行为的测试会变成阻力,写完就得改。给它留冒烟级别的覆盖,等接口稳定再补。
七、避坑清单
把覆盖率当质量指标。 会踩是因为它是唯一能自动出数的指标,管理上天然想用它。避法是把它降级为”未覆盖清单”的生成器——只看没覆盖的地方在哪、要不要补,不看那个百分比,也别给团队定百分比目标。目标一定,凑数的弱测试立刻涌进来。
批量接受 AI 生成的测试文件。 会踩是因为一次生成几十个文件,逐个读的心理成本很高,而它们全都是绿的,没有任何摩擦提示你停下。避法是分批合,每批不超过一个模块,每批做一次故意改坏的验证。验证不过的整批退回。
用快照测试省事。 会踩是因为它写起来最快、看起来覆盖面最广。避法是首次生成的快照必须人工逐行核对,核对不了的内容就别用快照;高价值路径(金额、权限、对外协议字段)一律用显式断言。
桩不带签名约束。 会踩是因为不带约束的写法更短,AI 也倾向于生成短的。避法是在项目里统一约定带签名校验的打桩方式,并在评审清单里作为一条硬项。
测试里出现和被测代码一样的计算表达式。 会踩是因为让测试通过最省力的办法就是把被测公式抄一遍到断言里。避法是断言里只允许出现字面量常数或独立算出来的期望值。看到测试在做算术,基本可以判定它在同义反复。
让 AI 同时写代码和测试。 会踩是因为一次说完更省事。避法是分成两次、两个上下文:先定行为和用例(含边界与异常),再写实现;或者反过来先写实现,再在不允许改实现的约束下补测试。同一次生成里,测试会被写成”能过实现”的形状,而不是”能验行为”的形状。
忘了测试代码也是攻击面。 会踩是因为测试目录通常不在安全检查范围里,但它常常带着真实的连接串、密钥、生产地址的样例。避法是把测试目录纳入同一套敏感信息扫描,做法参考 AI 代码安全审计。
小结
测试的价值只体现在它红的那一刻。全绿的套件不提供任何信息量,唯一能给你信息的动作是主动把代码改坏、看它是否变红。这个动作十分钟就能做一次,比读一整天测试代码更能告诉你真相。
一份可以直接用的自检清单:
- 挑三个最贵的函数(钱、权限、外部输入校验),各改坏一行,跑对应测试,确认都变红。
- 看运行摘要里的收集数和跳过数,和你以为的用例数对得上。
- 分支覆盖打开,读未覆盖清单里的分支条目,确认没有整条异常分支裸奔。
- 扫一遍断言,凡是只断非空、只断类型、只断不抛异常的,判为无效。
- 逐个看桩的目标,凡是指向被测模块内部函数的,删掉让它真跑。
- 看新增测试那次提交里被测目录的 diff 是否为空。
- 回头查这批测试过去有没有红过一次真问题,没有就考虑砍掉。