跳到正文

单元测试:覆盖率考的是输入,变异考的是断言

环境: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.ts 95 行 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 的 devDependenciesNone(只有 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)
getPostsgetCollection(虚拟模块)❌ 必须注入数据源
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 / Catch2pkg-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:testNode 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.ts 12 个导出里 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 汉字/分钟”这个基数本身是否合理。