A quick description of mutation testing and examples using PIT
Mutation testing can be used to evaluate the effectiveness of unit tests.
The idea is to mutate the source code by introducing faults and to check whether the existing unit tests are capable of detecting the faults (by failing).
Mutation testing frameworks generally work as follows:
- Your project is built and the unit tests are run to ensure your project is currently stable.
- It will then automatically apply a mutation operator (eg: remove a line of code, replace an addition with a subtraction, invert a boolean condition) on a single method of your code and re-run all unit tests to check if at least one of the test cases fails.
- If some tests fail, this means they were able to reveal the broken code.
- If no tests fail, this is a sign that there are gaps in your tests.
- It will repeat the mutation/testing process multiple times for different types of mutation operators.
Isn't code coverage/branch coverage enough? No.
To demonstrate, do the following:
- Run
mvn clean site
on the project. - Open the code coverage report (
target/site/jacoco/index.html
). All branch coverage is 100%. - Open the
SimpleNumberIsPositiveTest.java
unit test. You will see that it only has tests for positive and negative numbers, but there is no test for 0 (that test is currently commented out). - This means that if the condition in
SimpleNumber.isPositive()
method is accidentially changed from>=
to>
, there is no test that would catch that.
Traditional test coverage (i.e line, statement, branch etc) measures only which code is executed by your tests. It does not check that your tests are actually able to detect faults in the executed code. It is therefore only able to identify code that is definitely not tested.
This project contains a few examples of unit tests that have 100% branch coverage. However, running mutation testing on them will show that there are gaps in the tests. Each unit test has commented out sections that are the "missing" tests to make the mutation tests pass.
Run testBoundaryMutations.sh
. This script will run the mutation tests and automatically opens the PIT report (located at target/pit-reports/index.html
)
See that there is a mutation that lived (i.e. no test failed after the code was mutated). The mutation that lived is that the condition in SimpleNumber.isPositive()
method was changed from >=
to >
, but no unit test failed. This means we are missing a test case for the 0
bondary condition.
- Uncomment the
testBoundary
test method inSimpleNumberIsPositiveTest.java
- Run
testBoundaryMutations.sh
This time, you will see that all mutations were killed, as thetestBoundary
test failed when theisPositive
method was mutated.
Run testReturnValuesMutations.sh
. This script will run the mutation tests and automatically opens the PIT report (located at target/pit-reports/index.html
)
See that there is a mutation that lived (i.e. no test failed after the code was mutated). The mutation that lived is that the SimpleNumber.increment()
method was changed to always return null
, but no unit test failed. This means our unit tests are not checking the return value of the increment
method.
- Uncomment the
assertEquals
lines inSimpleNumberIncrememterTest.java
- Run
testReturnValuesMutations.sh
This time, you will see that all mutations were killed, asSimpleNumberIncrememterTest
now checks the return value of theincrement
method.
No, not yet. While mutation testing is very useful and would be beneficial as part of a CI build, it can have some drawbacks.
Mutation testing is a computationally expensive process and can take quite some time depending on the size of your codebase and the quality and speed of your test suite. PIT (the mutation testing framework used in this project) is fast compared to other mutation testing systems, and also has withHistory
and scmMutationCoverage
goals which can run the mutation tests on new code only, improving execution times.
There is also the (rare) possibility false positives. It is possible that certain mutations don't actually change the behaviour of the code, and the mutation testing framework incorrectly expects that a unit test should fail. This is called an equivalent mutation.
To see an example of an equivalent mutation, run testMathMutations.sh
. This script will run the mutation tests and automatically opens the PIT report (located at target/pit-reports/index.html
)
See that there is a mutation that lived (i.e. no test failed after the code was mutated). The mutation that lived is that the SimpleNumber.multiplyIfOnes()
method was changed to do division instead of multiplication. That is
return new SimpleNumber(value * otherNumber.value);
was changed to:
return new SimpleNumber(value / otherNumber.value);
No unit test failed, and this is considered an error.
The problem here is that 1 * 1
is the same value as 1 / 1
, so the mutation is equivalent to the original code. This means there is no chance that a unit test will fail, because the mutated code works in exactly the same way as the original code. The mutation testing framework is incorrectly expecting a unit test failure in this case.
Add the pitest-maven
plugin to your pom.xml
file:
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.4.3</version>
</plugin>
Now run the plugin on your project:
mvn -DtimestampedReports=false org.pitest:pitest-maven:mutationCoverage
After the mutation test has completed, a report will be available at target/pit-reports/index.html