Automated tests give us confidence. But how do we know our tests are actually good at catching real bugs? High code coverage looks nice on a report, but 100% coverage doesn’t mean your tests are effective.
This is where mutation testing comes in.
Instead of checking which lines are executed, mutation testing introduces small changes (mutants) to your code. Then, it checks whether your existing tests fail. If a test passes even after a logical change, that means your test didn’t catch a potential bug.
Let’s look at a real example using a simple Java coffee machine project. Coffee Machine Simulator
We have a CoffeeMachine class and a set of tests built with JUnit 5. One key part of the machine is a Reservoir, used to store water, beans, and milk. The reservoir ensures you can't brew more coffee than you have resources for.
Here’s one of the classes:
public abstract class AbstractReservoir implements Reservoir {
protected int level;
protected final int capacity;
public AbstractReservoir(int capacity) {
this.capacity = capacity;
this.level = capacity;
}
@Override
public void use(int amount) {
if (amount > level) {
throw new IllegalArgumentException("Not enough resources!");
}
level -= amount;
}
@Override
public void refill(int amount) {
level = Math.min(level + amount, capacity);
}
@Override
public int getLevel() {
return level;
}
}
We’ve got tests for use()
, refill()
, and different brewing scenarios like lattes, espressos, etc.
To run mutation testing, we use PIT (Pitest). After setting it up in our Maven pom.xml, we run:
mvn test-compile org.pitest:pitest-maven:mutationCoverage
It generates an HTML report showing which mutations were detected by tests and which survived.
Here’s one of the mutations PIT applied to the use()
method:
Original:
if (amount > level) {
throw new IllegalArgumentException("Not enough resources!");
}
Mutant:
if (amount >= level) {
throw new IllegalArgumentException("Not enough resources!");
}
This changes the logic subtly. With the mutant, trying to use exactly the remaining amount would now fail. And our tests didn’t catch it.
PIT marked this mutant as SURVIVED, meaning no test failed because of this change.
That’s a gap in the tests.
To catch this mutation, we add a test where the amount used is exactly equal to the current level:
@Test
public void testReservoir_UseExactAvailableAmount() {
BeanReservoir reservoir = new BeanReservoir(50);
reservoir.use(50); // Should succeed
assertEquals(0, reservoir.getLevel());
}
This test will fail if the mutant (>=) is applied, which means the mutant will be killed during the next mutation test run.
After rerunning PIT:
mvn org.pitest:pitest-maven:mutationCoverage
✅ The mutation is caught. Test effectiveness just went up.
Code coverage tells you what code is executed, not whether it’s actually tested well. Mutation testing fills that gap by actively trying to break your tests.
In our case, the tests looked complete, but mutation testing found a real logic edge case that was missed.
If you're already testing with JUnit and Maven, adding PIT is simple and powerful. And it’s a great way to ensure your tests are pulling their weight.
Delen: