用 Claude Code 给老代码补测试:先找缺口,再让每个测试证明自己有用
让 AI 补测试,最怕补出一堆只为凑覆盖率的用例。本站在 Express 的源码上实测了一个两步的做法:先让它找出没测到的代码并按风险排序,再补测试,并要求每个用例都通过“把代码改坏、确认测试会失败”的验证。附真实过程、它自己纠正的一处误判和我们的复核。
本站已实测,测试日期:2026-10-11。
本页目录(10 节)
这篇教程解决一个问题:给已有的代码补测试,怎么保证补出来的是有用的测试,而不是一堆只为让覆盖率好看的用例。
让 Claude Code “给这个文件加点测试”,它一定会交出一批全绿的用例。问题在于,全绿说明不了什么:一个什么都不检查的测试也是绿的。
这篇用的办法分两步:先只分析、不动手,把没测到的代码找出来并排个轻重;再补测试,并且要求每个新用例都证明自己能发现问题。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-11 |
| 版本 | Claude Code 2.1.296 |
| 系统 | Ubuntu 24.04 |
| 模型 | Opus 5.5,auto 模式 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 示例仓库 | Express 的源码,v5.3.0,没有做任何改动 |
起点:一个测试已经很全的项目
Express 自带 1261 个测试。先看覆盖率的基线:
git clone --depth 1 --branch v5.3.0 https://github.com/expressjs/express.git
cd express
npm install
npm run test-cov
lib/ 目录的结果:
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
All files | 98.75 | 96.21 | 100 | 99.61 |
request.js | 100 | 98.11 | 100 | 100 | 273
response.js | 97.08 | 93.71 | 100 | 99.08 | 827,831,835
view.js | 100 | 96.15 | 100 | 100 | 53
语句覆盖 98.75%,看起来没什么可补的。正因为这样它适合做例子:剩下的都是不显眼的缺口。你自己的项目缺口只会更多,做法是一样的。
1只分析,不写代码
跑一下 npm run test-cov,找出 lib/ 下没有被测试覆盖到的行和分支。每一处用一两句话说明:
这段代码在什么情况下才会执行,漏测它有什么风险。按风险从高到低排。先不要写任何代码。
1 分 16 秒后,它给出六项。整理成表:
| 排序 | 位置 | 什么时候才会执行 | 漏测的风险 |
|---|---|---|---|
| 1 | response.js 里发送文件的函数,930 到 979 行多处 |
文件传到一半客户端断开,或者几个事件几乎同时触发 | 这是防止回调被执行两次的逻辑。失效了会出现“响应头已发送”的报错,或者把客户端断开误报成错误 |
| 2 | response.js:642,res.append() |
给同一个响应头连续追加值,最常见的是写多个 cookie | 出问题时前面写的 cookie 会被悄悄丢掉,登录态就坏了,而且不报错 |
| 3 | response.js:826、830、834,res.redirect() |
参数不对:没传地址、地址不是字符串、参数顺序写反 | 这几处会发出弃用警告。没人验证过警告真的会发出来 |
| 4 | request.js:273,req.is() |
调用时直接传一个数组 | 文档里写明支持的用法,但逻辑很简单,出错的可能性低 |
| 5 | response.js:295,res.jsonp() |
正常情况下不会执行 | 很低。是一个防御性的分支,实际走不到 |
| 6 | view.js:53 |
直接创建 View 对象又不传选项 |
最低。Express 内部调用时总会传 |
这份清单有两点比覆盖率报告本身有用。
一、它找到了报告没列出来的缺口。 终端里的报告只列了 273、827,831,835、53 五处。它的说明是:
终端的文本报告只列出了 request.js:273、response.js:827,831,835、view.js:53 这几行,但实际漏测的不止这些。
sendfile() 里有好几处 if (done) return; 写在一行里,if 判断执行过,那一行就算覆盖了,但 return 本身从来
没执行过。我另外导出了 JSON 报告逐条核对了一遍。
排在第一位的那一项,就是这样找出来的。
二、它区分了“没测到”和“值得测”。 第 5 项它直接说了实际走不到,第 6 项说了只影响极少数用法。覆盖率数字不会告诉你这些。
先分析不动手,是官方文档反复建议的做法:把调查、计划和动手分开,免得它一上来就去解决错的问题。这一步的结果你可以改:觉得排序不对、有的不用补,现在说,比它写完再推翻省事。
2补测试,并要求每个用例证明自己
我们决定先补第 2、3、4 项。第 1 项要模拟连接中断,另算。
先补第 2、3、4 项,第 1 项先不做。要求:
1)照 test/ 里对应文件现有的写法,只测对外的行为;
2)不要改 lib/ 下的任何代码;
3)每个新用例都要做一次验证:临时把它要保护的那行代码改坏,确认这个用例会失败,再把代码恢复;
4)最后重跑 npm run test-cov,告诉我覆盖率前后的变化。
用 Edit 工具改文件,不要提交。
第 3 条是这篇的核心。一个测试有没有用,最直接的检验是:它要保护的代码坏了,它会不会失败。
这一轮用了 5 分 25 秒。它做的事情,按顺序:
先读现有的测试,再探明真实行为。 写 redirect 的断言之前,它先写了一个临时脚本,看参数不对时到底会发生什么:
● Existing tests pass. Before writing the redirect tests I'll check what res.redirect() actually does with bad
arguments, so the assertions match real……
写完之后,发现自己上一步有一处判断错了。 它对新加的用例单独跑了一次覆盖率,结果和预想的不一样:
● I found the real uncovered case: line 642's [prev].concat(val) handles when the header holds a single string
and an array is appended, not the "already an array" case I thought earlier. Let me check whether my new tests
actually cover this arm.
● The array-onto-array case was already covered, but "prev is a string, val is an array" wasn't hit by either
new test. I'll swap the second test to cover that case……
第一步里它说第 2 项漏测的是“响应头已经是数组时再追加”。实际漏测的是另一种情况:原来只有一个字符串值,再追加一个数组。它换掉了一个用例,并在最后的总结里把这个更正放在了最前面。
如果没有“重跑覆盖率”这条要求,这个错不会被发现:新测试照样是绿的,只是没有测到真正的缺口。
然后逐个改坏代码。 以其中一次为例:
● Mutation A1: make prev.concat(val) drop the new value. This should fail the three-appends test.
● Update(lib/response.js)
641 - value = Array.isArray(prev) ? prev.concat(val)
641 + value = Array.isArray(prev) ? prev
● Bash(… npx mocha --require test/support/env -R dot test/res.append.js …)
⎿ 6 passing (112ms)
1 failing
AssertionError [ERR_ASSERTION]: should have 3 occurrences of "Set-Cookie"
● A1 fails as expected. Restoring it, then mutation A2……
这样的验证一共做了 6 次,每次都是改坏、确认失败、改回来。最后它用 git diff lib/ 确认结果为空。
结果
它最后给的表(原文是表格,这里照录):
| 文件 | 用例 | 验证时改坏的地方 | 结果 |
|---|---|---|---|
test/res.append.js |
响应头已有两个值时再追加一个 | 把 prev.concat(val) 改成 prev |
失败:期望 3 个 Set-Cookie,实际 2 个 |
test/res.append.js |
只有一个值时追加数组(真正漏测的那一支) | 把 [prev].concat(val) 改成 [prev] |
失败:期望 3 个,实际 1 个 |
test/res.redirect.js |
不传地址时发出警告 | if (!address) 改成 if (false) |
失败 |
test/res.redirect.js |
地址不是字符串时发出警告 | 对应的判断改成 if (false) |
失败 |
test/res.redirect.js |
参数顺序写反时发出警告 | 对应的判断改成 if (false) |
失败 |
test/req.is.js |
传数组时返回第一个匹配的类型 | if (!Array.isArray(types)) 改成 if (true) |
失败:期望 "application/json",实际 false |
覆盖率的变化:
| 语句 | 分支 | 函数 | 行 | |
|---|---|---|---|---|
| 之前 | 98.75% | 96.21% | 100% | 99.61% |
| 之后 | 99.12% | 97.47% | 100% | 100% |
它的总结里还有两句话,说明它在判断什么该测、什么不该测:
- redirect 的测试只检查警告有没有发出。……参数非法时 redirect 后续的行为本身就很奇怪:比如不传 url 会得到
Location: undefined,参数写反会返回 500。这些属于碰巧的结果,我没有把它们写进断言,免得测试锁死这些行为。
- 第一个 append 用例(三次追加)覆盖的那一支原来已经被测到过,不算填补漏测,但它明确验证了连续写三个
cookie 的情况。如果你觉得多余,可以删掉。
第一句是写测试时很容易犯的错:把代码“现在恰好这样”的行为写成断言,以后谁改好了它,测试反而会挂。
新加的用例长这样,和文件里原有的写法一致:
describe('when given an array of types', function () {
it('should return the first matching type', function (done) {
var app = express()
app.use(function (req, res) {
res.json(req.is(['text/html', 'application/json']))
})
request(app)
.post('/')
.type('application/json')
.send('{}')
.expect(200, '"application/json"', done)
})
})
我们的复核
它报的数字,我们退出会话后自己跑了一遍:
| 它说的 | 我们跑出来的 |
|---|---|
| 1267 个测试全过,比原来多 6 个 | 1267 passing |
lib/ 没有改动 |
git diff --stat lib/ 为空,改动只有三个测试文件,共加了 123 行 |
| 覆盖率 99.12% / 97.47% / 100% / 100% | 一致 |
我们还自己做了一次“改坏”:把 request.js 第 273 行的判断改成 if (true),再跑 test/req.is.js,结果是 11 个通过、1 个失败。新加的那个用例确实拦得住。
为什么要这样要求
把这次实测里起作用的做法归纳一下,前两条是官方文档明确建议的,后三条是这次试出来的。
| 做法 | 作用 |
|---|---|
| 要测什么行为说具体 | 官方文档:要测试时,明确说你想验证的是什么行为 |
| 让它照现有测试的写法 | 官方文档:它会去读已有的测试文件,沿用同样的风格、框架和断言方式 |
| 先分析再动手 | 你有机会在它写之前改排序、砍掉不值得测的 |
| 改坏代码,确认测试会失败 | 筛掉那些永远是绿的、什么都不检查的用例 |
| 重跑覆盖率,报告前后变化 | 确认新测试打到了要补的地方。这次它靠这一步纠正了自己的误判 |
官方文档还有一条相关的建议:可以让它帮你找漏掉的边界情况,它会分析代码路径,提出错误条件、边界值、意外输入这些容易被忽略的测试。第一步做的就是这件事。
哪些不要硬补
覆盖率不是目标。这次我们留下了三项没有补:
- 第 1 项(连接中断时的处理)风险最高,但最难测。 要在测试里让客户端在传输中途断开,或者模拟多个事件同时触发。这类测试容易写得不稳定,值得单独花时间做,不适合和简单的用例混在一起一次交给它。
- 第 5 项实际走不到。 为了覆盖率去测一个不可能发生的分支,是浪费。
- 第 6 项影响极小。
哪些该补、补到什么程度,是你的判断。它能把依据摆出来,决定要你来做。
可以直接用的提示词
第一步:
跑一下覆盖率(命令是:……),找出 …… 目录下没有被测试覆盖到的行和分支。每一处说明:
这段代码在什么情况下才会执行,漏测它有什么风险。按风险从高到低排。先不要写任何代码。
第二步:
补第 …… 项。要求:
1)照现有测试文件的写法,只测对外的行为,不测实现细节;
2)不要改被测的代码;
3)每个新用例都要验证:临时把它要保护的那行代码改坏,确认这个用例会失败,再恢复;
4)最后重跑覆盖率,告诉我前后的变化;
5)哪些行为是碰巧如此、不该写进断言的,单独告诉我。
如果第二步里的几条规矩你每次都要说,把它们写成只对测试文件生效的规则,做法见CLAUDE.md 怎么写里“只对一部分文件生效的规则”一节。
这一趟花了多少
/cost 显示 0.95 美元,模型工作 4 分 4 秒,是这个系列到目前为止最贵的一次。原因是验证:6 次改坏、6 次重跑测试,再加上覆盖率的重跑,每一步都要带着越来越长的对话。这是 Claude Code 按官方价格估算的数字,不是中转站实际扣的金额。
想省一点,可以只对重要的用例要求“改坏验证”,简单的用例跳过。
这次实测没有覆盖到的
- 只在一个 JavaScript 项目、一种测试框架上试过。 其他语言和框架上,覆盖率工具和“改坏”的做法都要相应调整。
- 补的是三处简单的缺口。 风险最高的那一项(并发和连接中断)没有尝试。
- 没有涉及给新功能先写测试。 这篇只讲给已有代码补测试。
- 只跑了一次。 它这次找到了报告之外的缺口、纠正了自己的误判,不代表每次都会。所以复核那一步不能省。