单元测试:覆盖率考的是输入,变异考的是断言
环境:Windows 11 x64;Node v22.19.0(内置
node:test+--experimental-test-coverage,全程没装任何依赖);GCC 13.1.0 全路径D:/Qt/Tools/mingw1310_64/bin/g++.exe;Qt 6.9.3 mingw_64。仓库级数字以提交 91028b2 为锚点(49 个提交、90 个跟踪文件、锚点处 53 篇文章、src/utils/post.ts95 行 12 个导出)。所有实验都在C:/Users/wangt/AppData/Local/Temp/agents/engineering3/里做:被测代码是逐字从post.ts按行号抠出来的副本(第 15-19、21-24、26-29、31-36、65-73、75-77 行),仓库文件一行没改,也没跑npm/npx(总控在构建)。变异矩阵每轮独立跑过两遍,判定序列一致。
一、先承认现实:这个仓库一个测试都没有
| 检查项 | 命令 | 实测 |
|---|---|---|
| 测试文件 | git ls-files | grep -icE 'test|spec' | 0 |
| 测试依赖 | package.json 的 devDependencies | None(只有 3 个运行时依赖) |
| CI 里的测试步骤 | deploy.yml 中所有 run: | ['npm ci', 'npm run build'] → 0 条 test/lint/check |
| 本地钩子 | .git/hooks 里非 .sample 的文件 | 0 |
所以本仓库的唯一门禁是”能不能构建”。这不是缺陷清单,是这一节的起点:没有测试的项目,问题不是”没测”,而是”哪些代码根本没法定位测什么”。
二、可测试性的判据:这个函数要不要别人给它数据
先看一个真实报错。仓库里唯一的纯逻辑文件是 src/utils/post.ts,我试着直接 import 它:
$ node -e "import('file:///C:/Users/wangt/data/blog/src/utils/post.ts')..."
ERR: ERR_UNSUPPORTED_ESM_URL_SCHEME | Received protocol 'astro:'
第 1 行 import { getCollection } from 'astro:content' 是 Astro 的构建期虚拟模块,Node 认不出来。这个文件里 12 个导出,只有 1 个用到 getCollection,但整份文件因此无法被 import。按”要不要外部给数据”分类:
| 函数 | 依赖 | 直接可测? |
|---|---|---|
formatDate isoDate readingMinutes tagFolder tagHref + 内部 utc | 只依赖入参 | ✅ 6 个(抠出来 31 行、1184 字节,grep -c astro = 0) |
getPosts | getCollection(虚拟模块) | ❌ 必须注入数据源 |
groupByYear tagCounts postsBySection postsInCategory | 只吃 Post[] 参数 | ⚠️ 逻辑可测,但拿不到合法的 Post 对象(类型来自 getPosts 的返回值) |
这就是可测试性设计的全部判据:依赖以参数进来 → 能测;依赖从全局/虚拟模块拿 → 测不了,或者要 mock。第三行那 4 个函数是最典型的浪费——它们本身是纯函数,却因为类型定义挂在一个不可 import 的函数上,连带没法测。
三、30 条断言测出来的不是”对不对”,是”它到底怎么算的”
node --test 的输出(真实):
1..4
# tests 15
# suites 3
# pass 15
# fail 0
# duration_ms 433.8948
15 个 test 块、30 条 assert.*。断言真正的收益是这批行为被钉住了:
| 输入 | 输出 | 为什么值得写进测试 |
|---|---|---|
| 449 / 450 个汉字 | 1 / 2 分钟 | 阈值在 450,Math.round 半进位 |
500 个中文标点(,。:()×100) | 1 | 标点一个都不计(正则只收汉字与假名) |
`汉`×600(行内码)/ ```text…``` 围栏 600 汉字 | 都是 1 | 代码被整体剥离 |
只有开头一个 ``` 的围栏 | 2 | 未闭合的围栏不剥,600 字照算 |
2026-10-15T00:30(本机 UTC+8) | isoDate → '2026-10-14' | 无时区的时间按本地解析,再取 UTC 日 → 退一天 |
'Windows / Win32 编程' | 'Windows - Win32 编程' | 只换斜杠,空格原样保留进目录名 |
CON / NUL / lpt9 / Git. | 原样返回 | 保留名与结尾点号完全没处理 |
表里”退一天”和”保留名”这两行,是这一节里唯一两个只能靠人判断要不要紧的发现(前者在评审那一节展开)。测试做的是:把它们从”我记得好像是这样”变成”跑一次 434 毫秒就能确认”。
四、覆盖率 100%,缺陷测试 63.6%
覆盖率报告(真实输出):
# -------------------------------------------------------------
# file | line % | branch % | funcs % | uncovered lines
# -------------------------------------------------------------
# pure.ts | 100.00 | 92.86 | 100.00 |
# -------------------------------------------------------------
行 100%、函数 100%、分支 92.86%。听起来很好。于是我做了另一件事:手动往被测代码里植入 11 个缺陷(每个只改一个记号),每改一个就跑一遍那 30 条断言,看它是否变红。
mutant round1(15) round2(18)
M1 cjk/300->350 KILLED KILLED
M2 round->floor KILLED KILLED
M3 max(1->0 KILLED KILLED
M4 不剥围栏 SURVIVED SURVIVED
M5 词类收窄 SURVIVED KILLED
M6 day不补零 KILLED KILLED
M7 UTCDate->Date KILLED KILLED
M8 删掉 0x2f KILLED KILLED
M9 tag->x KILLED KILLED
M10 0x20->0x10 SURVIVED KILLED
M11 不折叠-- SURVIVED KILLED
KILLED 7/11 (63.6%) 8.4s | KILLED 10/11 (90.9%) 7.3s
一轮 11 次测试运行,全部跑完 8.4 秒(单次 0.36-0.67 秒)。三个必须记住的对比:
| 指标 | 第一轮(15 test / 30 断言) | 第二轮(18 test / 36 断言) | 变化 |
|---|---|---|---|
pure.ts 行覆盖 | 100.00% | 100.00% | 0 |
pure.ts 分支覆盖 | 92.86% | 93.33% | +0.47 个百分点 |
| 变异杀死率 | 63.6%(7/11) | 90.9%(10/11) | +27.3 个百分点 |
第二轮我只加了 3 个 test 块、6 条断言,而且它们不是随机加的——我把 4 个存活变异体逐个”喂输入”,找能区分原始实现与缺陷实现的值:
M5 -> "don't stop me now "×400:原=8 分钟,缺陷=10 分钟(字符类里的撇号被砍掉,词数翻倍)
M10 -> tagFolder('a\x11b'):原='a-b',缺陷='a\x11b'(0x11 不在阈值 0x10 以下,漏成控制字符)
M11 -> tagFolder('C++//QT'):原='C++-QT',缺陷='C++--QT'(连续非法字符没折叠)
结论很清楚:覆盖率考核的是”你的输入有没有踩到每一行”,变异测试考核的是”你的断言能不能分辨两个实现”。前者能自动化、能当门禁,但它对”断言写得松”这件事完全不敏感——M1(把阅读速度 300 改成 350)在真实数据上会让 54 篇文章的时长集体变短,而它被杀掉只是因为刚好有一条 449/450 的边界断言。
那个杀不掉的 M4
M4 删掉了”剥离成对围栏代码块”的那次 replace,两轮都全绿。我第二轮又构造了 4 组输入去区分它:
M4 -> 0 diffs
M5 -> 2 diffs
4 组里包含 ```text + 600 汉字 + ``` 这种正面命中,也包含 400 词量级的长文本,原实现与 M4 的输出一次都没差过。原因是剩下的那次”剥离行内代码”的 replace(/[^]*/g, ”)` 会把三个反引号当成两次配对,顺手把围栏里的正文一起吃掉——在这个输入空间里 M4 是等价变异体。这是变异测试的固有成本:它给的是”断言强度”的下界估计,不是质量分,而且必须有人逐个确认存活体到底是”没测到”还是”测不到”。我没能证明 M4 在任何输入下都不可区分,我只证明了我构造不出能区分的输入。
五、依赖注入的真实成本:一个参数,换来 5 条断言
getPosts 不能测,不是因为逻辑复杂,是因为它自己去拿数据。把它改成注入式(比较器逐字抄 post.ts 第 8-10 行,只把 getCollection 变成参数):
export type Fetch = () => Promise<Row[]>;
export async function getPostsFrom(fetch: Fetch) {
const posts = (await fetch()).filter((p) => !p.draft);
return posts.sort((a, b) => b.date.valueOf() - a.date.valueOf() || a.id.localeCompare(b.id));
}
桩数据是 8 行假记录(2 条草稿、4 条同一天、id 故意乱序)。第一次跑就红了,而且错在我写的期望值:
not ok 2 - 跨天倒序 + 同一天按 id 升序
Expected values to be strictly deep-equal:
+ actual - expected
[ 'apple', 'fig', 'kiwi',
+ 'zebra',
'mango',
- 'zebra',
'banana' ]
zebra 是 10-15、mango 是 10-14,倒序时 zebra 在前,我按输入顺序把它排到了后面。改断言、不改实现,第二次:
# tests 5
# pass 5
# fail 0
# duration_ms 385.3864
5 条断言里有一条是专门给”隐式契约”写的:postsBySection 对每个章节做 date 升序 sort,而同一天里谁先谁后完全由 sort 的稳定性决定——我把 1000 条同日期记录丢进去验证保序,V8 12.4(Node 22)实测通过。这类”依赖语言实现而非规范承诺”的行为,正是最该写测试锁死的地方:本仓库锚点上 49 个提交里有大量同一天发布的文章,顺序一变,章节页的阅读次序就变。
六、要不要引框架:先量本机现成的有什么
| 框架 | 本机状态(实测) | 成本 |
|---|---|---|
| GoogleTest / Catch2 | pkg-config 0 命中、Strawberry include 目录 0 命中,要用得联网拉源码 | 未实测(本轮不联网取依赖) |
| QtTest(Qt 6.9.3 mingw_64) | 头文件与 Qt6Test.prl 都在 | moc 0.83s + g++ 10.27s,产物 66200 字节 |
裸 main + 手写断言 | 一直在手边 | 编译 1.00s,-static 产物 76652 字节 |
node:test | Node 22 内置,零安装 | 全套 30 条断言跑完 434ms |
也就是说:选框架的第一个问题不该是”哪个断言宏更顺手”,而是”它的通过/失败能不能用一个返回码表达”。能,则测试立刻变成 bisect 判定、CI 门禁、发布前检查;不能(比如要人看日志),前面这些都白搭。QtTest 那份 2 条断言的用例我编译并运行了:rc=0、0.19 秒退出,但 stdout 是空的(QTEST_APPLESS_MAIN 的汇总行我没抓到,两次尝试都空,原因没继续查)——可用的信号只有退出码,而 QTest 的返回码就是失败数,这恰好是 git bisect run 那种判定脚本要的接口;同理 node --test 的退出码非 0 就能当门禁。
七、把真实数据灌进去,是另一套发现问题的办法
那 54 个 .md(写这一行时的工作区数,锚点 91028b2 处是 53 篇)全部喂进 readingMinutes:
篇数=54
readingMinutes min/中位/max=1/6/10 合计=320 分钟
其中 =1 的篇数=1
汉字数 min/中位/max=406/1586/3176 合计=93094
按"汉字/300 向上取整"直算合计=333 分钟
最长 3 篇=conc-threads-vs-processes.md=10min(3176字), qt6-framework-overview-and-project-setup.md=10min(2200字), c-basics-types-operators.md=9min(2618字)
900+ 字却算出最短的两篇=c-standard-library.md=4min(1406字), c-variadic-and-declarations.md=4min(1221字)
320 vs 333 分钟差 13 分钟,来源就是第三节那两条行为:代码块被剥离 + round 而非 ceil。c-standard-library.md 1406 个汉字只算 4 分钟,因为它是整站代码占比最高的一篇。扫描真实输入不会给你覆盖率,它给你的是分布异常:min=1 只有 1 篇(hello-github-pages.md,406 字),如果哪天有篇长文也显示 1,就知道是剥离逻辑吃了正文。
八、小结
- 本仓库实测:0 测试文件、0 测试依赖、CI 里 0 条测试步骤、0 个本地钩子,唯一门禁是”能否构建”。
- 可测试性判据只有一条:数据从参数进来就能测,从虚拟模块/全局拿就不能测。
post.ts12 个导出里 6 个是零依赖纯函数(抠出来 31 行),却整份文件因第 1 行 import 而无法被 Node 加载(实测ERR_UNSUPPORTED_ESM_URL_SCHEME … 'astro:')。 - 覆盖率与断言强度是两件事:30 条断言拿到 100% 行覆盖,只杀掉 11 个植入缺陷中的 7 个(63.6%);加 3 个”专门针对存活变异体”的测试后,分支覆盖只涨 0.47 个百分点,杀死率涨 27.3 个百分点(10/11)。一轮完整变异测试只要 8.4 秒。
- 有等价变异体:M4(删掉围栏剥离)两轮全绿、4 组构造输入都区分不出来,因为剩下的行内剥离顺手把围栏内容一起吃掉了。变异分数是找盲点的工具,不是 KPI。
- 依赖注入的成本是”函数签名加一个参数”,实测换来 5 条断言全绿(385ms);我自己把期望顺序写错了,是
node:test打的 actual/expected diff 纠正的——测试第一遍红的时候,先怀疑测试。 - 未实测的点:GoogleTest/Catch2 的引入成本(本机没有、本轮不联网)、CI 里加
node --test后的实际门禁耗时、QtTest 的汇总输出为何为空、以及”300 汉字/分钟”这个基数本身是否合理。