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.
Table of Contents
- 1. What mutation testing actually measures
- 2. Installing and configuring Infection
- 3. Understanding the most important mutation types
- 4. Interpreting the MSI score and setting targets
- 5. Analyzing surviving mutants and improving tests
- 6. Ignoring mutants sensibly without skewing metrics
- 7. Integrating Infection into CI pipelines
- 8. Controlling the runtime cost of mutation testing
- 9. Coverage vs. MSI: what the metrics really say
- 10. Summary
- 11. FAQ
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.