Skip to content
mutastic

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 original
int fee = amount <= 10 ? 10 : amount; // a mutant: at amount == 10 both give 10

That 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.

Three reasons not to write a test sound alike, and aren’t:

The question it answersAn exampleWho decides
EquivalentCould any input tell the change apart? No.a < b ? a : b becoming a <= b ? a : b: at a == b both give the samemutastic, when the bytecode proves it; you, otherwise
AridCould a test tell? Yes, but none should have to.Removing a log line, or changing a new ArrayList<>(n * 2) capacitymutastic’s arid filters
UnreachableCould an input tell? Yes, but that input never happens.A guard for a negative balance the ledger already rules outYou: 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.

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 does

Both 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 was
return a < b ? a : b; // at a == b, either is the same
return fee < 10 ? 10 : fee; // at fee == 10, both are 10
if (x < 2) x = 2; // at x == 2, x is already 2

This 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 > -1000

mutastic 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 blank

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 > 2 isn’t skipped, since 1 gets 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. @DoMutate and mutate: rules bring back arid code, never equivalent mutants, since running them can’t tell you anything.

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:

Terminal window
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.

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.