Your tests pass. But are they actually checking anything? Mutation testing answers that question by systematically modifying your code and checking whether your tests catch each change.
A mutation testing tool:
+ with -, swapping return values, etc.)Surviving mutants reveal weak spots in your test suite — code paths where your tests don't actually verify behavior.
cargo install cargo-mutants
Run against your crate:
cargo mutants
This generates a mutants.out/ directory with results:
| File | Contents |
|---|---|
caught.txt | Mutants killed by tests ✅ |
missed.txt | Mutants that survived ❌ |
timeout.txt | Mutants that caused test timeouts |
unviable.txt | Mutants that didn't compile |
Some mutants create infinite loops. Set a per-test timeout:
cargo mutants --timeout 30
The missed.txt file is where the action is:
src/frequency.rs:163:5: replace mhz_to_thz -> f64 with 1.0
src/frequency.rs:223:5: replace khz_to_thz -> f64 with 1.0
src/frequency.rs:283:5: replace hz_to_thz -> f64 with 1.0
This tells you: if someone replaced mhz_to_thz with a function that always returns 1.0, your tests would still pass. That's a real gap.
A common pattern that mutation testing catches:
#[test]
fn frequency_conversions() {
let thz = 1.0;
let mhz = 1_000_000.0;
assert_eq!(mhz_to_thz(mhz), thz); // thz is 1.0!
}
The mutant replace mhz_to_thz -> f64 with 1.0 survives because the expected output is 1.0. Fix: add a non-unity test point:
#[test]
fn frequency_conversions_non_unity() {
let thz = 2.5;
let mhz = 2_500_000.0;
assert_eq!(mhz_to_thz(mhz), thz); // Now 1.0 would fail
}
Test a specific file:
cargo mutants -f src/frequency.rs
Exclude files (e.g., skip main.rs which often has trivial mutants):
cargo mutants -e src/main.rs
Add to your CI pipeline for periodic checks (mutation testing is slow, so run nightly rather than on every push):
# GitHub Actions example
- name: Mutation testing
run: |
cargo install cargo-mutants
cargo mutants --timeout 60
if [ -s mutants.out/missed.txt ]; then
echo "::warning::Survived mutants found"
cat mutants.out/missed.txt
fi
Always ignore the output directory:
mutants.out/
-f to keep iteration fastcaught.txt growing is good but missed.txt shrinking is the goalreplace main with () is expected and harmlessRunning cargo mutants on rfconversions (a unit conversion library with 70 unit tests and 53 doc tests):
The 3 missed mutants were fixed by adding a single test with non-unity values. That's the power of mutation testing: one run, one test, measurably better coverage.