DEV Community

Cover image for Infection in Practice: A Mutation Score Your Team Won't Game
Gabriel Anhaia
Gabriel Anhaia

Posted on

Infection in Practice: A Mutation Score Your Team Won't Game


Your CI badge says 100% line coverage. A pricing bug still shipped last week. Both of those things are true at the same time, and once you see why, you stop trusting the coverage number.

Line coverage answers one question: did a test execute this line? It says nothing about whether a test would notice if the line were wrong. You can run every branch of a method and assert nothing about the result. The line lights up green. The bug walks straight through.

Mutation testing closes that gap. It breaks your code on purpose and checks whether your tests complain. If they don't, the test was decoration. In PHP, the tool for this is Infection. The metric it produces resists gaming. Here is how to read it and where to set the floor.

What Infection actually does

Infection parses your source into an AST, applies small changes called mutators, and reruns the tests that cover each mutated line. Every change produces a mutant. Then it counts.

  • If a test fails on the mutated code, the mutant is killed. Good. Your tests noticed.
  • If every test still passes, the mutant escaped. Your tests did not notice the code changed behavior.

Install it as a dev dependency:

composer require --dev infection/infection
Enter fullscreen mode Exit fullscreen mode

Infection needs code coverage to know which tests touch which lines. Use PCOV or Xdebug. PCOV is much faster and worth setting up for this alone.

A minimal infection.json5 at the repo root:

{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": ["src"]
    },
    "mutators": {
        "@default": true
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it:

vendor/bin/infection --threads=max
Enter fullscreen mode Exit fullscreen mode

MSI vs Covered MSI

Infection reports two headline numbers, and the difference between them is the whole story.

MSI (Mutation Score Indicator) is killed mutants over all mutants generated. Code with no tests at all still produces mutants, and those escape, so MSI drops. MSI measures breadth: how much of your code is both covered and asserted.

Covered Code MSI is killed mutants over only the mutants that at least one test executed. It ignores code no test touches. Covered MSI measures depth: where you do have tests, do they assert anything real.

The two numbers together diagnose the problem for you:

  • Low MSI, high Covered MSI: you have untested code. The tests you wrote are good; you just have not written enough of them.
  • High MSI and high Covered MSI: healthy. Broad coverage, sharp assertions.
  • Low on both: your assertions are weak even where tests run. This is the dangerous one, because line coverage hides it.

That last case is why the mutation score is hard to game. To bump line coverage, you call the code. To kill a mutant, a test has to fail when behavior changes, which means an assertion that actually inspects the result. You cannot fake a killed mutant with an empty test body.

The mutators that matter

Infection ships dozens of mutators. A handful catch the bugs you actually write.

Conditional boundaries. > becomes >=, < becomes <=. Off-by-one errors on thresholds are the most common escaped mutant in business logic, and they map straight to real pricing and limit bugs.

Negated conditionals. === becomes !==, < becomes >=. If flipping a condition doesn't break a test, that branch is untested in practice.

Return values. return true becomes return false. A method whose return value nothing asserts on is a method nobody is really testing.

Arithmetic. + becomes -, * becomes /. Money math lives here.

Logical operators. && becomes ||. Guard clauses with multiple conditions hide untested combinations behind a single green line.

Start with @default. It excludes the noisy mutators (like the ones that mutate array internals) and keeps the set that correlates with real defects.

Reading an escaped mutant as a test gap

Here is a discount rule with a boundary that looks fine:

final class BulkDiscount
{
    public function rateFor(int $quantity): float
    {
        if ($quantity > 100) {
            return 0.10;
        }

        return 0.0;
    }
}
Enter fullscreen mode Exit fullscreen mode

And a test that gives you 100% line coverage:

public function test_it_applies_the_bulk_rate(): void
{
    $discount = new BulkDiscount();

    self::assertSame(0.10, $discount->rateFor(150));
    self::assertSame(0.0, $discount->rateFor(50));
}
Enter fullscreen mode Exit fullscreen mode

Every line runs. PHPUnit is green. Infection is not:

--- Original
+++ New
@@ @@
-        if ($quantity > 100) {
+        if ($quantity >= 100) {
Enter fullscreen mode Exit fullscreen mode

That mutant escaped. Swapping > for >= changed the answer at exactly quantity = 100, and no test checks that value. The escaped mutant is pointing at a precise gap: the boundary itself. That is the number a customer ordering exactly 100 units hits.

Kill it by testing the edge:

public function test_the_bulk_rate_starts_above_100(): void
{
    $discount = new BulkDiscount();

    self::assertSame(0.0, $discount->rateFor(100));
    self::assertSame(0.10, $discount->rateFor(101));
}
Enter fullscreen mode Exit fullscreen mode

Now flipping the operator breaks a test. The mutant dies. You did not chase a coverage percentage; you fixed the specific hole the tool found. That is the loop: each escaped mutant is a sentence that reads "here is a behavior change nobody would catch."

Setting a threshold without theater

Two config keys turn Infection into a CI gate:

{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": ["src"]
    },
    "mutators": {
        "@default": true
    },
    "minMsi": 70,
    "minCoveredMsi": 85
}
Enter fullscreen mode Exit fullscreen mode

Infection exits non-zero when either falls below its floor. The split targets are deliberate. minCoveredMsi is the honest number: where your tests run, they should kill 85% of mutants. Set it high, because it measures the thing you care about. minMsi is lower, because it also counts code you have consciously chosen not to test yet.

The theater trap is chasing 100%. Some escaped mutants are equivalent mutants: the code changed but behaves identically, so no test can ever kill them. Forcing 100% pushes people to write assertions that pin implementation details or to sprinkle @infection-ignore-all until the number goes up. Both make the suite worse.

Set a floor that ratchets up over time and never down. When someone raises minCoveredMsi, it is a deliberate decision, the same way you would bump a static-analysis level.

Running it fast in CI

Full-suite mutation testing is expensive: it reruns tests once per mutant. On a pull request you do not need the whole codebase. You need the lines that changed.

Infection has a diff mode built for exactly this:

vendor/bin/infection \
    --threads=max \
    --git-diff-lines \
    --git-diff-base=origin/main \
    --logger-github \
    --min-covered-msi=85
Enter fullscreen mode Exit fullscreen mode

--git-diff-lines mutates only the lines the branch touched. --git-diff-base sets what you diff against. --logger-github posts annotations on the exact lines with escaped mutants, so reviewers see the gap inline on the PR. The full-codebase run stays on a nightly schedule where the runtime does not block anyone.

Feed it existing coverage instead of regenerating it when your pipeline already runs PHPUnit with coverage:

vendor/bin/infection \
    --threads=max \
    --coverage=build/coverage \
    --skip-initial-tests
Enter fullscreen mode Exit fullscreen mode

That skips Infection's own initial test run and reuses the report you already paid for.

The point

Line coverage tells you code ran. Mutation score tells you a test would have caught the code being wrong. The second one is the property you actually wanted the whole time, and it is the one a team cannot inflate by writing hollow tests. Track Covered MSI as your assertion-quality number, watch the gap to plain MSI as your coverage-breadth signal, and treat every escaped mutant as a named, reproducible test gap rather than a percentage to grind.

Where mutation testing pays off most is the domain layer: the pricing rules, the state transitions, the invariants that a boundary bug turns into a support ticket. That logic is worth isolating from your framework and your database so tests can hit it directly, fast, with no bootstrapping in the way. Keeping the core testable at the unit level is exactly the structure Decoupled PHP argues for: a domain you can mutate a thousand times a second because nothing external is in the path.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)