Mutation Testing with PIT and JUnit 6 (Improve Test Quality)

Code coverage tells you which lines your tests execute. It does not tell you whether your tests would catch a bug if one was introduced. Mutation testing answers the harder question: if I intentionally break the code in small ways, do my tests detect it? PIT (Pitest) is the leading mutation testing tool for Java, and it integrates directly with JUnit 6. This guide shows you how to set it up, read the reports, and use the results to genuinely improve your test suite quality.

What Is Mutation Testing?

PIT automatically modifies your production code in small ways called mutations β€” changing a + to a -, a > to a >=, removing a method call, negating a condition. Each modified version of the code is a mutant. PIT then runs your test suite against each mutant:

  • Killed mutant β€” at least one test failed against this mutant. βœ… Good: your tests caught the bug.
  • Surviving mutant β€” all tests passed against this mutant. ❌ Bad: your tests did not catch this bug.
  • Mutation score β€” percentage of killed mutants. Higher is better.

A mutation score of 85% means 85% of introduced bugs would be caught by your test suite. The surviving 15% represent genuine gaps in your tests.

Setup: PIT with Maven

<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.16.1</version>
    <dependencies>
        <!-- JUnit 5/6 Platform support for PIT -->
        <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>1.2.1</version>
        </dependency>
    </dependencies>
    <configuration>
        <!-- Target only your application code, not generated or config classes -->
        <targetClasses>
            <param>com.example.service.*</param>
            <param>com.example.domain.*</param>
        </targetClasses>
        <!-- Only run fast unit tests (tagged 'unit') for mutation analysis -->
        <targetTests>
            <param>com.example.*Test</param>
        </targetTests>
        <!-- Mutation operators to apply -->
        <mutators>
            <mutator>STRONGER</mutator> <!-- recommended set for Java -->
        </mutators>
        <!-- Fail build if mutation score drops below 80% -->
        <mutationThreshold>80</mutationThreshold>
        <!-- Output formats: HTML (human), XML (CI integration) -->
        <outputFormats>
            <outputFormat>HTML</outputFormat>
            <outputFormat>XML</outputFormat>
        </outputFormats>
        <!-- Verbose: show each mutant result -->
        <verbose>false</verbose>
    </configuration>
</plugin>

Running PIT

# Run mutation tests and generate report
mvn test-compile org.pitest:pitest-maven:mutationCoverage

# Or bind to verify phase: mvn verify
# Report generated at: target/pit-reports/YYYYMMDDHHMI/index.html

Setup: PIT with Gradle

// build.gradle
plugins {
    id 'java'
    id 'info.solidsoft.pitest' version '1.15.0'
}

pitest {
    // JUnit 5/6 platform support
    junit5PluginVersion = '1.2.1'

    // Target only service and domain classes
    targetClasses   = ['com.example.service.*', 'com.example.domain.*']
    targetTests     = ['com.example.*Test']

    // Use STRONGER mutator set
    mutators        = ['STRONGER']

    // Report formats
    outputFormats   = ['HTML', 'XML']

    // Fail if mutation score drops below 80%
    mutationThreshold = 80

    // Number of threads for parallel mutation testing
    threads         = 4
}

// Run: ./gradlew pitest
// Report: build/reports/pitest/index.html

Reading the PIT Report

PIT Mutation Report β€” Summary
============================================================
Files mutated:    12
Mutations total:  284
Mutations killed: 241   (84.9%)
Mutations survived: 43  (15.1%)    <-- these are your test gaps

Class: com.example.service.DiscountService
  Line 45: CONDITIONALS_BOUNDARY mutation SURVIVED
    Original:  if (orderTotal > 100.0)
    Mutant:    if (orderTotal >= 100.0)
    Status:    SURVIVED <-- no test checks the boundary exactly at 100.0!

  Line 67: NEGATE_CONDITIONALS mutation KILLED
    Original:  if (customer.isPremium())
    Mutant:    if (!customer.isPremium())
    Status:    KILLED by: OrderServiceTest.premiumCustomerGetsDiscount

Fixing Surviving Mutants: Real Examples

// Production code that generated a surviving mutant:
public double applyBulkDiscount(double orderTotal) {
    if (orderTotal > 100.0) {  // PIT mutated this to >= 100.0 and tests didn't catch it!
        return orderTotal * 0.90; // 10% discount
    }
    return orderTotal;
}

// Existing tests (insufficient β€” don't test the boundary):
@Test
void orderOver100GetsDiscount() {
    assertEquals(180.0, service.applyBulkDiscount(200.0), 0.001); // well above boundary
}

@Test
void orderUnder100GetsNoDiscount() {
    assertEquals(50.0, service.applyBulkDiscount(50.0), 0.001); // well below boundary
}

// FIX: add tests that specifically cover the boundary values
@Test
@DisplayName("Order total of exactly 100.00 does NOT qualify for bulk discount")
void orderAtExactly100DoesNotGetBulkDiscount() {
    // Tests the exact boundary: 100.00 should NOT get discount (> not >=)
    assertEquals(100.0, service.applyBulkDiscount(100.0), 0.001,
        "Bulk discount requires STRICTLY greater than 100.00");
}

@Test
@DisplayName("Order total of 100.01 is the minimum that qualifies for discount")
void orderJustAbove100GetsBulkDiscount() {
    // Tests one cent above the boundary: should get discount
    assertEquals(90.009, service.applyBulkDiscount(100.01), 0.001,
        "100.01 is the minimum qualifying amount for bulk discount");
}
// After adding these tests, PIT will report this mutant as KILLED

Common Mutators and What They Test

MutatorWhat it changesWhat tests it finds
CONDITIONALS_BOUNDARY> ↔ >=, < ↔ <=Missing boundary value tests
NEGATE_CONDITIONALSif(x) β†’ if(!x)Missing negative path tests
MATH+ β†’ -, * β†’ /Missing arithmetic verification
VOID_METHOD_CALLSRemoves void method callsMissing interaction verification
RETURN_VALSChanges return values (0, null, -1)Missing null/zero return tests
INCREMENTS++ β†’ --Missing counter/loop tests

Frequently Asked Questions (FAQs)

Q1: How long does mutation testing take compared to regular tests?

Mutation testing runs your test suite once per mutant. If PIT generates 300 mutants and your tests take 10 seconds, mutation testing takes roughly 50 minutes. This is why you should: (1) target only the most important classes rather than the entire codebase, (2) use the threads option to parallelise mutation runs, and (3) run mutation testing nightly rather than on every commit.

Q2: What mutation score should I aim for?

For business-critical service and domain classes, aim for 80–90% mutation score. For utility classes and infrastructure code, 60–70% is acceptable. Do not aim for 100% β€” some mutants are semantically equivalent (the mutated code is logically the same as the original) and cannot be killed. Equivalent mutants are a known limitation of mutation testing; PIT labels some as "No coverage" or detects some equivalences automatically.

Q3: What is the difference between mutation score and code coverage?

Code coverage measures whether a line was executed. Mutation score measures whether a test would fail if that line contained a bug. It is entirely possible to have 100% line coverage with 0% mutation score β€” if all your tests have no assertions. Mutation score is a far stronger quality signal. Use code coverage as a minimum threshold; use mutation score as the true quality measure. See Code Coverage with JaCoCo and JUnit 6 for coverage setup.

Q4: Can I exclude certain classes from mutation testing?

Yes. Use <excludedClasses> in Maven or excludedClasses in Gradle to exclude DTOs, configuration classes, main application classes, and generated code. You can also exclude specific methods with the @SuppressWarnings("all") annotation or PIT’s @DoNotMutate annotation when a method contains logic that is intentionally untestable (e.g., logging statements, debug output).

Q5: How do I integrate PIT into CI/CD?

Add the PIT Maven/Gradle goal to your nightly CI pipeline (not every commit β€” it is too slow). Use <mutationThreshold>80</mutationThreshold> to fail the build if the score drops below 80%. Archive the HTML report as a build artifact and publish the XML report to your test tracking system. In GitHub Actions, upload target/pit-reports/ with actions/upload-artifact so it is accessible from each nightly run. See Running JUnit 6 Tests in CI/CD Pipelines for pipeline configuration patterns.

See Also

Conclusion

Mutation testing is the most honest measurement of test suite quality available. While code coverage tells you which lines your tests touch, mutation testing tells you which bugs they would catch. PIT integrates cleanly with JUnit 6 and requires minimal configuration to start finding the gaps in your test suite. Run it nightly on your most critical business logic and use the surviving mutants as a prioritised list of tests to write next.

Next: Performance Testing Using JUnit 6 β€” measure and assert on the performance characteristics of your code using JUnit 6 and JMH.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.