Equivalent mutants
A mutant is equivalent when no input can tell it apart from the original: the code changed, but what it does didn’t. No test can catch it, because there’s nothing to catch.
int fee = amount < 10 ? 10 : amount; // the originalint fee = amount <= 10 ? 10 : amount; // a mutant: at amount == 10 both give 10That matters more than it looks. Counted as missed, an equivalent mutant asks for a test that can’t exist, lowers the score for nothing, and sends people, and coding agents, looking for a gap that isn’t there. So mutastic skips the ones it can prove, and gives you a way to record the ones it can’t.
Not the same as “doesn’t matter”
Section titled “Not the same as “doesn’t matter””Three reasons not to write a test sound alike, and aren’t:
| The question it answers | An example | Who decides | |
|---|---|---|---|
| Equivalent | Could any input tell the change apart? No. | a < b ? a : b becoming a <= b ? a : b: at a == b both give the same | mutastic, when the bytecode proves it; you, otherwise |
| Arid | Could a test tell? Yes, but none should have to. | Removing a log line, or changing a new ArrayList<>(n * 2) capacity | mutastic’s arid filters |
| Unreachable | Could an input tell? Yes, but that input never happens. | A guard for a negative balance the ledger already rules out | You: mutastic ignore --as=unreachable |
An equivalent mutant is a fact about the code. The other two are judgements, which is why mutastic
makes only the first kind on its own, and only on proof. Each skipped mutant is counted by its kind
in the summary (3 skipped (2 equivalent, 1 arid)), and none of them counts in the score.
What mutastic skips on its own
Section titled “What mutastic skips on its own”Each of these is matched in the bytecode, around the mutant’s own instruction, and the reason is
said with the mutant (equivalent: …). mutastic explain ID shows it.
It already returns the default. Changing return 0; to “return the default” changes nothing.
if (items.isEmpty()) return 0; // "returns 0 instead": it already doesBoth sides give the same value at the edge. A boundary mutant only changes what happens when the two sides are equal, and here both branches then give the same value:
if (x > max) max = x; // at x == max, storing x leaves max as it wasreturn a < b ? a : b; // at a == b, either is the samereturn fee < 10 ? 10 : fee; // at fee == 10, both are 10if (x < 2) x = 2; // at x == 2, x is already 2This covers int and long values against each other or a constant. Not float or double:
-0.0 == 0.0, but they’re different values, and 1.0 / -0.0 shows it.
The next test of an || already covers its case. Forcing the first test false changes nothing
when everything it lets through, the second does too:
static boolean inCredit(int balance) { return balance >= 1; }
if (inCredit(balance) || balance > -1000) { … } // every balance ≥ 1 is also > -1000mutastic reads through a static method of the same class whose whole body is one comparison, like
inCredit here. A boundary of the first test is skipped too, when its new edge is still covered.
A stream step the next step doesn’t care about. Removing .sorted() before a result that
ignores order (count(), anyMatch(…), min(…), sum()), or .distinct() before one that
ignores duplicates.
names.stream().sorted().anyMatch(String::isBlank) // order can't change whether any is blankHow mutastic stays honest about it
Section titled “How mutastic stays honest about it”A wrong “equivalent” is worse than a missed mutant: it hides a gap a test could have caught, and says it can’t. So:
- Only proof, never likelihood. A shape is skipped when the bytecode shows it exactly: the same
local, the same constant, the arms meeting. A near miss runs as usual:
a > 0 || a > 2isn’t skipped, since1gets past the first and not the second. - Every rule is checked by running it. mutastic’s own tests compile a fixture of these shapes and their near misses, run every mutant skipped as equivalent against the original on every input, and fail if one behaves differently.
- It can’t be overridden by accident.
@DoMutateandmutate:rules bring back arid code, never equivalent mutants, since running them can’t tell you anything.
When mutastic can’t tell
Section titled “When mutastic can’t tell”In general, whether two programs always behave the same can’t be decided, so most equivalent mutants aren’t the shapes above. When you can see that one is, record it:
mutastic ignore m1a2b3c4d --as=equivalent --reason="count is items.size(), and process() handles an empty list"It goes in .mutastic/ignores.yaml, with the line it’s on and the reason, to commit and review. It
holds until that line changes, then the mutant runs again. The summary counts it apart (1 ignored as equivalent), so what ignores hide can be reviewed by kind.
Be sure before you do. If you can write an input where the two give different results, it isn’t
equivalent, it’s a missing test, and that input is the test. If the input exists but can’t happen,
it’s unreachable, which is a different decision, so say that
instead with --as=unreachable.
Coding agents
Section titled “Coding agents”The agent format tells a coding agent the same: when a mutant’s case can’t
happen, or no caller can see the change, don’t contrive a test; say why, and ignore it with that
reason. An agent’s ignore is a proposal: review it like any other entry in ignores.yaml, with its
kind and reason in front of you.