PIT Mutation Testing for Java: Setup, Configuration, and Workflow

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:mutationCoverage

Report 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 pitest

Mutation Operators

PIT groups mutators into sets:

DEFAULTS (recommended starting point):

  • CONDITIONALS_BOUNDARY>=>, <<=
  • INCREMENTSi++i--
  • INVERT_NEGS-xx
  • MATH+-, */
  • NEGATE_CONDITIONALS==!=, <>=
  • RETURN_VALS — mutate return values
  • VOID_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 0

A 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:

  1. Exclude integration tests from PIT's scope — only run unit tests
  2. Use @MockBean sparingly — each @MockBean forces a new context
  3. 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:

  1. Add the Maven/Gradle plugin + JUnit 5 plugin
  2. Configure targetClasses and targetTests to scope correctly
  3. Use DEFAULTS mutators — covers the most important cases
  4. Enable incremental analysis for fast feedback on changes
  5. Run on a schedule in CI, not on every commit
  6. Fix surviving mutations by writing targeted assertions

Read more

Start now free