Infection PHP: Mutation Testing for PHP Projects

Infection PHP: Mutation Testing for PHP Projects

Infection is the leading mutation testing framework for PHP. It operates on the Abstract Syntax Tree (AST) level — generating mutants by altering your source code according to predefined mutation operators — then running your test suite against each change. A test suite that kills 80%+ of mutants provides much stronger behavioral guarantees than one that only achieves high line coverage. This guide covers installation, configuration, reading results, and integrating Infection into a real PHP CI pipeline.

Key Takeaways

MSI is the metric that matters. Mutation Score Indicator (MSI) tells you what percentage of generated mutants were killed. Aim for 80%+ on business-critical code. Line coverage alone doesn't tell you this.

Uncovered mutants are a red flag. Infection distinguishes mutants on lines with no test coverage from mutants that survived tests. Uncovered mutants mean your tests aren't even reaching that code.

Use pre-generated coverage to skip uncovered lines. Run PHPUnit with --coverage-xml first, then pass it to Infection. This avoids regenerating coverage on every mutation run and significantly speeds things up.

--threads speeds up the run. Infection supports parallel execution. On a modern machine, --threads=4 can halve total mutation testing time.

Enforce MSI thresholds in CI. Use --min-msi and --min-covered-msi flags. If new code ships with a kill rate below your threshold, the CI job fails.

What Infection PHP Is

Infection is an open-source mutation testing framework for PHP. Unlike simpler tools that search-and-replace text patterns, Infection works at the Abstract Syntax Tree level — it parses PHP source into an AST, applies transformations, and regenerates valid PHP from the modified tree. This makes its mutations semantically correct and avoids invalid PHP as an artifact of the process.

The core idea is straightforward: if you change your code slightly and your tests don't notice, those tests aren't actually verifying what you think they are. Infection automates this at scale, generating hundreds or thousands of small changes across your codebase and reporting which ones your test suite caught.

Infection integrates with PHPUnit (the dominant PHP test framework) and Pest (the newer, more expressive alternative). It produces reports in multiple formats — console, HTML, JSON, GitLab-compatible — making it easy to incorporate into existing CI workflows.

Installation

Install Infection via Composer as a development dependency:

composer require infection/infection --dev

This adds Infection and its dependencies to vendor/. The binary is available at vendor/bin/infection.

For projects that prefer a PHAR distribution (no Composer dependency conflicts):

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

Verify the installation:

vendor/bin/infection --version

Infection requires PHP 8.1 or later and a working test suite that passes cleanly before you start.

Initial Configuration

On first run, Infection can generate a default configuration file:

vendor/bin/infection --init

This creates infection.json5 in your project root. A typical configuration looks like:

{
    "$schema": "vendor/infection/infection/src/Configuration/Schema/infection-schema.json",
    "source": {
        "directories": [
            "src"
        ]
    },
    "mutators": {
        "@default": true
    },
    "testFramework": "phpunit",
    "testFrameworkOptions": "--stop-on-failure",
    "timeout": 10,
    "logs": {
        "text": "infection.log",
        "html": "infection.html",
        "json": "infection.json",
        "stryker": "stryker.json",
        "summary": "infection-summary.log",
        "gitlab": "infection-gitlab.json"
    },
    "minMsi": 75,
    "minCoveredMsi": 80
}

Key configuration fields:

  • source.directories — the directories containing code to mutate. Usually just ["src"].
  • mutators — which mutation operators to apply. "@default": true enables all standard mutators. You can enable or disable specific mutators here.
  • testFrameworkphpunit or pest.
  • testFrameworkOptions — flags passed to your test runner for each mutant test run. --stop-on-failure makes each run faster by stopping at the first failing test.
  • timeout — seconds before a mutant test run is considered timed out. Set based on your normal test suite duration.
  • minMsi / minCoveredMsi — minimum acceptable kill rates. If your run falls below these, Infection exits with a non-zero code.

Running Infection

Basic run:

vendor/bin/infection

Infection will:

  1. Run your test suite once to confirm a clean baseline
  2. Generate coverage data (or use pre-existing coverage)
  3. Generate mutants from your source files
  4. Run the test suite against each mutant
  5. Report results

For a project with thousands of source lines, this initial run can take 30–60 minutes. Subsequent runs targeting changed files are faster.

Reading Results: MSI and MCC

After the run completes, Infection prints a summary to the console:

Mutation Score Indicator (MSI): 78%
Mutation Code Coverage (MCC): 91%
Covered Code MSI: 85%

These three metrics have distinct meanings:

Mutation Score Indicator (MSI) is the overall kill rate: (killed + timeout) / total mutants. This includes mutants on uncovered lines — if 20% of your code has no tests, your MSI ceiling is 80% even with perfect tests on covered lines.

Mutation Code Coverage (MCC) tells you what percentage of your mutants are on code covered by at least one test. If MCC is 91%, 9% of your mutations were on lines no test ever reached.

Covered Code MSI is the kill rate considering only mutants on covered code: (killed + timeout) / (killed + survived + timeout). This tells you how well your tests verify the code they actually exercise. This is often the most useful metric — it separates "no tests" from "tests exist but don't assert correctly."

Understanding Mutant Categories

Infection classifies each mutant as:

Killed — A test failed when this mutant was applied. Your tests detected the behavior change. This is the desired outcome.

Survived — No test failed. The mutation changed your code and nothing caught it. Either there's no test covering this code path, or the tests cover it but don't assert on the affected behavior.

Uncovered — The mutated line has no test coverage at all. Infection can skip these entirely using pre-generated coverage data.

Escaped — Synonym for "survived" in some versions of Infection output.

Timed Out — The test suite exceeded the configured timeout with this mutant applied. Often happens when a mutation turns a loop condition into an infinite loop. Counted as killed (a form of detection) in MSI calculations.

Error — The mutant caused a PHP error (parse error, fatal error). Also counted as killed.

Mutation Operators

Infection ships with dozens of mutation operators organized into categories. The @default preset enables the most useful ones:

ArithmeticOperator — Replaces + with -, * with /, etc. Catches untested arithmetic.

BooleanSubstitution — Replaces true with false and vice versa. Catches hardcoded booleans in logic.

ConditionalBoundary — Changes > to >=, < to <=. The classic boundary condition test — are you testing at the exact boundary?

ConditionalNegation — Flips > to <, >= to <=. Are your conditionals oriented correctly?

LogicalAnd — Changes && to ||. Catches tests that don't verify compound conditions correctly.

Ternary — Swaps ternary branches. Catches cases where tests only exercise one branch.

Return values — Replaces return $var with return null, return 0, etc. Catches unchecked return values.

Unwrap — Removes function wrappers, e.g., array_reverse($x) becomes $x. Catches cases where the wrapping function's effect isn't tested.

Filtering Mutators

In large codebases, you may want to disable noisy or low-value mutators. Configure this in infection.json5:

{
    "mutators": {
        "@default": true,
        "MethodCallRemoval": {
            "isEnabled": false
        },
        "ConditionalBoundary": {
            "isEnabled": true,
            "settings": {
                "ignored": ["App\\Models\\*"]
            }
        }
    }
}

You can also enable only specific mutators for targeted runs:

{
    "mutators": {
        "ConditionalBoundary": true,
        "LogicalAnd": true,
        "BooleanSubstitution": true
    }
}

Focusing on a small set of high-value mutators (boundary conditions, boolean logic, return values) catches the most meaningful gaps without burning hours on lower-value mutations.

Integration with PHPUnit and Pest

PHPUnit

Infection works with PHPUnit out of the box. Set testFramework: "phpunit" in infection.json5. Standard PHPUnit configurations (phpunit.xml or phpunit.xml.dist) are automatically detected.

Pass additional flags via testFrameworkOptions:

{
    "testFramework": "phpunit",
    "testFrameworkOptions": "--stop-on-failure --no-coverage"
}

--no-coverage disables PHPUnit's own coverage collection during mutation runs (Infection handles coverage separately), which can speed up individual mutant test runs significantly.

Pest

Infection supports Pest from version 0.26:

{
    "testFramework": "pest"
}

Pest commands pass through the same testFrameworkOptions mechanism. One common configuration for Pest:

{
    "testFramework": "pest",
    "testFrameworkOptions": "--stop-on-failure"
}

HTML and Log Reports

The console output gives you the summary. For detailed investigation, use the HTML report:

vendor/bin/infection --log-verbosity=all

With "html": "infection.html" in your logs configuration, open infection.html in a browser. The report shows:

  • Each source file with per-line mutation results
  • Surviving mutants highlighted with the exact diff
  • Killed mutants (collapsed by default, expandable)
  • Uncovered lines clearly marked

The --log-verbosity=all flag includes all mutants (killed, survived, uncovered) in the logs. The default only logs survived and errored mutants. For full CI artifact collection, use all. For a quick local investigation, use the default.

The JSON log (infection.json) is machine-readable and useful for building custom dashboards or enforcing thresholds in scripts.

CI Integration with GitHub Actions

Here's a complete GitHub Actions workflow for mutation testing on a PHP project:

name: Mutation Testing

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

jobs:
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, xml
          coverage: pcov

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

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

      - name: Run Infection
        run: |
          vendor/bin/infection \
            --threads=4 \
            --coverage=build/coverage \
            --min-msi=75 \
            --min-covered-msi=80 \
            --log-verbosity=all
        env:
          INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }}

      - name: Upload HTML report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: infection-report
          path: infection.html

Key details:

  • --coverage=build/coverage points Infection to the pre-generated coverage data from PHPUnit, avoiding double coverage generation.
  • --min-msi=75 fails the CI job if overall MSI drops below 75%.
  • --min-covered-msi=80 fails if covered-code MSI drops below 80%.
  • --threads=4 runs four parallel mutation test processes.

Using Pre-Generated Coverage

Regenerating coverage on every mutation run is the biggest time cost. The pattern is:

  1. Run PHPUnit once with coverage enabled, outputting in the format Infection needs
  2. Pass that coverage data to Infection via --coverage
# Step 1: Generate coverage (done once per run)
XDEBUG_MODE=coverage vendor/bin/phpunit \
    --coverage-xml=build/coverage/coverage-xml \
    --log-junit=build/coverage/junit.xml

<span class="hljs-comment"># Step 2: Run Infection using pre-generated coverage
vendor/bin/infection --coverage=build/coverage

With pre-generated coverage, Infection skips the baseline test run and immediately starts applying mutants. This saves a full test suite execution time on every mutation run.

pcov or Xdebug are both supported for coverage generation. pcov is significantly faster and is the recommended choice for CI.

Parallel Execution

Infection's --threads option controls how many mutants are tested concurrently:

vendor/bin/infection --threads=4

Each thread runs a complete copy of your test suite against one mutant. This works best when:

  • Your test suite is CPU-bound rather than I/O-bound
  • Tests don't share a single database with write conflicts
  • The machine running tests has multiple available cores

For database-dependent tests, consider using SQLite in-memory for mutation testing or separate database connections per thread. If tests contend on a shared database, parallel execution can produce false failures.

In CI on GitHub Actions (ubuntu-latest has 2 cores), --threads=2 is the practical maximum. On self-hosted runners with more cores, 4–8 threads typically provide good speedup.

Practical Survival: What Surviving Mutants Reveal

When Infection reports a survived mutant, read it as a question: "What test would have caught this?" The diff tells you exactly what changed. Work backwards to what assertion would detect it.

Common patterns in surviving mutants and their causes:

Boundary condition survivors (>>=) reveal tests that only check mid-range values, never the exact boundary. Fix: add test cases at the exact threshold.

Boolean substitution survivors (truefalse) reveal hardcoded flags that tests never verify are set correctly in edge cases. Fix: add a test for the case where the flag matters.

Return value survivors (replaced return with null) reveal callers that don't use the return value in tests. Fix: assert on the return value explicitly.

Arithmetic survivors (+-) reveal missing assertions on calculated values. Fix: add assertions that verify the computed result, not just that no exception was thrown.

Conclusion

Infection gives PHP projects a quantitative measure of test quality that coverage numbers can't provide. Install it, run it against your most critical services, and read the surviving mutants as a prioritized list of tests to write. The HTML report makes the gaps visible. The CI integration prevents regressions.

A codebase where Infection kills 80% of mutants in critical paths is meaningfully more reliable than one with 80% line coverage and no mutation testing. The two measures tell you different things — coverage tells you what runs, MSI tells you what your tests actually verify.

HelpMeTest adds 24/7 production monitoring and AI-powered test generation — complementing your mutation testing workflow. Start free at helpmetest.com

Read more