Mutation testing validates that tests actually catch bugs by introducing controlled mutations (artificial bugs) into the code and verifying that tests fail. This provides a much stronger quality signal than line coverage alone.
- High line coverage doesn't guarantee quality tests: You can have 100% line coverage with tests that never assert anything
- Mutation testing verifies test effectiveness: If a test suite doesn't catch a mutation (bug), there's a gap in testing
- Mainnet stakes require confidence: For production deployments, we need assurance that tests catch real bugs
- Minimum Kill Rate: 75%
- Target Kill Rate: 80%+
- Enforcement: On release tags and release branches (blocking)
- Informational: On regular PRs (non-blocking, reported in comments)
We use cargo-mutants, a mutation testing tool specifically designed for Rust.
cd tooling/sanctifier-core
cargo mutants --no-shuffle --timeout 60cd tooling/sanctifier-core
cargo mutants --no-shuffle --timeout 600cargo mutants --file src/parser/lexer.rscargo mutants --no-shuffle --output mutants.out
# View mutants.out/mutants-report.html in browserMutation testing introduces changes to your code and runs your test suite. Each mutant falls into one of these categories:
The test suite detected the mutation (test failed). This is good!
Example:
// Original
fn is_valid(x: u32) -> bool {
x > 0
}
// Mutant: Changed > to >=
fn is_valid(x: u32) -> bool {
x >= 0 // Bug introduced
}
// If test suite has:
assert!(!is_valid(0)); // This test will catch the mutantThe mutation was not detected (all tests still passed). This indicates a test gap.
Example:
// Original
fn calculate_fee(amount: u64) -> u64 {
amount / 100
}
// Mutant: Changed / to *
fn calculate_fee(amount: u64) -> u64 {
amount * 100 // Bug introduced
}
// If no test verifies the fee calculation result, mutant survivesTest suite took too long to complete (exceeded timeout). May indicate:
- Performance issue introduced by mutation
- Infinite loop
- Test suite is generally slow
Mutation caused compile error. This is expected and not counted against kill rate.
#[test]
fn test_boundary_values() {
assert!(validate_size(0)); // Minimum
assert!(validate_size(MAX)); // Maximum
assert!(!validate_size(MAX + 1)); // Just over
}#[test]
fn test_invalid_input_returns_error() {
let result = parse_source(&[0xFF, 0xFE]);
assert!(matches!(result, Err(ParseError::InvalidUtf8)));
}#[test]
fn test_calculation_correctness() {
let fee = calculate_fee(1000);
assert_eq!(fee, 10); // Don't just call it, verify the result!
}#[test]
fn test_should_reject_invalid_state() {
let result = transition_to_invalid_state();
assert!(result.is_err());
}Mutation testing runs on schedule (weekly) and is informational only. Results are posted as PR comments if run manually.
For release tags (v*) and release branches (release/**), mutation testing is blocking:
- Kill rate < 75%: CI fails ❌
- Kill rate ≥ 75%: CI passes ✅
See .github/workflows/mutation-testing.yml for configuration.
Mutation: > changed to >=, && changed to ||
Solution: Test boundary conditions explicitly
Mutation: + changed to -, * changed to /
Solution: Assert on specific calculation results
Mutation: Return value changed from Some(x) to None
Solution: Test both success and failure paths
Mutation: Constant value changed (e.g., 100 to 101)
Solution: Use known inputs with expected outputs
- cargo-mutants documentation
- Mutation Testing in Rust blog post
- Academic background on mutation testing
Use --file to focus on specific files, or --timeout to reduce wait time.
Your test suite may be slow. Consider:
- Optimizing slow tests
- Increasing
--timeoutvalue - Using
--jobsfor parallelization
Review surviving mutants individually - some may be in:
- Logging code
- Debug assertions
- Dead code paths
- Code that should be refactored
Last Updated: 2026-07-27