0Pricing
PHP Academy · Lesson

Mutation Testing with Infection

Measure how good your tests really are.

Mutation Testing with Infection is a free PHP Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Coverage Lies

100% line coverage feels reassuring, but it only proves your tests executed the code — not that they would catch a bug in it. A test can run a line and assert nothing meaningful. Mutation testing measures the real thing: would your tests fail if the code were subtly broken? Infection is the standard PHP tool for this.

composer require --dev infection/infection

The Core Idea: Mutants

Infection takes your covered code and introduces tiny faults — mutants. It flips > to >=, + to -, && to ||, removes a return, etc. Then it re-runs your tests against each mutant:

  • If a test fails → the mutant is killed (good — your tests caught the fault).
  • If all tests pass → the mutant survived (bad — a real bug here would go unnoticed).

A Survivor in Action

Consider this function and a weak test. Infection would mutate >= to >. If no test checks the exact boundary (amount equals threshold), that mutant survives — revealing an untested edge.

<?php
function qualifiesForFreeShipping(float $total): bool
{
    return $total >= 50.0;   // Infection mutates >= to >
}

// Weak test only checks 100 and 10 -> never tests exactly 50.0
var_dump(qualifiesForFreeShipping(100.0)); // true
var_dump(qualifiesForFreeShipping(10.0));  // false
var_dump(qualifiesForFreeShipping(50.0));  // true  <-- the boundary the mutant exposes

Killing the Mutant

Add the boundary assertion and the >=-to-> mutant dies: under the mutated code 50.0 > 50.0 is false, so the test fails — exactly what we want. Mutation testing literally tells you which assertions are missing.

<?php
use PHPUnit\Framework\TestCase;

final class ShippingTest extends TestCase
{
    public function test_threshold_is_inclusive(): void
    {
        // Kills the >= -> > mutant
        self::assertTrue(qualifiesForFreeShipping(50.0));
    }
}

Configuration: infection.json

Infection is driven by infection.json5. You declare which directories to mutate, where logs go, and minimum score thresholds for CI to pass. Point source.directories at your production code only — never your tests.

{
  "source": {
    "directories": ["src"]
  },
  "logs": {
    "text": "build/infection.log",
    "html": "build/infection.html"
  },
  "mutators": {
    "@default": true
  },
  "minMsi": 80,
  "minCoveredMsi": 90
}

The MSI Metrics

Infection reports the Mutation Score Indicator:

  • MSI = killed / total mutants. Penalized by uncovered code (those mutants can't be killed).
  • Covered MSI = killed / mutants on covered lines. Measures how good your assertions are where you do test.
  • Mutation Code Coverage = how much code Infection could mutate at all.

A high line coverage but low Covered MSI is the classic signal of assertion-poor tests.

Running Infection Efficiently

Mutation testing is expensive — it re-runs the suite once per mutant. Two big speedups: run tests in parallel with --threads, and only mutate code touched by your current branch using Git diff filtering, ideal for CI on pull requests.

vendor/bin/infection --threads=max --git-diff-lines --git-diff-base=origin/main

Reading the Survivors

The value is in the diff Infection prints for each survived mutant. It shows the exact line and the change your tests failed to detect. Treat survivors as a to-do list: either add the missing assertion, or recognize the mutant is harmless (an equivalent mutant).

- return $total >= 50.0;
+ return $total > 50.0;

# Mutant survived: no test asserts the inclusive boundary (total === 50.0)

Equivalent Mutants and Ignoring

Some mutants are equivalent — they change the code but not its observable behavior, so no test could ever kill them. These can't be killed and drag MSI down unfairly. Suppress known-false-positive mutators on specific code rather than gaming tests to chase them.

{
  "mutators": {
    "@default": true,
    "Plus": {
      "ignore": ["App\\Math\\Statistics::variance"]
    }
  }
}

Where Mutation Testing Pays Off

Because it is slow, target it well:

  • Apply to core domain logic — pricing, permissions, calculations — where silent bugs are costly.
  • Gate PRs on covered MSI of changed lines, not the whole repo.
  • Don't chase 100% globally; diminishing returns and equivalent mutants make that wasteful.
  • Use it to find weak assertions, then fix the tests — the score is a means, not the goal.

It Needs Coverage Data to Be Fast

Infection only mutates lines your tests actually cover, so it reuses your test runner's coverage. With Xdebug this is slow; pcov is dramatically faster for line coverage and is the recommended driver for mutation runs. Infection can also generate the coverage itself or consume a coverage report you already produced in CI.

# Faster mutation runs: use pcov instead of Xdebug for coverage
php -d pcov.enabled=1 vendor/bin/infection --threads=max

# Or reuse coverage already generated by your PHPUnit step:
vendor/bin/infection --coverage=build/coverage --skip-initial-tests

Quick Check

What does a surviving mutant tell you?

Recap

You learned to measure test quality, not just quantity:

  • Mutation testing injects tiny faults (mutants); killed = caught, survived = a gap.
  • Coverage shows execution; Covered MSI shows assertion strength.
  • Configure via infection.json5 with MSI thresholds; run with --threads and Git-diff filtering for speed.
  • Survivors are a to-do list; watch for equivalent mutants and ignore them deliberately.
  • Target core logic and gate PRs on changed-line MSI rather than chasing a global 100%.

Frequently asked questions

Is the “Mutation Testing with Infection” lesson free?

Yes — the full text of “Mutation Testing with Infection” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.

What will I learn in “Mutation Testing with Infection”?

Measure how good your tests really are. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start PHP Academy?

No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Mutation Testing with Infection” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this PHP Academy lesson?

Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. The Test-Driven Development Workflow
  2. Mocking and Stubbing with Mockery
  3. Integration and Functional Testing
  4. Mutation Testing with Infection
← Back to PHP Academy