Mutation Testing with Infection
Measure how good your tests really are.
Mutation Testing with Infection is a free PHP Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Coverage Lies
100% line coverage feels reassuring, but it only proves your tests executed the code — not that they would catch a bug in it. A test can run a line and assert nothing meaningful. Mutation testing measures the real thing: would your tests fail if the code were subtly broken? Infection is the standard PHP tool for this.
composer require --dev infection/infectionThe Core Idea: Mutants
Infection takes your covered code and introduces tiny faults — mutants. It flips > to >=, + to -, && to ||, removes a return, etc. Then it re-runs your tests against each mutant:
- If a test fails → the mutant is killed (good — your tests caught the fault).
- If all tests pass → the mutant survived (bad — a real bug here would go unnoticed).
A Survivor in Action
Consider this function and a weak test. Infection would mutate >= to >. If no test checks the exact boundary (amount equals threshold), that mutant survives — revealing an untested edge.
<?php
function qualifiesForFreeShipping(float $total): bool
{
return $total >= 50.0; // Infection mutates >= to >
}
// Weak test only checks 100 and 10 -> never tests exactly 50.0
var_dump(qualifiesForFreeShipping(100.0)); // true
var_dump(qualifiesForFreeShipping(10.0)); // false
var_dump(qualifiesForFreeShipping(50.0)); // true <-- the boundary the mutant exposes
Killing the Mutant
Add the boundary assertion and the >=-to-> mutant dies: under the mutated code 50.0 > 50.0 is false, so the test fails — exactly what we want. Mutation testing literally tells you which assertions are missing.
<?php
use PHPUnit\Framework\TestCase;
final class ShippingTest extends TestCase
{
public function test_threshold_is_inclusive(): void
{
// Kills the >= -> > mutant
self::assertTrue(qualifiesForFreeShipping(50.0));
}
}
Configuration: infection.json
Infection is driven by infection.json5. You declare which directories to mutate, where logs go, and minimum score thresholds for CI to pass. Point source.directories at your production code only — never your tests.
{
"source": {
"directories": ["src"]
},
"logs": {
"text": "build/infection.log",
"html": "build/infection.html"
},
"mutators": {
"@default": true
},
"minMsi": 80,
"minCoveredMsi": 90
}The MSI Metrics
Infection reports the Mutation Score Indicator:
- MSI = killed / total mutants. Penalized by uncovered code (those mutants can't be killed).
- Covered MSI = killed / mutants on covered lines. Measures how good your assertions are where you do test.
- Mutation Code Coverage = how much code Infection could mutate at all.
A high line coverage but low Covered MSI is the classic signal of assertion-poor tests.
Running Infection Efficiently
Mutation testing is expensive — it re-runs the suite once per mutant. Two big speedups: run tests in parallel with --threads, and only mutate code touched by your current branch using Git diff filtering, ideal for CI on pull requests.
vendor/bin/infection --threads=max --git-diff-lines --git-diff-base=origin/mainReading the Survivors
The value is in the diff Infection prints for each survived mutant. It shows the exact line and the change your tests failed to detect. Treat survivors as a to-do list: either add the missing assertion, or recognize the mutant is harmless (an equivalent mutant).
- return $total >= 50.0;
+ return $total > 50.0;
# Mutant survived: no test asserts the inclusive boundary (total === 50.0)Equivalent Mutants and Ignoring
Some mutants are equivalent — they change the code but not its observable behavior, so no test could ever kill them. These can't be killed and drag MSI down unfairly. Suppress known-false-positive mutators on specific code rather than gaming tests to chase them.
{
"mutators": {
"@default": true,
"Plus": {
"ignore": ["App\\Math\\Statistics::variance"]
}
}
}Where Mutation Testing Pays Off
Because it is slow, target it well:
- Apply to core domain logic — pricing, permissions, calculations — where silent bugs are costly.
- Gate PRs on covered MSI of changed lines, not the whole repo.
- Don't chase 100% globally; diminishing returns and equivalent mutants make that wasteful.
- Use it to find weak assertions, then fix the tests — the score is a means, not the goal.
It Needs Coverage Data to Be Fast
Infection only mutates lines your tests actually cover, so it reuses your test runner's coverage. With Xdebug this is slow; pcov is dramatically faster for line coverage and is the recommended driver for mutation runs. Infection can also generate the coverage itself or consume a coverage report you already produced in CI.
# Faster mutation runs: use pcov instead of Xdebug for coverage
php -d pcov.enabled=1 vendor/bin/infection --threads=max
# Or reuse coverage already generated by your PHPUnit step:
vendor/bin/infection --coverage=build/coverage --skip-initial-testsQuick Check
What does a surviving mutant tell you?
Recap
You learned to measure test quality, not just quantity:
- Mutation testing injects tiny faults (mutants); killed = caught, survived = a gap.
- Coverage shows execution; Covered MSI shows assertion strength.
- Configure via
infection.json5with MSI thresholds; run with--threadsand Git-diff filtering for speed. - Survivors are a to-do list; watch for equivalent mutants and ignore them deliberately.
- Target core logic and gate PRs on changed-line MSI rather than chasing a global 100%.
Frequently asked questions
Is the “Mutation Testing with Infection” lesson free?
Yes — the full text of “Mutation Testing with Infection” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.
What will I learn in “Mutation Testing with Infection”?
Measure how good your tests really are. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start PHP Academy?
No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Mutation Testing with Infection” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this PHP Academy lesson?
Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- The Test-Driven Development Workflow
- Mocking and Stubbing with Mockery
- Integration and Functional Testing
- Mutation Testing with Infection