0

Mutation testing with Infection PHP

Advanced5 min read·php-14-011

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:

  • GreaterThanGreaterThanOrEqual: $x > 5$x >= 5
  • Return_: Replace return with null or 0
  • Arithmetic: +-, */
  • LogicalAnd: &&||

Code Example

bash
# 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 mutant
text
ESCAPED 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
<?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
}