AI 写的测试用例为什么大多没有鉴别力:怎么逼它写出真正的边界用例
数据截至 2026-07,文中提到的测试框架与变异测试工具的命令、参数以各自官方最新文档为准。
多数人把这件事归错因了:以为是模型能力不够、提示写得不够细,其实是你把实现代码喂给了它。 你打开一个文件,选中整段实现,让 AI 补单测——那一刻测试的命运就定了。它能看到实现,就会照着实现反推断言:代码里写了 if x > 0,它就造一个 x=1 和一个 x=-1;代码里返回空列表,它就断言等于空列表。这批用例描述的是”这段代码现在的行为”,不是”这段代码应该有的行为”。两者在实现正确时完全重合,所以看起来毫无破绽;两者在实现有 bug 时也完全重合,因为 bug 也被一起抄进断言里了。测试和实现同源,就没有鉴别力可言。
鉴别力这个词值得先定义清楚:一条测试有鉴别力,是指被测逻辑坏掉的时候它会变红。不是覆盖了多少行,不是断言了多少次,就这一条。你手上那批 AI 生成的用例,绝大多数在实现被改坏之后依然是绿的——这就是它们没用的全部原因。
站内另外两篇和这个话题挨着但不重叠:AI 写的测试全绿却拦不住 bug 处理的是测试已经写完、已经绿了,你怎么验它是不是假的;Agent 评测集怎么构建 处理的是评测一个 agent 系统本身该收集什么样本。本篇管的是更靠前的一步——用例还没生成,怎么组织输入和验收,让产出的用例从一开始就带鉴别力。
一、先分因:四种没鉴别力,判别方法各不相同
别一上来就重写测试,先看清是哪一种。四种成因的处置动作完全不同,认错了会白干。
复述型。断言的期望值是从实现里抄来的。典型迹象:期望值写得非常精确、非常怪,比如断言返回的字符串等于 user_0042_ok,而需求里根本没规定过这个格式。判别方法:随便挑三条断言,问自己”这个期望值我能从需求、接口文档、或者产品的口头约定里推出来吗”。推不出来的,就是抄的。
断言空转型。用例调用了函数,然后断言”没抛异常""结果不是空""类型是 dict”。这类断言在被测逻辑返回任何一个结构正确但数值错误的结果时都不会红。判别方法:把断言部分单独看,问它排除了多少种可能的返回值。只排除了 None 和异常,那它的鉴别力就只有这么多。
mock 穿透型。为了让用例跑起来,AI 把依赖全 patch 掉了,包括被测函数内部真正要验的那段计算。最后测试验证的是”mock 被调用了一次、参数是什么”,被测逻辑本身一行都没执行。判别方法:把被测函数体整个删掉只留 pass(或 return None),跑测试。还有一部分是绿的,那部分就是在测 mock。
维度缺失型。用例本身写得挺扎实,断言也具体,但只覆盖了”值的大小”这一个维度。空集合、单元素、重复元素、顺序颠倒、超长字符串、非 ASCII 字符、时区跨天、浮点尾数、并发重入、第二次调用——这些维度一个都没碰。判别方法:把用例的输入列成一张表,看它们在哪些维度上真正有差异。经常你会发现十几个用例其实只在一个维度上取了十几个点。
这四种可以同时存在,而且通常同时存在。
二、判别表
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 断言的期望值精确得可疑,需求里查不到出处 | 复述型:AI 看过实现 | 抽三条断言逆推需求来源,推不出即确诊 | 丢弃这批用例,改用只给签名和行为描述的方式重生成 |
| 断言只有非空、非 None、类型检查 | 断言空转 | 人工把返回值改成另一个结构相同的值,测试仍绿 | 逐条要求断言绑定具体值、具体异常类型或具体副作用 |
| 被测函数掏空后测试仍有一部分绿 | mock 穿透 | 把函数体替换成 pass 跑全量,记下仍绿的用例 | 只在进程边界(网络、时钟、随机、文件)mock,核心计算不 mock |
| 覆盖率很高但改坏比较符不变红 | 维度缺失 + 断言弱 | 把所有用例的输入值列出来,看有没有一条正好落在边界值上;一条都没有即确诊维度缺失(若有边界输入却仍不红,则是断言弱) | 按维度清单补边界用例,见第四节 |
| 用例数量多但内容高度相似 | 一次性生成量太大 | 把输入列表排序看差异维度 | 改成参数化用例,每轮只让它围绕一个维度扩展 |
| 测试偶尔红偶尔绿 | 时间、随机、并发未固定 | 连续跑同一条用例多次,看是否稳定 | 注入固定时钟和固定种子,把并发点显式化 |
| 改了实现后 AI 主动把断言改松让它变绿 | 目标错位 | git diff 只看测试文件,检查断言是被改还是被加 | 明确规定测试文件在修 bug 阶段只读,见第六节 |
这张表按排查顺序从上往下用。前两行先做,因为它们决定这批用例要不要整体作废——如果确诊是复述型,后面所有的修补都是在给废料抛光。
三、哪一步 AI 能干,哪一步必须你来定
这个分界线是本篇最要紧的一句话:期望行为的来源必须是人,用例的形态可以是 AI。
必须你来定的有三件事。一是”什么算对”。返回值应该是多少、异常该抛哪一个、失败时状态该回滚到哪里,这些来自需求、契约、上游约定,AI 从实现代码里读不出来,读出来的只是现状。二是边界在哪。业务上金额允许为零吗,用户名允许全空格吗,时间戳跨月结算算哪一边——这类问题的答案在产品脑子里,不在代码里。三是什么算 bug。同样一个”排序不稳定”的行为,在有些模块是缺陷,在有些模块无所谓。
可以放心交给 AI 的也有三件。一是把你定好的用例表翻译成代码,包括夹具、参数化、清理逻辑,这部分它做得又快又整齐。二是造数据,尤其是构造符合某种格式约束的脏数据、超长输入、特殊字符组合。三是重构既有测试,比如把二十个重复用例合并成一个参数化用例、把散落的 setup 提成 fixture。
顺序上有个很实际的用法:先让 AI 读需求或接口描述,产出一份自然语言的用例清单(每条一行:输入条件 / 预期结果),你在这份清单上删改增补,定稿之后再让它写代码。清单阶段你审起来很快,代码阶段你审起来很慢。把人的注意力放在清单上,产能和质量都会好一大截。这条思路和规格先行的工作方式是一致的,展开可以看 规格驱动开发怎么落地。
四、怎么逼出边界用例:四个可执行动作
动作一:隔离实现,只给契约
新写一个空文件,把函数签名、类型注解、docstring 或者接口文档粘进去,实现代码不给。让 AI 基于这些写测试。它写不出来的地方,恰恰是契约描述不清的地方——这本身就是有价值的信息,说明你的接口定义有歧义。
如果被测代码已经存在且很难剥离,退一步:让它先只读签名产出用例清单,清单定稿后再允许它看实现来写夹具。清单一旦定稿,复述实现的空间就被压掉了大半。
动作二:给出维度清单,而不是让它自由发挥
自由发挥的结果就是十几个用例挤在一个维度上。你要给的是维度轴,让它在每个轴上取点。常用的轴:
- 规模:空、单元素、两元素、大量元素
- 取值:零、负数、最小值、最大值、刚好越界、类型不符
- 结构:字段缺失、字段多余、嵌套为空、值为 null
- 文本:空串、纯空白、非 ASCII、含换行、含分隔符本身、超长
- 时间:跨天、跨月、闰日、时区不同、同一时刻的先后
- 顺序:乱序输入、重复元素、稳定性要求
- 数值:浮点尾数、精度累积、除零
- 交互:重复调用是否幂等、并发调用、中途失败后的状态、超时与重试
- 失败路径:每一个抛异常的分支都要有对应用例
不是每个轴都适用于每个函数,但逐轴过一遍的效率远高于”再多写几个用例”。
动作三:反向提问——先列故障假设,再写用例
换个说法输入:不要说”给这个函数写测试”,说”列出这个函数最可能坏在哪十个地方,每一条给出能捕获它的最小用例”。这一句话的变化很大,因为它把生成目标从”覆盖代码”改成了”捕获故障”。产出的用例通常带有明确的假设,你审的时候也更容易判断这条到底有没有价值。
动作四:变异验收——这是唯一的收货标准
前面三步都是提高命中率,真正决定收不收的是这一步。做法是手工把实现改坏,看测试红不红。改的方式选这几种最有效的:
- 把一处比较符
>改成>=(抓 off-by-one) - 把一个
and改成or - 删掉一处入参校验
- 把一个提前 return 删掉,让流程继续往下走
- 把返回值的某个字段值改成另一个合法值
每改一处跑一次测试,记结果,然后复原。复原用 git 最稳:
# 改坏之前先确认工作区干净
git status --short
# 改一处 -> 跑测试 -> 看是否变红
python -m pytest -q
# 无论红绿,改回来
git checkout -- path/to/module.py
五处变异里有三处以上不变红,这批测试就不算交付。变红的比例是可以量化的验收线,比覆盖率靠谱得多。生态里有成熟的变异测试工具可以把这个过程自动化(Python 侧有 mutmut、cosmic-ray,JavaScript 侧有 Stryker),但手工做五处的成本很低,先手工做一轮拿到直观感受,再决定要不要上工具。
覆盖率不是废的,只是位置要摆对:它是筛子,用来发现”这个分支一次都没跑到”;它不是验收,因为跑到不等于验到。把覆盖率数字写进准入门槛之后,AI 会精准地生产覆盖率——那正是断言空转型用例的温床。
五、什么时候别再折腾
有几种情况,继续在测试上加班是纯亏损,该换路。
逼了三轮还是逼不出红的。你换了输入方式、给了维度清单、做了故障假设,变异依然不红。这多半不是测试的问题,是被测代码不可测:函数内部直接 new 了数据库连接、直接读了系统时间、依赖全局可变状态、一个函数做了六件事。这时候正确的动作是停下测试,先做可测性重构——把纯计算抽成独立函数、把时钟和随机源变成参数、把 I/O 推到边界。重构完再写测试,成本会掉一个量级。硬在原结构上堆 mock,堆出来的东西下次改代码就得全部重写。
夹具比被测逻辑还长得多。搭一个用例要造五层对象、灌十张表,说明你测的粒度选错了。这一层不该做单元测试,该做窄口径的集成测试或者契约测试,只验模块边界上的输入输出。
遗留代码没有任何规格。没人说得清它现在的行为哪些是特性哪些是 bug。这种情况下直接追求”正确性测试”是缘木求鱼——你连”正确”的定义都拿不到。正确动作是先写特征测试(characterization test):如实记录当前行为,作为改动时的护栏,不做对错判断。等你要改某个行为时,再针对那一处补真正的正确性断言。这一步的取舍逻辑和 AI 项目的技术债怎么算 是一脉相承的。
回滚点怎么留。让 AI 批量补测试之前,先把工作区提干净,测试单独一个提交,和实现改动分开。这样一旦发现整批用例是复述型,git revert 或者直接删掉那个提交就能干净退出,不会和实现改动纠缠在一起。也别在同一个分支上同时让它改实现和改测试——那会让你分不清红转绿到底是因为 bug 修好了,还是因为断言被放松了。
六、避坑清单
把实现代码整段粘给它。 会踩是因为这是最省事的操作,IDE 里选中即可。后果是同源复述,前面说透了。避法:新开一个只含签名和契约的临时文件作为输入;实在要给实现,也先走用例清单定稿这一关。
用覆盖率当验收门槛。 会踩是因为覆盖率是唯一现成的、能自动算出来的数字,写进 CI 特别顺手。后果是催生大量无断言用例。避法:覆盖率只报不卡,卡的是变异存活数。
修 bug 时让 AI 同时动实现和测试。 会踩是因为你只说了”让测试过”,它就会挑成本最低的路径——改断言、加跳过标记、放宽浮点容差。避法:修 bug 阶段明确把测试文件当只读,评审时单独 git diff -- tests/ 看一遍,断言只允许新增不允许放松。相关的失控模式在 AI 改坏代码怎么回滚 里有更完整的处理。
mock 一切能 mock 的东西。 会踩是因为 mock 掉之后测试跑得飞快也不会挂。后果是被测逻辑一行没执行。避法:定一条硬规矩——只 mock 进程边界(网络、磁盘、时钟、随机、第三方 SDK),业务计算一律真跑。
一次让它生成几十个用例。 会踩是因为看着产量很爽。后果是大量近似重复,审查成本高到你根本不会认真审。避法:一次只围绕一个维度扩展,用参数化把同维度的点收进一个用例。
用快照测试代替断言。 会踩是因为快照写起来最快。后果是快照文件一旦不匹配,最省事的做法就是重新生成快照,于是这层保护自动失效。避法:快照文件必须进代码评审,更新快照的提交要单独说明理由。
测试里出现真实时间和真实随机。 会踩是因为直接调系统时钟最直观。后果是用例偶发失败,团队很快学会重跑一次当没看见,从此这条测试失去信号价值。避法:时钟和随机源作为参数注入,测试里给固定值。
浮点直接判等。 会踩是因为 AI 会照着某次运行结果把小数完整抄进断言。后果是换个平台、换个求和顺序就红。避法:金额用整数分或定点类型,其余用容差比较。
只测成功路径。 会踩是因为失败路径的构造成本更高,AI 默认走阻力最小的方向。避法:把”每个 raise 都要有对应用例”写进你的用例清单模板,清单阶段就把它们列出来。
收束:一份自检清单
写完一批 AI 生成的测试,过这七条再合入:
- 断言里的期望值,能从需求或契约推出来吗?推不出的先标记。
- 断言排除了多少种错误返回?只排除了 None 和异常的,重写。
- 把被测函数体掏空跑一遍,还有绿的吗?有就是在测 mock。
- 手工做五处变异,红了几处?少于三处不收货。
- 用例之间的差异落在几个维度上?只有一个维度就补轴。
- 有没有失败路径用例?每个抛异常的分支至少一条。
- 时间、随机、并发是否被固定?连续跑几次是否稳定?
这七条里最省时间的是第三条和第四条,几分钟就能做完,却能直接给出”这批测试值不值得信”的答案。AI 写测试这件事本身没有问题,问题在于它优化的是让测试通过,而你要的是让测试在该红的时候红——把这个差距用变异验收显式地量出来,剩下的都是执行细节。