Mutation Testing with Infection PHP: Beyond 100% Coverage
You have a project with 100% Code Coverage. Boss is happy, team is happy, CI is green. But do those tests actually catch bugs? Or are they just executing lines of code without asserting anything important?
This is where Mutation Testing comes in. In the PHP ecosystem, the most famous tool is Infection.
What is Mutation Testing?
The principle is simple:
- The tool creates "mutant" versions of your production code.
- Example: changing
+to-,truetofalse,>to>=, deleting a line of code... - Then, it runs your tests against that mutated code.
- If your tests still Green (Pass) -> Means Mutant Escaped. Your test is weak; the code changed behavior but the test didn't notice.
- If your tests Red (Fail) -> Means Mutant Killed. Your test is good; it detected the anomaly.
Our goal is to "Kill" as many Mutants as possible (high MSI - Mutation Score Indicator).
Installing Infection in Laravel
Infection works best when you already have a PHPUnit or Pest foundation.
composer require --dev infection/infection
Initialize configuration:
./vendor/bin/infection --init
It will create an infection.json.dist file.
Real World Example
Suppose you have discount logic:
public function isEligibleForDiscount(User $user): bool
{
return $user->age >= 18 && $user->hasSubscription();
}
And you write a (poor quality) test like this:
// Only one test case
it('checks discount eligibility', function () {
$user = User::factory()->create(['age' => 20, 'subscribed' => true]);
expect($this->service->isEligibleForDiscount($user))->toBeTrue();
});
This test passes and covers 100% of the lines.
When Infection runs:
-
It creates a mutant changing
>=to>:return $user->age > 18 ...-> With a 20-year-old user, this mutant still returnstrue. Test Passes. -> Mutant Escaped. Reason: We lack a boundary test case at value 18. -
It creates a mutant changing
&&to||:return ... || ...-> Test Passes. -> Mutant Escaped.
Fixing It
Infection will show you exactly which line escaped and what the mutant was. To "kill" these mutants, you must write more test cases:
it('allows exactly 18', function() { ... }); // Kill mutant > to >=
it('denies under 18', function() { ... });
it('requires subscription', function() { ... }); // Kill mutant && to ||
Running Infection
Since Mutation Testing runs the test suite many times (once per mutant), it is very slow.
- Run only for changed files (git diff).
- Or run periodically (nightly build) instead of every commit.
Command to run only for modified files:
./vendor/bin/infection --git-diff-filter=AM
Or run in parallel for speed:
./vendor/bin/infection --threads=4
Conclusion
Code Coverage only tells you if a line of code has been executed. Mutation Score tells you if a line of code has been verified thoroughly.
If you are building Critical systems (Payment, Banking, Healthcare) with Laravel, Infection PHP is a mandatory tool in your CI pipeline to ensure absolute confidence in code quality.