上一篇结束在一个报错循环里。这次换个开场:你不贴报错,只说“帮我给这段代码写点测试”。几分钟后拿到一个 test_export.py,跑一下,全绿。
全绿看着像验收通过,其实什么都没证明。绿色有两种来源:代码真的对了;或者这些测试本来就不可能红。后者更常见,也更危险,因为它把“没检查”伪装成了“检查过了”。
这篇要你亲手做一件事:让 AI 写一组测试,再用三种方式证明这组测试真的会拦住错误。
开工前准备三样:一个能跑 pytest 的目录,里面有个可 import 的模块(下面用 export.py);一份你自己写过的验收标准(没有就从上一篇需求的“完成标准”“出错表现”两栏抄);一个干净的 git 工作区——后面要反复改坏代码再还原。
第 0 步:先把验收标准写成表
测试里唯一有价值的东西是“应该等于什么”,这部分叫测试的 期望值(test oracle)。AI 最容易糊弄人的地方也在这里:它写得出格式漂亮、断言具体的测试,但断言的期望值是从你现有代码里抄来的。抄来的期望值只会把当前行为(包括 bug)固定下来。
所以期望值必须由你先给,来源只能是需求。接着上一篇那个解析金额的函数:
| 输入 | 期望 |
|---|---|
79.00 | 79.0 |
1,299.00 | 1299.0 |
N/A | None |
"" | None |
-5.00 | -5.0(退款是合法负数) |
1.299,00 | 抛 ValueError(欧陆格式,我们不接受) |
最后一行是上一篇埋的伏笔:1.299,00 去掉逗号会变成 1.29900,float 高高兴兴接受,总额悄悄错掉。这种“不报错但结果错”的问题,正是测试唯一能长期看住的东西。
第 1 步:把表交给它,但别把实现交给它
为了让 AI“写得更贴合”,很多人会把 export.py 一起贴进去。看起来是给上下文,实际是给答案。
有研究量过代价:提示里给正确代码时,模型生成的测试在 HumanEval 上准确率 80.45%,换成有 bug 的代码掉到 57.12%;在 APPS 上,给正确代码时测试能检出 39.85% 的 bug,给错代码只剩 19.61% 1。另一项实验结论一致:模型生成的断言主要捕捉 实际行为,而不是 期望行为,而且它判断别人给的断言对不对的能力比生成断言还差 2。
原理不复杂:期望值要么来自你写的需求,要么来自它读到的代码。你把代码递过去,等于让它照抄现状,包括 bug。
第一次请求照这个形状写:
附件是一张验收表,六行输入和期望。先只写 pytest 测试,不要写实现。
- 一条测试只验表里一行,函数名说明是哪种情况,上方注释标出它对应表里第几行。表里没有的情况标成“待确认”,不要自己加。
- 断言值照表写,不要用
assert result is not None这种不判具体值的写法。- 不要断言日志文本、不要断言私有函数被调用、不要断言内部数据结构。
- 每条测试现在都应该跑不过——如果现在就能通过,说明它没在测需求。
第 4 条给测试定了一个可检验的条件:现在必须红。
第 2 步:先跑一遍,确认它红在对的地方
1pytest -q你要看到的失败是 AssertionError 或 ValueError,而且落在断言那一行:
1FAILED test_export.py::test_thousands_separator
2FAILED test_export.py::test_missing_value两种跑偏要当场修,别往后拖:
ImportError:连函数都还没写。先让它补一个最小签名(def parse_amount(text): raise NotImplementedError)再继续,这类失败会掩盖真正的问题。- 全绿:第一个警报。要么表里的行为已经实现,要么这批断言是照着现有代码抄的。后者就是“永远通过”的测试:以后永远绿,也永远拦不住任何东西。
第 3 步:让它实现,但在提示里堵住一条路
正常让它写实现,但必须加一句:
只改
export.py,不要修改测试文件和任何断言。如果你认为表里某一行写错了,停下来说明哪一行、为什么,等我确认。
不写这句,它十有八九会顺手把测试改成能过的样子:把 1,299.00 那一行的期望改成 None,或者给失败用例加个 pytest.skip。这不是模型格外狡猾,而是“让测试变绿”本身就是它被优化的方向。有研究评测了几款基于 LLM 的测试生成工具,发现那些会过滤或“修好”失败测试的工具,最终交付的套件反而更容易把 bug 当作正确行为验证通过 3。
这条和上一篇守的界线是同一条:报错消失不等于问题解决,测试变绿不等于行为正确。
第 4 步:亲手改坏代码,看它拦不拦
全绿了。但前面的绿色只说明“代码符合测试”,不说明“测试能发现错误”。把这两件事分开检查的方法是:故意把代码改坏一处,看有没有测试变红。
这个动作叫变异检查:往代码里注入一个人为的错误,如果测试无法区分坏版本和原版本,说明它没在核对这块行为。工程上它有成套工具和指标,在你手上手工做五个就够——每改一处跑一次 pytest -q,记下哪条红,然后 git checkout -- export.py 还原:
| 把实现改坏成 | 应该有测试变红 |
|---|---|
float(text.replace(",", "")) → 再多去掉一个 . | test_thousands_separator |
N/A 分支 return None → return 0.0 | test_missing_value |
| 去掉负号处理,负数直接抛错 | test_negative_refund |
1.299,00 从抛 ValueError 改成返回 None | test_european_format_rejected |
某个边界 < 改成 <= | 看情况——这一条常常 没有 测试变红 |
最后一行不是凑数。边界上的一个等号,是 AI 写代码时最容易出错、也最容易被测试漏掉的地方。改完毫无反应,你就找到一个真实的漏洞:那里没有被核对,只是被执行过。
为什么不能看覆盖率。 覆盖率只统计“这些行被执行过”,不统计“执行结果被检查过”。一项评测直接点出了这个漏洞:模型可以调用类里所有方法却什么都不检查,照样拿到 100% 覆盖率和 100% 通过率 4。另一项研究给出了更刺眼的对照:模型生成的测试在某个题目上达到 100% 的行覆盖和分支覆盖,对应的变异分数只有 4% 5。
第 5 步:读一遍测试,认出三种坏味道
变异检查能发现“漏测”,读代码能发现“测错了对象”。拿到测试先人工扫这三种:
没有断言的测试。 只有调用,或者只写 assert result is not None。它永远通过,因为没有任何东西能让它失败。换成表里那一行的具体期望值,换不了就删掉。
一个测试里一串断言,还说不清谁是谁。 失败时你只知道这个测试挂了,不知道挂在哪一条,也不知道它对应哪条验收标准。统计上它在 LLM 生成的测试里极其普遍,class 级 38–49%,method 级 92.8–99% 6。它不必然错,但你必须能把它拆回“一条断言对应验收表一行”。
断言绑在实现上。 断言 mock 被调用几次、断言私有函数被调用、断言日志文本、断言内部列表的顺序。判断只用一句话:换一种写法实现同样的行为,这条测试还该不该通过? 不该通过的,它测的是实现,不是需求。代价不在今天,而在三个月后:行为没变、你重构代码,它全红,于是你被迫改测试——改着改着就变成“改测试让 CI 变绿”,又绕回第 3 步的坑。
顺带检查异常场景。研究里 LLM 生成的测试覆盖异常处理的比例,class 级只有 7–23%,method 级几乎为 0 6。回到你的表,1.299,00 那一行必须是 pytest.raises(ValueError),而不是一个随便测测的通过用例。
第 6 步:把漏掉的那几处喂回去
第 4 步里没变红的改动,是最好的提示词素材,比“再加强一下测试”具体一百倍:
我把
1.299,00的分支从抛ValueError改成返回None,测试全绿。补一条能区分这两种行为的测试,只改测试文件。
然后回到第 4 步再撞一次。这个“生成—撞坏—反馈—补测”的循环在研究中明显提高了测试的判别力,同一批题目上的变异分数到 89% 左右,远高于纯提示 5。
什么时候不必这么折腾
一次性脚本、还在摸索写法的原型,不值得走完整套流程。判断标准不是代码长短,而是 这段逻辑会不会再跑一次、会不会算钱、会不会决定谁能看到什么。会,就留测试。
还有一条边界:测试本身也会腐烂。绑死实现的断言会在下次重构时逼你改测试,为了迁就旧测试而保留的错误设计也会一直留着。所以第 5 步那句判据值得反复用:它测的是行为,还是这一版写法。
测试之所以值得这些功夫,是因为它是整条协作流程里唯一能由机器替你反复核对的部分。它成立的前提只有两个:期望值来自需求,而且你真的试过把它撞坏。