Mutation 测试:给测试做“测试“

测试圈有个老笑话:一套从不失败的测试,最大的可能不是代码完美,而是测试什么都没断言。B16 讲了怎么把测试跑对,B17 讲了门禁怎么守结构;这篇讲验证它们本身有效性的元手段——变异测试(mutation testing)。
1. 核心思想:与其相信绿灯,不如制造红灯
变异测试的流程一句话:对源码做一个小的、真实的破坏(变异体),然后跑验证手段,它必须失败。失败 = 变异体被"杀掉"(验证有效);居然还是绿的 = 变异体"存活"(验证有盲区,这个破坏没人管)。
业界有成熟的 mutation 框架(自动算子、覆盖率报告),我们没用——鸿蒙 ArkTS 生态里没有现成的,而且我们的需求更聚焦:不求全量变异覆盖率,只求每个关键不变式至少有一个变异体能证明有人守着它。于是手写,工具就是 sed + 备份恢复。
2. 我们的实现:朴素的 sed 变异 + 严格的恢复纪律
以词库算法的 mutation 脚本为例(test-p2-lexicon-algorithm.sh),结构非常直白:
# 备份
cp "$ALGO" "$TMP_DIR/algo.orig"
restore_all() { cp "$TMP_DIR/algo.orig" "$ALGO"; … }
trap cleanup EXIT # 无论脚本怎么死,工作区必须恢复
# 变异 1:maxLen 6 → 5
sed -i.bak 's/const MAX_LEN: number = 6;/const MAX_LEN: number = 5;/' "$ALGO"
run_check_expect_fail "maxLen_6_to_5" # 门禁不变绿 = ✓;竟然还绿 = ✗ 计数
# 变异 2:字典构建去掉 emotion keys
# 变异 3:……
三个设计细节:
变异的是真实源码,不是夹具。B17 的夹具树验证"门禁逻辑写得对",这里的变异验证"门禁对真实代码的这条规则真的连上了"。两个层次都不可少——剧本对了不代表实战演出了。
恢复纪律用 trap 兜底。脚本改的是工作区真实文件,中途 Ctrl-C、断言失败、机器断电都可能发生。trap cleanup EXIT 保证任何退出路径都恢复原文件;每个变异跑完也立刻 restore_all 再上下一个——变异体之间绝不叠加(变异 2 必须在干净代码上做,否则你测的是组合变异,归因不了)。
判定方向是反的,输出也要反直觉地写:门禁应失败但通过了 是红色 ✗,门禁正确失败 是绿色 ✓。第一次看到会愣一下,但这正是 mutation 的思维转换:验证手段的失败才是成功。
3. 在 Hvigor + Hypium 上跑变异,三个鸿蒙特有的坑
变异逻辑本身与平台无关,但执行层踩在鸿蒙工具链上,有三个坑不绕开会得出完全错误的结论:
坑一:Hvigor 的退出码不可信,判定必须读 test_result.txt。 这是鸿蒙测试最容易踩的雷:Hypium 断言失败时,Hvigor 进程仍可能返回 0。如果 mutation 脚本按"退出码非零 = 变异被杀"判定,那么一个测试根本没拦住变异体的情况会被记成"杀掉"——变异测试反而成了盲区制造机。我们的约定(也是全项目测试纪律)是:每个变异体跑完必须解析
entry/.test/default/intermediates/test/coverage_data/test_result.txt
里的 fail 计数来判定。退出码只说明"工具链没崩",不说明"断言红了"。
坑二:ArkTS 的编译器是你的第一层变异杀手,但要分清"编译杀"和"测试杀"。 ArkTS 强类型 + 静态检查比 JS 严格得多:有些变异体(比如把函数返回值类型改坏、删掉一个必填字段)根本活不到测试执行,编译期就死了。这不是坏事——说明类型系统在替你守这条契约——但归因必须分开记录:compile-kill 证明的是编译器守着它,test-kill 证明的是测试守着它。把编译杀记成测试杀,会高估测试套件的有效性;反过来如果一条你"以为测试在守"的契约其实只有编译器在守,重构到动态边界时就会裸奔。sed 做变异时也要意识到这个偏差: sed 改的是文本,ArkTS 的静态约束意味着很多"文本上成立"的变异体天然偏向编译杀,设计变异体时要刻意选那些类型层面合法、行为层面错误的破坏(改常量、改比较方向、漏一组数据),才测得到测试本身。
坑三:增量缓存会让变异"假存活"或"假被杀"。 Hvigor 有增量构建缓存,而 sed 改完源码立刻跑 test,如果缓存判定逻辑没吃上这次变更(某些中间产物路径下会发生),你跑的可能是变异前的编译产物——变异体"假存活",你会误以为测试有盲区,白追半天。反过来,上一次的失败产物残留也可能造成"假被杀"。所以变异脚本里跑测试前要确保中间产物是新鲜的(我们的做法与 B19 的干净重建原则同源:不相信时间戳,关键轮次先清 build/ 与 .test/ 中间目录)。在鸿蒙工具链上,"我改的代码真的被编译进去了吗"是一个需要正面回答的问题,而不是默认假设。
顺带一个现实约束:鸿蒙工具链一轮 Hypium 的耗时明显高于 JVM/Node 生态的单元测试,每个变异体都是真金白银的分钟数。这反过来强化了下一节的原则——变异体必须精选,不是撒网。
4. 变异体怎么选:对着不变式开清单
变异体不是随机破坏,每个都瞄准一条冻结的不变式。词库算法的 15 个变异体,对着 B08 讲的每一条契约:maxLen 改动、字典漏组、匹配下限 2 改 1、未命中步长改动、overlay 泄漏进统计……每个变异体都能回答"如果谁把这条契约改坏了,谁会报警?"
各阶段的 mutation 数量也随关单冻结:P2 算法 15 个、P3 呈现 8 个、P5 各任务 18~22 个、阶段关单 20+ 个——任务单里写明"N 个变异全部被杀"是验收项。这个指标的价值不在数字大小,在于它把"测试有效性"从感觉变成了计数。
5. 什么时候值得做,做到什么程度
务实地说,mutation 测试不便宜(每个变异体都要设计、实现、维护,在鸿蒙工具链上还要付出成倍的执行耗时),我们的投入原则:
-
冻结契约必做:算法行为、安全边界(凭据/权限)、Prompt 契约、版本元数据——改坏了后果严重且有"正确答案"的;
-
纯 UI 布局不做:样式细节没有客观对错,变异了也不知道该谁报警;
-
一次设计,长期回归:脚本进仓库,随阶段验收重跑;新冻结一条契约,就补一个对应变异体(B17 说的"门禁 TDD"在这里同样适用)。
6. 小结
-
全绿只证明现状自洽,不证明能抓坏;变异测试用"故意改坏必须变红"验证验证手段本身。
-
手写实现足够务实:sed 变异真实源码、trap 保证恢复、变异体间隔离、判定方向反转。
-
鸿蒙工具链三个坑:退出码不可信只认
test_result.txt、编译杀与测试杀分开归因、增量缓存导致假存活/假被杀。 -
变异体对着冻结不变式开清单;"N 个变异全部被杀"写进验收。
-
投入聚焦冻结契约,纯 UI 不做;新契约必补新变异。
更多推荐




所有评论(0)