Mutation Testing in PHP with Infection

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/infection

Then initialize it:

./vendor/bin/infection --init

This 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=4

The --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 > 100 to >= 100 — if your test only passes 101, 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=80

Start 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.html

The 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:

  1. Run Infection on your domain/business logic only. Exclude framework boilerplate.
  2. Set a CI gate at a reasonable threshold (70–80% MSI for covered code).
  3. Review surviving mutants weekly. Fix the high-value ones — logic operators, boundary conditions, return values.
  4. 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.

Read more

Start now free