Mutation Testing in PHP with Infection
Code coverage tells you which lines your tests execute. Mutation testing tells you whether your tests actually catch bugs. These are very different things — and the gap between them is where silent regressions live.
Infection is the leading mutation testing framework for PHP. It works by making small, deliberate changes to your source code (mutations), then running your test suite against each mutant. If your tests fail, the mutant is "killed" — good. If your tests pass, the mutant "survived" — which means your tests didn't catch a real code change. That's a coverage hole worth closing.
Installing Infection
Install Infection via Composer as a dev dependency:
composer require --dev infection/infectionThen initialize it:
./vendor/bin/infection --initThis creates an infection.json5 configuration file in your project root.
Basic Configuration
A minimal infection.json5 looks like this:
{
"$schema": "vendor/infection/infection/resources/schema.json",
"source": {
"directories": ["src"]
},
"mutators": {
"@default": true
},
"testFramework": "phpunit",
"testFrameworkOptions": "--testsuite=unit",
"minMsi": 70,
"minCoveredMsi": 80
}minMsi— minimum Mutation Score Indicator (percentage of mutants killed). CI fails below this.minCoveredMsi— same, but only for covered code lines.
Running Infection
./vendor/bin/infection --threads=4The --threads flag parallelizes mutation runs significantly. On a modern machine, 4 threads cuts runtime by 60–70%.
Understanding Mutators
Infection ships with dozens of built-in mutators grouped by category. The @default set covers the most impactful ones:
ArithmeticOperator — changes + to -, * to /, etc. Comparison — changes === to !==, > to >=, etc. Boolean — flips true/false, negates conditions. Return — replaces return values with null, 0, ''. Unwrap — removes function wrappers like array_reverse().
Example: given this source function:
public function calculateDiscount(int $price, int $percentage): int
{
if ($percentage > 100) {
throw new \InvalidArgumentException('Percentage cannot exceed 100');
}
return (int) ($price * $percentage / 100);
}Infection might generate these mutants:
- Change
> 100to>= 100— if your test only passes101, this mutant survives. - Change
/to*in the return — if you only test that a value is returned, not its exact amount, this survives. - Replace the return with
return 0— if you never assert the actual calculated value, this survives.
Writing Mutation-Resistant Tests
A weak test:
public function testCalculateDiscount(): void
{
$result = $this->calculator->calculateDiscount(200, 10);
$this->assertIsInt($result);
}This test survives almost every mutation. A strong test:
public function testCalculateDiscountReturnsCorrectValue(): void
{
$this->assertSame(20, $this->calculator->calculateDiscount(200, 10));
$this->assertSame(0, $this->calculator->calculateDiscount(200, 0));
$this->assertSame(200, $this->calculator->calculateDiscount(200, 100));
}
public function testCalculateDiscountThrowsOnInvalidPercentage(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->calculateDiscount(200, 101);
}
public function testCalculateDiscountThrowsExactlyAt101NotAt100(): void
{
// This test kills the >= mutant on the boundary check
$this->assertSame(200, $this->calculator->calculateDiscount(200, 100));
$this->expectException(\InvalidArgumentException::class);
$this->calculator->calculateDiscount(200, 101);
}Specific assertions on exact values, boundary conditions, and exception triggers kill the most mutants.
Ignoring Mutants
Sometimes a surviving mutant is acceptable — for example, a log statement that intentionally has no observable effect. You can mark individual lines:
/** @infection-ignore-all */
$this->logger->debug('Processing item', ['id' => $id]);Or configure ignored mutators globally in infection.json5:
{
"mutators": {
"@default": true,
"IncrementInteger": false
}
}Integrating with CI
Add Infection to your CI pipeline after your regular test suite passes:
# .github/workflows/tests.yml
- name: Run mutation tests
run: ./vendor/bin/infection --threads=4 --min-msi=70 --min-covered-msi=80Start with a low threshold (50%) and ratchet it up as you improve your tests. Never lower the threshold — only raise it.
Reading the HTML Report
Run with --log-verbosity=all and open infection-log.html in your browser:
./vendor/bin/infection --log-verbosity=all
open infection.htmlThe report groups surviving mutants by file and shows the exact diff. This is the most efficient way to find which assertions you're missing.
Practical Strategy
Don't try to achieve 100% MSI — that's often impractical for complex business logic, UI rendering code, or bootstrap files. Instead:
- Run Infection on your domain/business logic only. Exclude framework boilerplate.
- Set a CI gate at a reasonable threshold (70–80% MSI for covered code).
- Review surviving mutants weekly. Fix the high-value ones — logic operators, boundary conditions, return values.
- Exclude noise mutants (loggers, debug output) via
@infection-ignore-all.
Mutation testing is most valuable when you run it regularly, not as a one-off audit. Integrate it into your pipeline and watch your test suite's real effectiveness improve over time.
Once your mutation score is solid locally, consider pairing it with continuous monitoring in production. HelpMeTest provides 24/7 test monitoring so you know the moment a real-world flow breaks — sign up free and keep your test coverage working around the clock.