乐于分享
好东西不私藏

我把 AI 补单测的实践,沉淀成了一个可以复用的 Skill

我把 AI 补单测的实践,沉淀成了一个可以复用的 Skill

前几篇文章,我一直在讲一件有点大的事:

怎么让 AI 去处理一个历史工程里的单测覆盖长任务。

先讲 Claude Code 的长任务机制。

再讲 历史单测覆盖这类任务,白天怎么拆、晚上怎么跑、早上怎么看证据。

然后又补了一篇:AI 能把覆盖率推上去以后,单测的价值到底在哪里。

写到这里,我发现还有一块没讲完。

真正值得留下来的,不是某一次我让 AI 跑了多久,也不是某个模块覆盖率涨了多少。

这其实也接上了我前面写过的几篇 Skill 方法论。

我之前写过 为什么 Skill 比 Prompt 更值得沉淀,也写过 一个 Skill 写坏了是什么感觉,后来还写过 怎么把团队里的私有 Skill 管起来。

但那些文章更多是在讲方法论。

这一次,单测覆盖长任务把这套方法论重新打了一遍。

更值得留下来的,是这次实践里反复出现的那些判断:

什么模块适合推进单测?

覆盖率低的时候先看哪里?

AI 说“这行不可覆盖”,我能不能信?

什么时候继续逼它想办法,什么时候停下来登记?

测试 PR 能不能顺手改生产代码?

覆盖率门禁到底怎么挂,才不会变成一个好看的摆设?

这些问题,如果每次都靠临场提醒 AI,下一次还会重新踩一遍。

所以后来我把它整理成了一个 Skill。

名字叫 unit-test-cover。

我更想讲的是:一次 AI Coding 实践,怎么从“我给了一段 Prompt”变成“我沉淀了一套可复用的工作规程”。

一句“帮我补单测”不够

一开始,我当然也想过直接说:

帮我给这个模块补单测,把覆盖率推上去。

这句话太顺了。

尤其现在的 Claude Code、Codex 这类工具,确实能读代码、写测试、跑命令、修失败,还能一轮一轮往前拱。

但这句话的问题也很明显。

它只说了目标,没有说边界。

AI 会补测试。

但它可能补出只执行、不保护的测试。

比如只断“结果非空”,或者只断“没有抛异常”。

它也可能为了让覆盖率好看,去测一堆没有业务意义的 getter、空分支、防御性残留。

它可能根据当前实现反推断言。

如果当前实现里本来有 bug,它就一本正经地把 bug 写成“预期行为”。

它还可能把真实依赖全部 mock 掉,然后告诉你“已经覆盖了”。

更麻烦的是,补测试时经常会撞到历史代码不好测。

这时候 AI 很容易顺手改一下生产代码。

改完测试过了,看起来很积极。

但 review 的人就难受了。

这批改动到底是在补测试,还是偷偷改了行为?

如果线上老行为被改掉,是修 bug,还是破坏兼容?

这种时候,一句 Prompt 兜不住。

它需要一套规程。

下面这张图想表达的就是这个转变:左边是一句临时 Prompt 和零散测试笔记,右边是一套可以反复调用的 Skill 工作规程。真正变化的不是 AI 变聪明了,而是人把边界、步骤和验收方式写出来了。

unit-test-cover 不是写测试 Prompt

我后来给这个 Skill 定的目标,不是“生成测试”。

而是“推进一个模块的单测覆盖到可收口状态”。

这两个说法差别挺大。

生成测试,只关心产出测试文件。

推进覆盖,还要关心准入、复测、判断、门禁和登记。

我把它压成五个动作:

阶段要解决的问题
清盘这个模块适不适合用单测推进
建测先按什么规范和测试栈写
冲覆盖每轮怎么看未覆盖行、套什么技法
挂门禁覆盖率 floor 怎么防倒退
登记疑似缺陷、死代码、真不可约放哪里

如果把 unit-test-cover 画成流程,它更像下面这五步:先看模块是否适合进入单测覆盖,再按规范建测,接着一轮轮冲覆盖,最后挂门禁并把疑似问题登记下来。它不是一口气把测试写满,而是每一步都留下证据。

这五个动作里,真正有价值的不是“让 AI 干活”。

而是把人脑里那些容易遗漏的判断,提前写进流程。

比如:

启动层、集成层、模拟器,不要硬拿单测覆盖去啃。

真实中间件风险,不要在单测里用半真半假的方式糊过去。

疑似生产 bug,不要在测试 PR 里顺手修。

覆盖率门禁不是越高越神圣,而是要基于当前实测 floor,后面只升不降。

这些话如果只在聊天里说一次,很快会丢。

写进 Skill,下一次它就会变成启动规则。

第一层:先读单一事实源

unit-test-cover 里面最重要的一点,是它没有把所有规范都塞进 Skill 正文。

它先要求 AI 去读三类文档。

在一个真实项目里,大概会是这样:

docs/test/module-unit-test-conventions.mddocs/test/module-unittest-playbook.mddocs/test/test-findings.md

第一份是单测规范。

它负责写清楚测试框架、目录约定、mock 纪律、覆盖率门禁模板,以及哪些事情不能在单测里做。

第二份是模块 playbook。

它更像实战手册。

某类代码怎么测,哪些地方容易误判为不可覆盖,哪些分支可以用什么技法打下来,都放在这里。

第三份是 findings。

它记录测试过程中发现的疑似缺陷、死代码、真不可约和后续处理建议。

这一点对历史工程很重要。

因为补测试一定会照出问题。

照出来以后不能装作没看见,也不能顺手乱修。

先登记。

测试 PR 只负责补保护。

生产行为修复另走分支。

这条纪律看起来慢,但能让 review 人知道:这次改动到底是什么。

第二层:每一轮都要看证据

unit-test-cover 的执行环很土。

先跑测试。

再读覆盖率。

找最大未覆盖类。

再去看具体未覆盖行。

然后小批补测试。

补完再跑全模块。

听起来没有什么玄学。

但历史单测覆盖最需要的,恰恰是这种土办法。

因为 AI 很容易在对话里给你一种“我正在推进”的感觉。

可到底推进到哪里了?

哪些类还裸着?

哪些行是没有测到,哪些行是测不到?

失败是测试写错了,还是历史行为没说清?

这些都不能靠总结。

要看命令、覆盖率报告、未覆盖行和失败清单。

所以 Skill 里会要求每一轮都做几件事:

动作目的
跑单模块测试确认当前状态,不在脏基础上写
读 coverage 报告找到真实缺口,不凭感觉补
排未覆盖类先处理最大风险面
查未覆盖行判断是可测、不可约还是死代码
小批补测试降低一次 diff 的 review 难度
全模块复测防止局部过了、整体坏了

这套流程并不酷。

但它能让 AI 的“努力”变成可验收的证据。

第三层:把经验做成技法表

这次实践里,最适合沉淀成 Skill 的,是“类型到技法”的决策表。

因为补单测不是从零发明。

很多问题会反复出现。

比如 service 编排类怎么测。

配置类怎么测。

静态工具怎么测。

构造器里直接 new 出来的客户端怎么测。

网络 handler 怎么测。

并发分支怎么测。

AI 第一次遇到,可能会绕路。

第二次还靠口头提醒,就有点亏了。

所以我把它们整理成一张表:

被测类型更适合的做法
service / repository 编排mock 依赖,断返回值、状态和协作者入参
configuration / properties直接构造,注入配置值,逐个产物断言
纯确定性静态工具直接真实调用,不为了 mock 而 mock
静态外部边界用 static mock 包住边界
构造器内 new 的客户端construction mock,覆盖成功和失败分支
网络 handler用内存 channel 或 mock context,不连真实网络
wrapper / DAO初始化必要元数据,断查询条件或写入参数
private 方法 / 不可达守卫谨慎反射直达,用来固定历史行为
并发分支用 latch / lock 控制时序,不靠 sleep 赌
真不可约不硬测,写清原因,交给集成测试或人工确认

这张表不是为了炫技。

它的价值在于减少 AI 的自由发挥。

比如网络相关代码,不能为了补单测就真的连一个外部服务。

单测这一层要保持便宜、稳定、可重复。

再比如并发分支,不能靠 sleep 碰运气。

能用锁和 latch 控住时序,就把时序写死。

还有 private 方法和不可达守卫。

这类东西不能滥用反射。

但历史代码里确实会有一些防御分支、兼容分支、死代码边界。

如果要判断它到底是不是不可约,偶尔需要用反射把行为钉出来。

关键是,Skill 里要写清楚:

反射不是常规入口。

它是最后的核验工具。

真不可约也不是 AI 说一句“测不了”就算。

要先穷尽更合理的技法。

实在不适合单测,再登记原因。

第四层:门禁不是装饰

单测覆盖推进到最后,一定会碰到门禁。

以前我对 coverage gate 的理解比较粗。

好像挂上去就行。

这次实践以后,我更愿意把它叫做 coverage floor。

它不是荣誉墙。

它是棘轮。

意思是:

这个模块已经建立到某个最低保护水平了,以后不要再掉回去。

所以 unit-test-cover 里会强调几件事。

第一,floor 基于实测结果。

不要拍脑袋写一个漂亮数字。

第二,只升不降。

后续有人删测试、绕测试、让覆盖率掉下去,门禁要能拦住。

第三,不为了过门禁降低断言。

覆盖率数字上去了,但测试不保护行为,这种测试没有意义。

第四,不为了过门禁假造中间件。

单测不应该半真半假地连 DB、MQ、Redis、HTTP 服务。

真实依赖就放到代码级集成测试里验证。

这句话在前一篇也讲过。

单测的价值不是替代所有测试。

它是用最快、最稳定、最容易定位的反馈,挡住局部变更风险。

门禁也应该服务这个目标。

最小可复用 Skill 模板

如果把 unit-test-cover 压成一个读者可以改的版本,我会先保留这个骨架:

name: unit-test-coverdescription: Use when driving one module's unit-test coverage  to the agreed coverage gate.# unit-test-cover把“清盘 → 建测 → 冲覆盖 → 挂门禁 → 登记”固化成可复用流程。## 输入`$ARGUMENTS` = 目标模块名。为空则先询问。## 单一事实源执行前先读:- `docs/test/module-unit-test-conventions.md`- `docs/test/module-unittest-playbook.md`- `docs/test/test-findings.md`缺失时停止,不凭记忆补全流程。## 准入1. 目标模块必须适合单测保护。2. 启动层、集成层、模拟器、未启用模块不进入本流程。3. 从最新主干拉新分支。4. 先确认模块依赖能构建。## 执行环1. 跑单模块测试和覆盖率。2. 读取 coverage 报告,找最大未覆盖类。3. 按 playbook 的类型到技法表补测试。4. 小批推进,每批跑全模块测试。5. 达到当前天花板后挂门禁。6. 登记疑似缺陷、死代码和真不可约。## 红线- 测试 PR 零生产代码改动。- 不假造中间件。- 不为了覆盖率降低断言。- 疑似生产 bug 先登记,不顺手修。- 真不可约必须写清原因和后续验证层级。

这份模板不能直接解决你的项目问题。

但它给了一个起点。

真正要复用时,至少还要补三份东西:

文档你要写什么
conventions本项目测试框架、命令、目录、门禁和红线
playbook常见代码类型、测试技法、踩坑记录
findings测试过程中发现但不在本 PR 修的问题

没有这三份文档,Skill 很容易退化成一段长 Prompt。

有了这三份文档,Skill 才能每次启动时重新加载项目规则。

这也是我现在越来越喜欢 Skill 的原因。

Prompt 更像一次提醒。

Skill 更像一份会被反复调用的工作说明。

和 /goal 接起来,爽点才出来

只写 Skill,其实还不够爽。

它更像一份工作规程。

真正把长任务跑起来,要把它和 /goal 接上。

比如单个模块可以这样写:

/goal 按 unit-test-cover 推进 <模块A> 单测覆盖,直到单模块测试通过、coverage floor 达标、疑似缺陷和真不可约都登记完成。

多个模块也可以拆成清单:

/goal 按 unit-test-cover 依次推进 <模块A>、<模块B>、<模块C>,每个模块独立跑测试、补覆盖、登记 findings、输出验收证据。遇到需要改生产逻辑或历史行为不明确时,停止当前模块并记录。

这时候体验就不一样了。

以前你要一轮一轮提醒:

•继续看 coverage。

•继续补这个类。

•再跑一次测试。

•失败原因写清楚。

接上 /goal 以后,这些“继续推进”的动作会变成目标的一部分。

在规范、命令和模块边界都写清楚的情况下,它可以自己跑基线、看未覆盖类、补测试、复测、登记 findings,再继续下一轮。

中间基本不用人工干预。

你早上回来看的,不是一段“我已完成”的总结,而是一批测试、覆盖率变化、命令结果和问题登记。

这件事确实很爽。

爽点不是 AI 突然会魔法。

爽点是:你不用再把同一句“继续按这个规则推进”重复说十几遍。

再给 AI 一张任务卡

除了 Skill,我还会给每个模块一张任务卡。

它不需要复杂。

关键是写清边界。

你现在处理【<模块名>】的单测覆盖推进。目标:- 补齐关键业务行为单测。- 提升到当前约定的 coverage floor。- 疑似缺陷、死代码、真不可约写入 findings。允许改动:- 测试文件。- fixture。- mock。- 测试辅助工具。禁止改动:- 生产逻辑。- 接口行为。- 历史兼容分支。- 覆盖率门禁预期。必跑命令:- <单模块测试命令>- <覆盖率或 verify 命令>停止条件:- 连续 3 轮无法推进。- 需要修改生产代码才能继续。- 历史行为预期不明确。- 必跑命令无法运行。最终输出:- 新增测试清单。- 执行命令和结果。- 覆盖率变化。- 待确认问题。- findings 登记项。

这张图对应的是我现在更习惯的协作方式:Skill 提供通用工作规程,任务卡限定当前模块边界,AI 负责在这两个约束之间推进,并把命令、覆盖率和 findings 作为证据交回来。

这张任务卡解决的是另一个问题:

Skill 是通用流程。

任务卡是当前模块的边界。

如果只靠 Skill,AI 知道怎么推进,但不知道这一轮具体目标。

如果只靠任务卡,AI 知道当前目标,但容易忘掉长期纪律。

两者合在一起,再交给 /goal 持续推进,才会从“AI 能帮我补测试”,变成“AI 可以自己把一整个模块,甚至一组模块,按同一套规程推进到可验收状态”。

这可能才是这轮单测覆盖实践里最值得留下来的东西。

不是某个神奇提示词。

而是一套下次还能继续用的工作规程。

相关学习资料