Infection PHP: Mutation Testing to Measure Test Quality

Infection PHP: Mutation Testing to Measure Test Quality

Code coverage tells you which lines were executed during tests. It says nothing about whether those tests actually verify anything. A test that calls every function but makes no assertions gets 100% code coverage. Mutation testing fixes this. Infection, the PHP mutation testing framework, modifies your code in small ways and checks whether your tests catch the change. If they don't, your tests are weaker than they appear.

The Core Concept

Infection makes small, targeted modifications to your source code — called "mutants":

// Original
if ($age >= 18) {
    return true;
}

// Mutant 1: >= becomes >
if ($age > 18) {
    return true;
}

// Mutant 2: >= becomes <
if ($age < 18) {
    return true;
}

// Mutant 3: return true becomes return false
if ($age >= 18) {
    return false;
}

Infection runs your tests against each mutant. A mutant is "killed" if at least one test fails. A mutant "escapes" if all tests pass — meaning your tests didn't catch a behavioral change. Escaped mutants reveal gaps in test assertions.

Installation

composer require --dev infection/infection

Or as a standalone PHAR:

wget https://github.com/infection/infection/releases/download/0.28.1/infection.phar
chmod +x infection.phar

First Run

vendor/bin/infection

On first run, Infection:

  1. Runs your PHPUnit suite to establish a baseline
  2. Generates mutants for each covered line
  3. Runs tests against each mutant
  4. Reports the Mutation Score Indicator (MSI)

Sample output:

Processing source code files: 12/12
Creating mutants: 347
Mutants created: 347

Running initial test suite...
PHPUnit: 156 tests, 312 assertions (2.45s)

Running mutants...
    [347 / 347] (100%) [4.2 minutes]

Results:
  - Killed:   298 (85.9%)
  - Escaped:   32 (9.2%)
  - Timed out:  8 (2.3%)
  - Not covered: 9 (2.6%)

Metrics:
  Mutation Score Indicator (MSI): 85.9%
  Mutation Code Coverage: 97.4%
  Covered Code MSI: 88.2%

An MSI of 85.9% means 85.9% of mutants were killed by tests.

Configuration

Create infection.json5 in your project root:

{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": ["src"],
        "excludes": [
            "Infrastructure/Database/Migrations",
            "Infrastructure/Console"
        ]
    },
    "testFramework": "phpunit",
    "testFrameworkOptions": "--testsuite=Unit,Integration",
    "timeout": 10,
    "logs": {
        "text": "build/infection/infection.log",
        "html": "build/infection/infection.html",
        "json": "build/infection/infection.json",
        "badge": {
            "branch": "main"
        }
    },
    "mutators": {
        "@default": true
    },
    "minMsi": 80,
    "minCoveredCodeMsi": 85
}

minMsi and minCoveredCodeMsi cause Infection to exit with a non-zero code if the score falls below the threshold — this makes CI fail when test quality drops.

Understanding Mutators

Infection has dozens of mutators organized in groups:

{
    "mutators": {
        "@default": true,          // all default mutators
        "@arithmetic": true,       // +, -, *, /, %, **
        "@comparison": true,       // ==, ===, !=, >, <, >=, <=
        "@boolean": true,          // true/false flips
        "@conditional_boundary": true,  // > becomes >=, < becomes <=
        "@return_value": true,     // return true → return false, return 0 → return 1
        "ArrayItemRemoval": false  // disable specific mutator
    }
}

Common mutators and what they catch:

// ConditionNegation mutator
// Original: if ($user->isActive())
// Mutant:   if (!$user->isActive())
// Caught by: tests that verify behavior for inactive users

// IncrementInteger mutator
// Original: $limit = 10;
// Mutant:   $limit = 11;
// Caught by: tests with assertions on exact counts

// FalseValue mutator
// Original: return true;
// Mutant:   return false;
// Caught by: any test asserting the return value

Reading the HTML Report

The HTML report is the most useful debugging tool. It shows each escaped mutant with the original code and the mutation:

ESCAPED: src/Service/DiscountService.php:45

Original:
  if ($order->total >= $this->minimumOrderAmount) {

Mutant:
  if ($order->total > $this->minimumOrderAmount) {

Covered by:
  - DiscountServiceTest::test_applies_discount_above_minimum

This escaped mutant tells you: your test for discounts doesn't verify the boundary condition. A total == $minimumOrderAmount case is not tested.

The fix:

public function test_applies_discount_at_exact_minimum(): void
{
    $service = new DiscountService(minimumOrderAmount: 100.00);
    $order = Order::factory()->make(['total' => 100.00]);

    $this->assertTrue($service->qualifiesForDiscount($order));
}

public function test_no_discount_just_below_minimum(): void
{
    $service = new DiscountService(minimumOrderAmount: 100.00);
    $order = Order::factory()->make(['total' => 99.99]);

    $this->assertFalse($service->qualifiesForDiscount($order));
}

Performance Optimization

Mutation testing is slow — it runs your test suite once per mutant. Strategies to keep it manageable:

Run Only on Changed Files

# Run Infection only on files changed in this PR
vendor/bin/infection \
    --git-diff-filter=A,M \
    --git-diff-base=origin/main

Use Infection with Specific Directories

# Only mutate a specific module
vendor/bin/infection --filter=src/Domain/Pricing

Run With Coverage from PHPUnit

Infection can reuse existing coverage data instead of regenerating it:

# Generate coverage with PHPUnit first
vendor/bin/phpunit --coverage-xml=build/coverage/xml --log-junit=build/coverage/junit.xml

# Reuse it in Infection
vendor/bin/infection --coverage=build/coverage

This is 2-3x faster than having Infection generate coverage itself.

Parallel Execution

vendor/bin/infection --threads=4

CI Integration

# .github/workflows/mutation.yml
name: Mutation Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  mutation:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Need git history for --git-diff-base

      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          coverage: pcov

      - name: Install dependencies
        run: composer install --prefer-dist --no-interaction

      - name: Generate coverage
        run: vendor/bin/phpunit --coverage-xml=build/coverage/xml --log-junit=build/coverage/junit.xml

      - name: Run Infection (changed files only on PRs)
        if: github.event_name == 'pull_request'
        run: |
          vendor/bin/infection \
            --coverage=build/coverage \
            --git-diff-filter=A,M \
            --git-diff-base=origin/main \
            --threads=4 \
            --min-msi=80

      - name: Run Infection (full run on main)
        if: github.ref == 'refs/heads/main'
        run: |
          vendor/bin/infection \
            --coverage=build/coverage \
            --threads=4 \
            --min-msi=80

Ignoring Specific Mutations

Some code is intentionally not worth mutation testing (getters, DTOs, boilerplate):

<?php
// Exclude a whole class
/** @infection-ignore-all */
class UserDTO
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
    ) {}
}

// Exclude a specific line
public function getId(): int
{
    return $this->id; // @infection-ignore-all
}

Or configure path exclusions in infection.json5:

{
    "source": {
        "excludes": [
            "Http/Resources",
            "DataTransferObjects"
        ]
    }
}

Interpreting MSI Scores

MSI Interpretation
< 50% Test suite has critical gaps — tests exist but don't verify behavior
50-70% Partial coverage — main paths covered, edge cases missing
70-85% Good — most behavior verified; boundary conditions may be missing
85-95% Strong — tests verify behavior thoroughly
> 95% Exceptional — diminishing returns, may not be worth the CI time

Start with --min-msi=60 and raise it incrementally as you improve coverage. Don't set it to 95% on a legacy codebase — you'll never achieve CI green.

Summary

Mutation testing is the quality check for your quality checks. Code coverage shows what code was touched; mutation testing shows whether your tests would actually catch a bug. Infection integrates cleanly with PHPUnit, runs in CI, and produces actionable HTML reports that show exactly which boundary conditions aren't verified. The investment pays off most on business-critical code — pricing logic, access control, and validation — where a subtle boundary condition bug has real consequences.

Read more

Start now free