Mutation testing with Infection PHP
Concept
Mutation testing is a technique for measuring the quality of your test suite. A mutation tester (like Infection PHP) introduces small source code changes ("mutations") — flipping > to >=, changing + to -, removing a return value — and then runs your test suite against each mutated version. A "killed" mutant means at least one test failed (your tests caught the change). An "escaped" mutant means all tests still passed (your tests didn't catch the bug).
Why mutation testing matters: Code coverage metrics (70% line coverage, 80% branch coverage) only tell you which code was executed. They don't tell you whether your assertions would catch a bug. A test that calls $this->assertTrue(true) gives you 100% coverage of the assertion but catches nothing. Mutation testing reveals coverage gaps where your tests run the code but don't verify its behavior.
Infection PHP: The standard mutation testing framework for PHP. It uses the abstract syntax tree (via nikic/php-parser) to generate mutants and runs PHPUnit or Pest against each.
Key metrics:
- Mutation Score Indicator (MSI): Percentage of mutants killed. High MSI (>90%) indicates a well-tested codebase.
- Covered MSI: MSI considering only mutants in covered code. Distinguishes "tests don't run this code" from "tests run it but don't assert enough".
Common mutation operators:
GreaterThan→GreaterThanOrEqual:$x > 5→$x >= 5Return_: Replace return withnullor0Arithmetic:+→-,*→/LogicalAnd:&&→||
Code Example
# Install Infection
composer require --dev infection/infection
# Run (auto-detects PHPUnit config)
./vendor/bin/infection
# Run with specific thresholds (fail if MSI drops below 70%)
./vendor/bin/infection --min-msi=70 --min-covered-msi=80
# Output: infection.log shows each mutantESCAPED mutants (your tests MISSED these bugs):
------
killed: 45, escaped: 12, skipped: 3
Escaped:
src/Money/Money.php:15 → $this->amount + $other->amount
Mutant: → $this->amount - $other->amount
(No test failed when + became -)<?php
// Poor test — runs the code but doesn't assert enough
public function test_add_money(): void
{
$a = new Money(100, 'USD');
$b = new Money(50, 'USD');
$result = $a->add($b);
// BUG: No assertion on result amount!
$this->assertInstanceOf(Money::class, $result); // This passes even if + became -
}
// Better test — Infection would kill the arithmetic mutant
public function test_add_money_correctly(): void
{
$a = new Money(100, 'USD');
$b = new Money(50, 'USD');
$result = $a->add($b);
$this->assertSame(150, $result->amount()); // Explicitly verifies the arithmetic
}
// infection.json (configuration)
{
"source": { "directories": ["src"], "excludes": [] },
"logs": { "text": "infection.log", "html": "infection.html" },
"mutators": { "@default": true },
"phpUnit": { "configDir": "." },
"testFramework": "phpunit",
"minMsi": 70,
"minCoveredMsi": 80
}