1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# Mutation testing
`cargo mutants` modifies your source code (replacing operators,
deleting calls, changing return values) and checks that at least one
test fails for each mutation. Surviving mutants indicate code that
tests execute but don't actually verify.
Run mutation testing separately from coverage — it's slow:
```bash
just mutants
```
## Workflow
1. Run `just mutants` after reaching 100% coverage.
2. For each surviving mutant, tighten an existing assertion (or
add one) so the mutated behavior fails the test (see worked
example below).
3. If a mutant is equivalent (same observable behavior) or
unreachable (dead code behind `unreachable!`), add it to
`.cargo/mutants.toml` with an explanation.
4. CI enforces zero missed mutants — a surviving mutant fails the
build.
## Worked example: weak assertions hide bugs
A function with full line coverage and a passing test:
```rust
// src/order.rs
pub fn total(items: &[Item]) -> u32 {
items.iter().map(|i| i.price).sum()
}
```
```rust
#[test]
fn computes_total() {
let items = vec![Item::new(10), Item::new(20)];
assert!(total(&items) > 0);
}
```
`just mutants` reports:
```
src/order.rs:3:5: replace total -> u32 with 1 ... NOT CAUGHT
```
`cargo-mutants` replaced the function body with `1`. The test still
passes — `1 > 0` is true. Coverage saw the line execute; the
assertion didn't notice the body was gutted.
The mutant points at a weak assertion. Tighten it from a property
("greater than zero") to a value ("exactly thirty"):
```rust
#[test]
fn computes_total() {
let items = vec![Item::new(10), Item::new(20)];
assert_eq!(total(&items), 30);
}
```
Re-run `just mutants`. The `with 1` mutant now fails (`assert_eq!(1,
30)` panics) and is caught. Other body-replacement mutants (`with
0`, `with u32::MAX`) are caught by the same assertion.
The lesson is general: every `assert!` predicate is a hint that the
test could be tighter. Replacing `>` with `==` (or `assert!` with
`assert_eq!`) usually buys mutation coverage for free.
## Where surviving mutants tend to live
- Functions whose tests assert on a property (`> 0`, `is_some()`,
`len() > 1`) instead of a specific value.
- Side-effect functions called for their effect but not verified
(`logger.record("x")` called, `logger.entries` not inspected).
- Boundary conditions where the only tested inputs sit far from the
boundary, so `<` vs `<=` produce the same result.
- Error-construction code where the variant is matched but the
payload isn't.
## Excluding equivalent mutants
Some mutations produce equivalent programs and can never be caught.
For example, changing `> 1` to `>= 1` when an earlier branch already
handled `== 1` — both versions behave identically.
Document each exclusion in `.cargo/mutants.toml` with a regex
pattern and a comment explaining why it's equivalent or unreachable:
```toml
# Equivalent mutant: the `== 1` branch above already handles len 1,
# so `> 1` and `>= 1` are equivalent in the else-if position.
exclude_re = [
"my_file\\.rs:\\d+:\\d+: replace > with >= in some_function",
]
```
Every exclusion needs a comment. Undocumented exclusions hide real
gaps.
## Tree copying
cargo-mutants builds each mutant in a scratch copy of the tree, and
since 25.1.0 that copy ignores every gitignore file by default —
untracked and ignored files come along too. A path that isn't a
regular file (a sandbox bind-mounting `/dev/null` over `.envrc`, say)
aborts the run:
```
Error: Failed to copy .../.envrc to /tmp/cargo-mutants-.../.envrc
the source path is neither a regular file nor a symlink to a regular file
```
Set `gitignore = true` in `.cargo/mutants.toml` so the copy skips
ignored files, including matches from the global git ignore. Filtering
only applies in a tree with a `.git` directory, so a jj repo must be
colocated. `--in-place` skips the copy entirely but can't be combined
with `-j`.