Mutation Testing with Infection PHP: Beyond 100% Coverage

· 3 min read

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:

  1. The tool creates "mutant" versions of your production code.
  2. Example: changing + to -, true to false, > to >=, deleting a line of code...
  3. Then, it runs your tests against that mutated code.
  4. If your tests still Green (Pass) -> Means Mutant Escaped. Your test is weak; the code changed behavior but the test didn't notice.
  5. 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:

  1. It creates a mutant changing >= to >: return $user->age > 18 ... -> With a 20-year-old user, this mutant still returns true. Test Passes. -> Mutant Escaped. Reason: We lack a boundary test case at value 18.

  2. 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.

Comments