PIT Mutation Testing for Java: Setup, Configuration, and Workflow
PIT (Pitest) is the standard mutation testing tool for Java. It integrates with Maven and Gradle, runs mutations on your bytecode (faster than source-level mutation), and generates HTML reports showing exactly which mutations survived and where.
Maven Setup
Add the PIT plugin to pom.xml:
<build>
<plugins>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
<configuration>
<targetClasses>
<param>com.example.service.*</param>
<param>com.example.domain.*</param>
</targetClasses>
<targetTests>
<param>com.example.*Test</param>
<param>com.example.*Tests</param>
</targetTests>
<mutators>
<mutator>DEFAULTS</mutator>
</mutators>
<outputFormats>
<outputFormat>HTML</outputFormat>
<outputFormat>XML</outputFormat>
</outputFormats>
<threads>4</threads>
<mutationThreshold>70</mutationThreshold>
<coverageThreshold>80</coverageThreshold>
</configuration>
<dependencies>
<!-- JUnit 5 support -->
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>Run mutation tests:
mvn org.pitest:pitest-maven:mutationCoverageReport is at target/pit-reports/index.html.
Gradle Setup
// build.gradle
plugins {
id 'info.solidsoft.pitest' version '1.15.0'
}
pitest {
targetClasses = ['com.example.service.*', 'com.example.domain.*']
targetTests = ['com.example.*Test', 'com.example.*Tests']
mutators = ['DEFAULTS']
outputFormats = ['HTML', 'XML']
threads = 4
mutationThreshold = 70
junit5PluginVersion = '1.2.1'
}./gradlew pitestMutation Operators
PIT groups mutators into sets:
DEFAULTS (recommended starting point):
CONDITIONALS_BOUNDARY—>=→>,<→<=INCREMENTS—i++→i--INVERT_NEGS—-x→xMATH—+→-,*→/NEGATE_CONDITIONALS—==→!=,<→>=RETURN_VALS— mutate return valuesVOID_METHOD_CALLS— remove void method calls
STRONGER (superset of DEFAULTS, catches more, slower):
<mutators>
<mutator>STRONGER</mutator>
</mutators>ALL (every available mutator):
<mutators>
<mutator>ALL</mutator>
</mutators>Custom selection:
<mutators>
<mutator>CONDITIONALS_BOUNDARY</mutator>
<mutator>NEGATE_CONDITIONALS</mutator>
<mutator>RETURN_VALS</mutator>
<mutator>EMPTY_RETURNS</mutator>
<mutator>NULL_RETURNS</mutator>
<mutator>TRUE_RETURNS</mutator>
<mutator>FALSE_RETURNS</mutator>
</mutators>Reading the Report
The HTML report shows each class with:
- Line coverage: Lines covered by tests
- Mutation coverage: Percentage of mutations killed
Click a class to see the source code with mutation annotations:
✓ SURVIVED if (amount > 0) → if (amount >= 0)
✗ KILLED return total; → return 0A green line = mutation killed. A red line = mutation survived. Focus your attention on red lines in business logic.
Scoping What PIT Tests
<configuration>
<!-- Only mutate these classes -->
<targetClasses>
<param>com.example.service.*</param>
<param>com.example.domain.*</param>
<!-- Exclude data classes -->
<excludedClasses>
<param>com.example.domain.*DTO</param>
<param>com.example.domain.*Entity</param>
</excludedClasses>
</targetClasses>
<!-- Only run these test classes -->
<targetTests>
<param>com.example.*Test</param>
<param>com.example.*Tests</param>
<!-- Exclude integration tests — too slow -->
<excludedTestClasses>
<param>com.example.*IntegrationTest</param>
</excludedTestClasses>
</targetTests>
<!-- Exclude specific methods -->
<excludedMethods>
<param>toString</param>
<param>hashCode</param>
<param>equals</param>
<param>get*</param>
<param>set*</param>
</excludedMethods>
</configuration>Thresholds
PIT can fail the build if mutation score drops below a threshold:
<mutationThreshold>70</mutationThreshold> <!-- fail if < 70% mutations killed -->
<coverageThreshold>80</coverageThreshold> <!-- fail if < 80% line coverage -->Set thresholds conservatively when first adopting PIT — start at your current score and raise incrementally.
Incremental Analysis
PIT can compare against a previous run and only mutate changed code:
<configuration>
<withHistory>true</withHistory>
<historyInputFile>${project.build.directory}/pit-history/history.xml</historyInputFile>
<historyOutputFile>${project.build.directory}/pit-history/history.xml</historyOutputFile>
</configuration>Incremental mode is critical for large projects — it reduces mutation test time from 30 minutes to 2–5 minutes for typical PR-sized changes.
Parallel Execution
<threads>4</threads> <!-- Run 4 JVM instances in parallel -->
<timeoutFactor>1.5</timeoutFactor> <!-- Allow 50% more time per mutant -->
<timeoutConstant>3000</timeoutConstant> <!-- Plus 3000ms constant overhead -->Set threads to your CPU core count. More threads = faster execution but higher memory usage.
JUnit 5 Integration
PIT requires the JUnit 5 plugin for projects using JUnit 5:
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>Without this plugin, PIT won't find or run JUnit 5 tests.
Spring Boot Projects
PIT works with Spring Boot but can be slow because it starts the Spring context for each mutant. Solutions:
- Exclude integration tests from PIT's scope — only run unit tests
- Use
@MockBeansparingly — each@MockBeanforces a new context - Configure context caching — Spring's test context caching helps when running multiple mutants
<excludedTestClasses>
<param>com.example.*IntegrationTest</param>
<param>com.example.*IT</param>
</excludedTestClasses>CI Integration
GitHub Actions example:
- name: Run mutation tests
run: mvn org.pitest:pitest-maven:mutationCoverage
- name: Upload PIT report
uses: actions/upload-artifact@v4
with:
name: pit-report
path: target/pit-reports/For scheduled runs (nightly mutation testing):
on:
schedule:
- cron: '0 2 * * *' # Run at 2 AM daily
push:
branches: [main]Example: Finding Real Bugs
// Code under test
public class DiscountCalculator {
public double calculate(double price, int quantity) {
if (quantity >= 10) {
return price * quantity * 0.9; // 10% discount
}
return price * quantity;
}
}
// Existing test — 100% coverage
@Test
void appliesDiscountForLargeOrders() {
DiscountCalculator calc = new DiscountCalculator();
double total = calc.calculate(100.0, 10);
assertThat(total).isEqualTo(900.0);
}PIT mutates >= to >. Now quantity = 10 doesn't apply the discount. The test still passes because it checks for 900.0 — wait, actually it would fail. Good.
But PIT also mutates 0.9 to 1.0. Your test passes if total is 900.0 but would also... no, the result would be 1000.0. Test would fail. Good.
What if PIT mutates >= to > and there's no test for the exact boundary (quantity = 10)? With only a test for quantity = 15, the mutation survives. PIT would report: survived mutation at line 3. You'd write a boundary test for quantity = 10.
Summary
PIT for Java:
- Add the Maven/Gradle plugin + JUnit 5 plugin
- Configure
targetClassesandtargetTeststo scope correctly - Use
DEFAULTSmutators — covers the most important cases - Enable incremental analysis for fast feedback on changes
- Run on a schedule in CI, not on every commit
- Fix surviving mutations by writing targeted assertions