Mutation Testing with Infection as a Quality Lever for PHPUnit
AI generated
@test
assert
PHPUnit · Infection · Mutation Testing · CI
Mutation Testing with Infection
as a Quality Lever for PHPUnit

100% code coverage does not mean the tests actually check anything. Infection introduces automatic mutations into production code and measures whether PHPUnit tests detect these faults. The Mutation Score Index is the most honest quality metric you can apply to a test suite.

14 min read Infection · MSI · Mutants · CI pipeline PHP 8.2+ · PHPUnit 10/11

1. What mutation testing actually measures

Mutation testing answers a question that code coverage cannot answer: would the tests fail if the production code contained a bug? The approach is radically pragmatic: a tool, in PHP that is Infection, automatically generates many slightly altered versions of the production code, called mutants. Each mutant contains a small, targeted fault: a > becomes >=, a return true becomes return false, an addition becomes a subtraction. Then the entire PHPUnit test suite runs against each of these mutants.

If at least one test fails, the mutant is considered "killed", the tests detected the fault. If the suite runs through without any test failing, the mutant "survived". A surviving mutant is a finding: this fault in the production code would have gone unnoticed. Code coverage only measures whether a line was executed, not whether the test would detect a fault in that line. The Mutation Score Index (MSI) is the percentage of killed mutants and the most honest quality metric you can apply to a test suite.

Using mutation testing changes how you write tests. Developers who work with Infection instinctively start writing assertions that really verify the core logic path, instead of just walking the happy path and then adding an arbitrary assertion so the test turns "green". This shift in mindset is often more valuable than the actual MSI numbers.

2. Installing and configuring Infection

Infection is installed as a Composer dev dependency: composer require --dev infection/infection. To run, Infection needs a PHPUnit code coverage source, either via Xdebug (XDEBUG_MODE=coverage) or via PCOV, which is considerably faster. An infection.json5 configuration file in the project root controls which directories are analyzed, which mutation operators are active, and which MSI thresholds count as a minimum requirement.

The first run often produces alarming results: MSI values of 30 to 50 percent are common in projects that have never run mutation testing. That is not a failure on the developers' part, it is the normal state when coverage was the only metric in use. Infection's HTML report shows, for every surviving mutant, exactly which line of code was changed and which mutation survived. These reports are the starting point for targeted test improvement.


// infection.json5, Infection configuration for a PHP project
{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": ["src"],
        "excludes": ["src/Infrastructure/Migrations"]
    },
    "mutators": {
        "@default": true,
        "UnwrapArrayFilter": false
    },
    "minMsi": 75,
    "minCoveredMsi": 85,
    "testFramework": "phpunit",
    "testFrameworkOptions": "--testsuite=unit",
    "threads": 4,
    "logs": {
        "text": "var/infection/infection.log",
        "html": "var/infection/index.html",
        "summary": "var/infection/summary.log"
    }
}

3. Understanding the most important mutation types

Infection ships with more than 60 built-in mutators. The ones most relevant to PHP business logic are arithmetic mutators (addition to subtraction, multiplication to division), logical mutators (&& to ||, removing !) and comparison mutators (> to >=, === to !==). Return mutators are particularly valuable, turning return $value into return null or return true into return false, because these mutants frequently survive when tests do not assert the return value.

The @default group activates every mutator that is generally useful for PHP code. For specific domains you can deactivate individual mutators. The UnwrapArrayFilter mutator, for example, removes array_filter() calls, and in some codebases this produces thousands of mutants in helper code that is not core logic. Deliberately deactivating such mutators keeps the focus on the code that really matters.

4. Interpreting the MSI score and setting targets

The Mutation Score Index is the percentage of killed mutants relative to the total number of generated mutants. An MSI of 70 percent means that 30 percent of the introduced faults would have gone unnoticed by the test suite. The Covered MSI, by contrast, only considers mutants in lines of code that are actually executed by tests, it is always higher than the MSI and shows how effectively the existing coverage secures the paths that are tested.

Realistic targets for legacy projects start at 60 percent MSI and 75 percent Covered MSI. For new modules in greenfield projects, 80 percent MSI and 90 percent Covered MSI are achievable. More important than absolute numbers is the trend: an MSI that rises or stays stable on every CI run shows that the team handles test quality deliberately. A falling MSI signals that new features are being written without sufficient safeguards. The minMsi option in infection.json5 lets CI builds fail whenever the threshold is undershot.

5. Analyzing surviving mutants and improving tests

Infection's HTML report is the most important tool for actual quality improvement. For every surviving mutant, the report shows the original code and the mutation as a diff. Common patterns among surviving mutants: boundary conditions that are tested but never asserted at the boundary value itself. Return values of methods that are called but whose result is never checked. Negations in conditions that can be removed without any test failing.

Systematic analysis of surviving mutants follows a clear pattern: first you identify the most critical surviving mutators, ones in core business logic such as price calculation, authorization checks or state transitions. For every surviving mutant in this area you write a new test that checks exactly the condition the mutant changed. This process is iterative and produces tests that have real fault-detection ability, not just coverage.


<?php
// Example: surviving mutant reveals missing boundary assertion
class DiscountCalculator
{
    /**
     * Returns discount rate for the given order total.
     * Mutant: changed > to >= for the 100.00 threshold, survived!
     * This means no test checked the exact boundary value.
     */
    public function getRate(float $orderTotal): float
    {
        if ($orderTotal > 100.00) {  // Infection mutates to: >= 100.00
            return 0.10;
        }
        return 0.00;
    }
}

// Before: test that lets mutant survive
class BadDiscountTest extends TestCase
{
    public function testLargeOrderGetsDiscount(): void
    {
        $calc = new DiscountCalculator();
        // Only tests far above boundary, mutation > to >= survives
        $this->assertSame(0.10, $calc->getRate(200.00));
    }
}

// After: boundary tests that kill the mutant
class GoodDiscountTest extends TestCase
{
    public function testExactBoundaryGetsNoDiscount(): void
    {
        $calc = new DiscountCalculator();
        $this->assertSame(0.00, $calc->getRate(100.00)); // kills >= mutant
    }

    public function testJustAboveBoundaryGetsDiscount(): void
    {
        $calc = new DiscountCalculator();
        $this->assertSame(0.10, $calc->getRate(100.01)); // kills < mutant
    }
}

6. Ignoring mutants sensibly without skewing metrics

Not every surviving mutant needs to be killed with a new test. Some mutants live in helper methods that only produce logging or debug output, verifying their behavior in tests would produce tests that create more noise than value. Infection offers two mechanisms for excluding such mutants: the @infection-ignore-all docblock attribute for an entire class or method, and the ignoreSourceCodeByRegex option in infection.json5.

The rule here: ignored mutants must be documented. A comment explaining why a particular mutant is ignored prevents later developers from either writing pointless tests for the ignored code or assuming the MSI is already optimal. You should never exclude entire directories without justification just to raise the MSI, that undermines the whole point of the metric.

7. Integrating Infection into CI pipelines

Integrating Infection into CI means making two decisions: when does mutation testing run, and how strict is the threshold? Since mutation testing takes considerably longer than a normal test suite, it is advisable to run it in a separate CI job that is not on the critical path of every commit. A sensible strategy: mutation testing runs on every pull request against the changed files only (with --git-diff-filter=AM), not against the entire codebase.

Infection's --git-diff-filter flag is the decisive lever here: it filters the files to be analyzed down to exactly the files that were changed in the current branch. That reduces the runtime from possibly 20 minutes for the whole project to 1 to 3 minutes for the changed code. Combined with an MSI threshold of 80 percent for changed files, this ensures that new code is always adequately safeguarded, without legacy code that has not yet been optimized blocking the pipeline.


# GitHub Actions: Infection for changed files only
# .github/workflows/mutation.yml
name: Mutation Testing

on:
  pull_request:
    branches: [main, develop]

jobs:
  infection:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # required for git-diff-filter

      - name: Setup PHP with PCOV (faster than Xdebug for coverage)
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: pcov
          extensions: pcov

      - run: composer install --no-interaction

      - name: Run PHPUnit with coverage (required by Infection)
        run: vendor/bin/phpunit --coverage-xml=var/infection/coverage/xml
                                --log-junit=var/infection/junit.xml

      - name: Run Infection on changed files only
        run: |
          vendor/bin/infection \
            --git-diff-filter=AM \
            --min-msi=80 \
            --min-covered-msi=90 \
            --coverage=var/infection/coverage \
            --threads=4 \
            --logger-html=var/infection/index.html
        env:
          INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_KEY }}

8. Controlling the runtime cost of mutation testing

The biggest practical obstacle to mutation testing is runtime. Infection runs the entire PHPUnit suite for every mutant, with 500 mutants and a suite that runs in 10 seconds, that is 83 minutes. Three strategies cut this cost significantly: parallelization via --threads, PCOV instead of Xdebug for coverage generation, and limiting the analyzed code to critical directories.

The most effective optimization, however, is using --only-covered: Infection then only analyzes mutants in lines that are covered by at least one test. This reduces the mutant count considerably without distorting the quality signal, because mutants in uncovered code would survive anyway and are already flagged as a problem by missing coverage. With --filter you can additionally isolate individual test suites or classes to introduce mutation testing into existing projects step by step.

9. Coverage vs. MSI: what the metrics really say

The differences between code coverage and MSI are fundamental and complement rather than compete with each other. Code coverage says: "This line was executed by a test." The MSI says: "This line was executed by a test that would also detect a fault in this line." 100 percent coverage with 40 percent MSI means: every line is exercised, but almost half of all faults in those lines would go unnoticed.

Metric What it measures Weakness Complement
Line Coverage Line executed? No assertion required Shows gaps in the test path
Branch Coverage All branches tested? No assertion check Better than Line Coverage
Mutation Score Index Fault in line detected? Slow execution Direct quality measure
Covered MSI MSI for covered code only Ignores uncovered code Efficiency of existing tests

The practical recommendation: treat coverage as a necessary condition, MSI as a sufficient condition. First you make sure critical paths have high coverage, then you optimize the MSI for those covered areas. A project that starts supplementing coverage as its sole metric with MSI usually finds that the tests passing the least mutation testing are also the ones that gave the least real confidence, the MSI makes explicit what used to be only a gut feeling.

Mironsoft

Test quality, mutation testing and CI integration for PHP projects

Test suite with a real quality measure?

We set up Infection for your PHP project, analyze the surviving mutants in your core logic and improve, in a targeted way, the tests that would currently let faults through.

Infection Setup

Installation, configuration and an initial MSI baseline for your PHP project

Mutant Analysis

Systematic analysis of surviving mutants in critical business logic and targeted test refactoring

CI Integration

Infection in GitHub Actions or GitLab CI with git-diff-filter for fast PR checks

10. Summary

Mutation testing with Infection is the most effective quality lever you can apply to PHPUnit test suites once code coverage is already in place. The Mutation Score Index reveals what coverage hides: whether tests would actually detect faults. In practice, projects with MSI-driven test development write tests that explicitly safeguard boundaries, return values and error handling, instead of merely walking through code.

Integration into CI is leaner than feared via --git-diff-filter: only changed files are analyzed, keeping the runtime under control. PCOV instead of Xdebug halves coverage-generation time. Targeted ignores with documented reasons keep the MSI an honest metric. Starting at an MSI of 60 percent and optimizing step by step toward 80 percent leaves you not just with better numbers, but with a test suite you can genuinely rely on.

Mutation Testing with Infection, the Essentials at a Glance

MSI vs. Coverage

Coverage measures execution, MSI measures fault-detection ability. 100% coverage with 40% MSI means: almost half of all faults would have gone unnoticed.

CI Integration

--git-diff-filter=AM analyzes only changed files. Runtime 1 to 3 min instead of 20+ min for the whole project. PCOV instead of Xdebug for fast coverage.

Surviving Mutants

The HTML report shows a diff for every surviving mutant. Boundaries, return values and negations are the most common findings. Optimize critical logic first.

Realistic Targets

Legacy: 60% MSI as an entry point, 75% as a target. New modules: 80% MSI, 90% Covered MSI. Trend matters more than absolute numbers.

11. FAQ: Mutation Testing with Infection for PHPUnit

1Coverage vs. MSI: what is the difference?
Coverage: line executed. MSI: fault in the line detected. 100% coverage can coexist with 40% MSI, meaning almost half of all faults would have gone unnoticed.
2How long does an Infection run take?
With git-diff-filter and PCOV: 1 to 3 min for PR changes. Whole project: 10 to 30 min. --threads=4 cuts the time roughly in half again.
3Which MSI target should you aim for?
Legacy: 60-70% entry point, 80% target. New modules: 80% MSI, 90% Covered MSI. The trend matters more than absolute numbers.
4Ignoring mutants without skewing the MSI?
@infection-ignore-all in the docblock or ignoreSourceCodeByRegex in infection.json5. Ignored mutants are excluded from the calculation. Document the reason.
5Most important mutators for business logic?
Comparison mutators (> to >=), return mutators (true to false), logical mutators (&& to ||). These survive most often in weakly tested business logic.
6Enable coverage before Infection?
Yes, Infection needs coverage data. PCOV instead of Xdebug is recommended, considerably faster. Run PHPUnit with --coverage-xml before starting Infection.
7Infection for integration tests?
Technically possible, but not recommended. Integration tests increase the runtime exponentially. Infection is most effective for fast unit tests of business logic.
8What is Covered MSI?
MSI for lines with coverage only. Always higher than overall MSI. Shows how effectively existing tests detect faults in covered code.
9PHP 8 features supported?
Yes, Infection supports PHP 8.0-8.4 including readonly properties, enums, match expressions and union types. Specific mutators are in newer versions.
10Why does a mutant survive?
The HTML report shows the exact diff per mutant. Most common causes: missing boundary assertions, unchecked return values, missing negation tests.