Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 069 / 100

Mutation testing with PIT: practical tips and trade-offs

Find tests that pass no matter what the code does.

Expona Admin8 min read

The problem with tests that always pass

We've all shipped tests that gave us a false sense of security. You run the test suite, everything passes, and weeks later a bug makes it to production that those tests should have caught. The root cause? Your assertions were too loose or your mocks were hiding real behavior. Mutation testing with PIT: practical tips and trade-offs addresses this blind spot directly by injecting bugs into your code and checking whether your tests actually fail.

Mutation testing is a technique where a tool modifies ("mutates") your source code in small ways—changing > to >=, removing method calls, flipping boolean values—then reruns your test suite. If your tests still pass after a mutation, you've found a surviving mutant, which indicates a gap in your test coverage. It's a humbling but effective way to find tests that pass no matter what the code does.

How PIT mutates code and reruns tests

PIT (Pitest) is a Java-based mutation testing framework that integrates with Maven and Gradle. It works by bytecode manipulation: it doesn't rewrite your source files. Instead, it generates mutated bytecode on the fly, runs your test suite against each mutant, and reports which mutants survived (tests passed) and which were killed (tests failed).

Here's a typical workflow:

  1. Add PIT to your build configuration
  2. Run mvn org.pitest:pitest-maven:mutationCoverage
  3. PIT generates mutants and runs your tests against each one
  4. Review the HTML report to see which mutants survived
  5. Strengthen assertions or add new tests to kill surviving mutants

Consider this simple pricing calculation:

java
public class PriceCalculator {
    public BigDecimal calculateTotal(BigDecimal unitPrice, int quantity, BigDecimal discountRate) {
        BigDecimal subtotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
        BigDecimal discount = subtotal.multiply(discountRate);
        return subtotal.subtract(discount);
    }
}

A weak test might look like this:

java
@Test
public void testCalculateTotal() {
    PriceCalculator calculator = new PriceCalculator();
    BigDecimal result = calculator.calculateTotal(
        BigDecimal.TEN,      // unitPrice
        5,                   // quantity
        BigDecimal.valueOf(0.1)  // 10% discount
    );
    assertNotNull(result);  // Too loose!
}

This test will pass even if PIT mutates the subtraction to addition, or removes the discount logic entirely. When you run PIT, it surfaces mutants like:

  • Changing subtract(discount) to add(discount) → test still passes
  • Removing the discount calculation → test still passes
  • Changing the discount rate multiplier → test still passes

A stronger test catches these mutations:

java
@Test
public void testCalculateTotalWithDiscount() {
    PriceCalculator calculator = new PriceCalculator();
    BigDecimal result = calculator.calculateTotal(
        BigDecimal.TEN,      // unitPrice
        5,                   // quantity
        BigDecimal.valueOf(0.1)  // 10% discount
    );
    // Expected: (10 * 5) - (50 * 0.1) = 50 - 5 = 45
    assertEquals(BigDecimal.valueOf(45), result);
    
    // Edge case: verify discount is actually applied
    BigDecimal noDiscount = calculator.calculateTotal(
        BigDecimal.TEN,
        5,
        BigDecimal.ZERO
    );
    assertEquals(BigDecimal.valueOf(50), noDiscount);
    assertTrue(result.compareTo(noDiscount) < 0);
}

Now mutations to the arithmetic logic will cause test failures, and PIT reports a higher mutation kill rate.

Surviving mutants reveal weak assertions

A surviving mutant is a gift. It tells you exactly where your test suite has a blind spot. The PIT report shows you the line of code, the type of mutation applied, and the test class that should have killed it but didn't.

Common categories of surviving mutants:

  • Assertion gaps: Test passes but doesn't verify the actual computed value
  • Boundary neglect: Assertions miss edge cases at limits (zero, negative, max values)
  • Boolean flips: Tests don't differentiate between true and false return values
  • Mock isolation: Mocked dependencies hide real behavior; mutations to the mock setup go undetected
  • Side-effect blindness: Tests ignore state changes or external calls

Here's a permission-checking example where surviving mutants are instructive:

java
public class PermissionService {
    public boolean canEditOrder(User user, Order order) {
        if (user.isAdmin()) {
            return true;  // Admins can always edit
        }
        return user.getId() == order.getCreatorId();
    }
}

A test that might pass with surviving mutants:

java
@Test
public void testAdminCanEdit() {
    User admin = new User(1, true);  // isAdmin = true
    Order order = new Order(99);
    PermissionService service = new PermissionService();
    
    assertTrue(service.canEditOrder(admin, order));  // OK, but...
}

PIT might mutate the condition if (user.isAdmin()) to if (!user.isAdmin()) and the test still passes because the creator ID also matches. Add this test to kill that mutant:

java
@Test
public void testAdminCanEditRegardlessOfCreator() {
    User admin = new User(1, true);
    Order order = new Order(99);  // creatorId = 99
    PermissionService service = new PermissionService();
    
    assertTrue(service.canEditOrder(admin, order));
}

@Test
public void testNonAdminCannotEditOthersOrder() {
    User user = new User(1, false);  // isAdmin = false
    Order order = new Order(99);     // creatorId = 99 (not user's order)
    PermissionService service = new PermissionService();
    
    assertFalse(service.canEditOrder(user, order));
}

Run PIT on core domain modules only

Running mutation testing on your entire codebase is expensive. A typical project might generate thousands of mutants, and each requires a full test run. On a large codebase, this can take hours or even days. Be selective.

Focus PIT on:

  • Business logic modules: Pricing, discounts, commission calculations
  • Permission and access control layers: Authorization decisions are high-stakes
  • State machines and workflow engines: Complex conditional logic
  • Financial or compliance-critical code: Where bugs are expensive

Skip or deprioritize:

  • Controller routing and request mapping (integration tests handle this better)
  • Boilerplate getters and setters
  • Logging and monitoring code
  • Simple pass-through adapters and mappers

Configure PIT in Maven to target specific packages:

java
<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.14.2</version>
    <configuration>
        <targetClasses>
            <param>com.myapp.pricing.*</param>
            <param>com.myapp.auth.*</param>
        </targetClasses>
        <targetTests>
            <param>com.myapp.pricing.*Test</param>
            <param>com.myapp.auth.*Test</param>
        </targetTests>
        <mutators>
            <mutator>STRONGER</mutator>
        </mutators>
        <timeoutFactor>1.25</timeoutFactor>
    </configuration>
</plugin>

The STRONGER mutator set includes more realistic mutations. The timeoutFactor prevents infinite loops from hanging the build.

Great for pricing and permission logic

Two domains where mutation testing delivers immediate ROI are pricing calculations and permission checks. These are exactly where small code changes can have large business impact, and where weak tests are most dangerous.

Pricing domain example:

E-commerce systems depend on correct price calculations. A mutation like changing < to <= in a tiered discount condition could shift which bracket a customer falls into. Surviving mutants here indicate tests that don't adequately exercise the boundaries between tiers.

java
public class TieredDiscountCalculator {
    public BigDecimal getDiscount(BigDecimal quantity) {
        if (quantity.compareTo(BigDecimal.valueOf(100)) < 0) {
            return BigDecimal.ZERO;
        } else if (quantity.compareTo(BigDecimal.valueOf(500)) < 0) {
            return BigDecimal.valueOf(0.05);  // 5% discount
        } else {
            return BigDecimal.valueOf(0.10);  // 10% discount
        }
    }
}

Mutation testing will check if your tests verify behavior at exactly 100 and exactly 500 units, where PIT will flip boundary comparisons.

Permission logic example:

Authorization code is critical and often subtle. A change to an AND to an OR, or vice versa, can open a security hole. Surviving mutants in permission logic often indicate that test cases don't exercise the full decision tree.

java
public class ResourceAccess {
    public boolean canDelete(User user, Resource resource) {
        boolean isOwner = user.getId() == resource.getOwnerId();
        boolean hasAdminRole = user.hasRole("ADMIN");
        boolean isInGracePeriod = resource.getCreatedAt()
            .isAfter(Instant.now().minus(Duration.ofHours(24)));
        
        return (isOwner && isInGracePeriod) || hasAdminRole;
    }
}

Test this thoroughly to catch mutations:

java
@Test
public void testOwnerCanDeleteWithinGracePeriod() {
    // Test owner + within grace + non-admin
}

@Test
public void testOwnerCannotDeleteAfterGracePeriod() {
    // Test owner + after grace + non-admin
}

@Test
public void testAdminCanAlwaysDelete() {
    // Test admin + any time + any owner
}

@Test
public void testNonOwnerCannotDelete() {
    // Test non-owner + within grace + non-admin
}

Common mistakes

Running PIT too early or too often: Don't run mutation testing on every build. Use it as a quarterly code-quality check or when refactoring critical modules. The cost isn't worth it for rapid development iterations.

Chasing 100% mutation kill rate: Some mutants are harmless (e.g., removing a redundant null check). Use PIT's filters and exclusions to ignore them. A 75–85% kill rate on core logic is excellent and sustainable.

Testing implementation details instead of behavior: Surviving mutants often point to tests that verify how code does something rather than what it does. Refactor to test outcomes and invariants instead.

Not involving the team: Mutation reports can look alarming at first glance. Walk through surviving mutants as a team to decide which are legitimate gaps and which are noise.

Ignoring timeouts: Mutated code can cause infinite loops. Always set a reasonable timeoutFactor and monitor build times.

Key takeaways

  • Mutation testing surfaces tests that pass but don't actually verify behavior—a gap traditional code coverage misses.
  • PIT mutates bytecode and reruns your test suite; surviving mutants reveal weak assertions and boundary neglect.
  • Focus PIT on high-stakes domains: pricing logic, permission checks, workflow state machines, and financial calculations.
  • Start with a narrow scope (one or two modules) and use filters to keep run times reasonable.
  • A 75–85% mutation kill rate on core logic is a realistic and valuable target; don't chase perfection.
  • Use the PIT report as a team exercise to strengthen tests iteratively rather than a compliance checkbox.
  • Combine mutation testing with traditional unit testing and integration testing for comprehensive quality assurance.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles