你的测试全过了。你的代码还是错的。
你有 100% 的行覆盖率。每个分支都执行到了。每个函数都被调用了。然后有人在定价逻辑里把一个 + 改成了 -,跑了一遍测试,全部通过。
这不是理论问题。它真实发生在你的测试执行了代码,却没有真正验证行为的时候。覆盖率衡量的是哪些行被执行了,而不是哪些输出被检查了。变异测试通过故意引入小的 bug,然后验证你的测试能否发现它们,来填补这个缺口。
对 Rust 团队来说,问题不是变异测试是不是个好主意。而是 cargo-mutants——这个生态中的主流工具——在 Rust 的编译时间和类型系统面前是否实用。答案是肯定的,但有一些需要留意的限制。
变异测试到底在做什么
变异测试的概念很简单。工具对你的源代码做一个极小的改动,运行你的测试套件,然后检查是否有测试失败。
如果测试套件失败了,这个变异体就被”杀死”了。这正是你想要的结果。它说明你的测试注意到了这个 bug。
如果测试套件通过了,这个变异体就”存活”了。这意味着你的测试执行了被变异的代码,却没有发现任何异常。你有一个弱测试。
常见的变异包括替换算术运算符(+ 变成 -)、交换比较运算符(> 变成 >=)、替换布尔字面量(true 变成 false),以及删除返回值的函数调用。每个改动都小到人类一眼就能认出它是 bug。测试套件也应该能认出来。
cargo-mutants 如何在 Rust 代码上工作
cargo-mutants 是一个专门为 Rust 构建的变异测试工具。它不需要你注解测试或修改构建系统。安装完直接运行。
cargo install cargo-mutants
cargo mutants
该工具扫描你的源文件,通过对 AST 应用转换规则来生成变异体,然后为每个变异体运行 cargo test。它追踪哪些变异体存活下来,并输出一份报告。
下面是一个看起来靠谱、实际上并不靠谱的函数及其测试:
pub fn apply_discount(price: f64, rate: f64) -> f64 {
price * (1.0 - rate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_discount() {
let result = apply_discount(100.0, 0.2);
// 我们调用了函数。覆盖率是 100%。
// 但我们从未断言结果。
}
}
cargo mutants 会生成一个变异体,把 * 改成 /,或者把 1.0 - rate 替换成 1.0 + rate。测试仍然会通过,因为它从未检查 result。存活的变异体标记出了这个问题。
一个能杀死变异体的真正测试长这样:
#[test]
fn test_apply_discount() {
assert_eq!(apply_discount(100.0, 0.2), 80.0);
assert_eq!(apply_discount(50.0, 0.0), 50.0);
}
现在每一个算术变异体都会失败,因为断言能捕获错误的输出。
输出长什么样
运行 cargo mutants,你会得到一个摘要:
Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants
Missed mutants 就是存活的变异体。cargo mutants 会把每一个写入 mutants.out/,附带 diff 和文件路径。你读取 diff,然后补上缺失的断言。
Timeout 发生在变异体导致无限循环时。cargo-mutants 会检测这种情况,并将其标记为被超时杀死,这算作成功。
Unviable mutants 是无法编译的改动。Rust 的类型系统在测试运行之前就拒绝了它们。
Rust 的类型系统是一把双刃剑
在 JavaScript 或 Python 中,变异测试工具可以替换几乎任何运算符,代码照样能跑。只是结果错了。在 Rust 中,很多变异在测试运行之前就被编译器拦截了。
把 + 换成 - 在无符号整数上可能会导致溢出,但代码能编译。在泛型上下文中把 > 换成 <,如果 trait bounds 不支持该比较,编译器可能会拒绝。删除一个返回调用方预期值的函数调用,编译器会直接报错。
这意味着 cargo-mutants 生成的有效变异体比其他语言的同类工具少。一个 Python 项目的一个模块可能有 200 个变异体。一个 Rust 项目可能只有 40 个。能编译的那些变异体,才是真正可能溜进生产环境的。类型系统过滤掉了噪音。
代价是编译时间。每一个有效变异体都会触发一次重新构建。一个测试套件跑五分钟的项目,运行 cargo mutants 可能要花一个小时。
编译时间税是真实的
这是团队犹豫的主要原因。变异测试理论上非常易于并行化。每个变异体都是独立的。但实际上,Rust 的构建系统并不能干净地对同一个源码树并行发起几十个编译调用。
cargo-mutants 有 --jobs 参数,但磁盘 I/O 和 crate graph 的锁会成为瓶颈。在典型的双核 CI runner 上,扩展性很差。
你可以缓解这个问题。使用 --in-place 避免为每个变异体复制源码树。使用 --file 或 --exclude 来针对特定模块。把变异测试安排在夜间或每周运行,而不是每次推送都跑。
cargo-mutants 会漏掉什么
没有变异测试工具能抓住所有问题。cargo-mutants 有一些特定的限制你应该了解。
它不会变异宏展开。如果你的关键逻辑藏在宏里面,工具看到的是调用,而不是生成的代码。
它不理解语义等价。有些变异体产生的行为不同,但对所有有效输入来说仍然是正确的。一个多余的 + 0 可能会存活,因为测试不在乎,尽管这个变异并不是真正的 bug。你需要手动甄别这些。
什么时候变异测试值得这个成本
你不需要在每次提交时都运行 cargo mutants。你需要它的时候,是你的测试套件已经大到让你不再相信自己的断言了。
在以下情况运行它:某个关键模块覆盖率高,但你还是往里面放过 bug;或者一次重构以微妙的方式改变了逻辑,你想确认断言是否足够严密。
不要在这些情况运行它:你的测试套件本身就不稳定,或者编译时间已经是所有人抱怨的瓶颈。先修基本功。
把它加入 CI 而不搞崩流水线
实际的设置是一个定时任务,而不是每个 Pull Request 的门槛。
下面是一个每周运行的 GitHub Actions 工作流:
name: Mutation Testing
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-mutants
run: cargo install cargo-mutants
- name: Run mutation testing
run: cargo mutants --in-place
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-report
path: mutants.out/
--in-place 参数让磁盘占用保持在合理范围。rust-cache 缩短初始构建时间。定时触发避免阻塞开发者。把报告作为 artifact 上传,这样你就可以 review 存活的变异体,而不用在 CI 日志里翻页。
从一个模块开始
你不需要变异整个代码库。挑一个包含核心业务逻辑、且有 bug 历史的模块。运行 cargo mutants --file src/pricing.rs。阅读报告。修复最弱的测试。
第一次运行总是最糟糕的。你会发现有些测试执行了代码却什么都不断言。你会发现有些分支被覆盖了,但测试并不检查分支的结果。你会奇怪这些测试当初是怎么让人觉得够用的。
这就是重点。变异测试不是在代码里找 bug。它是在测试里找 bug。在 Rust 中,编译器已经帮你抓住了那些明显的错误,这正是你需要的反馈闭环。
常见问题
什么是变异测试?
变异测试通过向源代码引入小的、故意的 bug 来评估你的测试套件。如果你的测试失败了,变异体就被”杀死”。如果你的测试通过了,变异体就”存活”了,你就有一个缺口。
变异测试与代码覆盖率有什么区别?
覆盖率衡量哪些行被执行了。变异测试衡量你的测试能否检测到这些行产生的错误输出。一个测试可以有 100% 覆盖率,但一个变异体都抓不到。
变异测试对所有 Rust 项目都很慢吗?
成本随编译时间和测试数量而扩展。小型库可能几分钟就跑完。大型 workspace 项目则要长得多。使用 --file 和 --exclude 把运行范围限定到特定模块。
我可以忽略假阳性的变异体吗?
可以。cargo-mutants 支持 mutants.toml 配置文件来排除文件、函数或特定变异类型。谨慎使用,以免掩盖真正的测试缺口。