Measure Test Quality, Not Just Coverage: Pest Mutation Testing

Pest mutation testing shows whether your tests would catch a real bug, not just run the line. Set up Xdebug or PCOV, run --mutate, and read the score.

Steven Richardson
Steven Richardson
· 7 min read

You can have 100% line coverage and still ship a bug that any decent test should have caught. Coverage tells you a line executed during the test run. It says nothing about whether you actually asserted on what that line produced. Pest mutation testing closes that gap by changing your code on purpose and checking whether your tests notice.

Coverage vs mutation testing#

Here's a worked example. A small pricing class with a bulk discount that kicks in at ten units:

class PriceCalculator
{
    public function discountedTotal(int $cents, int $quantity): int
    {
        $total = $cents * $quantity;

        // bulk discount applies from 10 units upward
        if ($quantity >= 10) {
            return (int) round($total * 0.9);
        }

        return $total;
    }
}

And a test that hits every line:

covers(PriceCalculator::class);

it('applies a bulk discount', function () {
    $total = (new PriceCalculator)->discountedTotal(1000, 10);

    expect($total)->toBeInt(); // executes the branch, asserts nothing real
});

Run coverage and this looks perfect. The $quantity >= 10 branch ran, the discount line ran, green across the board. But the only assertion is toBeInt(). Change the discount from 10% to 50% and this test still passes. Delete the discount entirely and it still passes. The line is covered; the behaviour is untested.

This is exactly the blind spot Pest's arch() helper for architectural rules and snapshot testing for API responses can't see either — they check structure and shape, not whether your assertions are load-bearing. Mutation testing is the tool that measures the assertions themselves.

Mutation testing works by introducing small changes — mutants — into your source, then re-running the tests that cover that code. Flip >= to >. Change return $total to return 0. Swap * for +. If a test fails, the mutant is tested (killed): your suite caught the change. If every test still passes, the mutant is untested (survived): nothing was actually asserting on that logic.

A quick vocabulary note, because the wider mutation testing PHP world borrows terms from Infection. Infection talks about "killed" and "escaped" mutants and reports an MSI (Mutation Score Indicator). Pest reports the same idea but prints tested / untested and a plain Score. Same concept, different label.

Set up Xdebug or PCOV#

Mutation testing needs a coverage driver to know which tests cover which lines. Pest requires Xdebug 3.0+ or PCOV — without one, --mutate has nothing to work with.

Check what you have:

php -m | grep -iE 'xdebug|pcov'

PCOV is dramatically faster than Xdebug for coverage and is my default in CI. Install it with PECL:

pecl install pcov

Then make sure coverage is actually enabled when you run. With Xdebug you need coverage mode on:

XDEBUG_MODE=coverage ./vendor/bin/pest --mutate

The mutation plugin itself ships inside Pest 3 and later — there's no separate composer require beyond Pest. Pest 3 introduced the --mutate flag as a first-class feature, and the API is unchanged in Pest 4, so everything here works the same whether you're on 3.x or the current 4.x release.

Run your first Pest mutation testing pass#

Laravel mutation testing follows the same two-step rhythm every time: scope a test to the class it covers, then run with --mutate. The --parallel flag spreads the work across cores and is worth it from day one:

./vendor/bin/pest --mutate --parallel

Against the weak test above, Pest mutates the comparison and reports a survivor:

  UNTESTED  app/Services/PriceCalculator.php  > Line 10: GreaterThanOrEqual - ID: 9f3c1a7b

       if ($quantity >= 10) {
  -        if ($quantity >= 10) {
  +        if ($quantity > 10) {

  Mutations: 1 untested
  Score:     0.00%

The exact mutator name and ID will differ in your run, but the signal is the diff: Pest changed >= to >, which means at exactly 10 units the discount no longer applies — and your test didn't care. Fix the assertion so it pins the actual value:

it('applies a 10% bulk discount at exactly 10 units', function () {
    $total = (new PriceCalculator)->discountedTotal(1000, 10);

    expect($total)->toBe(9000); // 1000 * 10 = 10000, minus 10% = 9000
});

Now flipping >= to > makes quantity 10 skip the discount and return 10000, the assertion fails, and the mutant is killed:

  Mutations: 1 tested
  Score:     100.00%

That one assertion change is the entire point of mutation testing php: it forced a test that proves the boundary, not just one that runs the line.

Scope and read your Pest mutation testing score#

The covers() helper is what keeps a run sane. It tells Pest which class a test exercises so mutation only targets relevant code:

covers(PriceCalculator::class);

mutates() does the same job for mutation purposes; the difference is that covers() also filters your code coverage report. If you don't scope anything, Pest has no idea what to mutate. You can widen the net with --class for a namespace, or --everything to mutate the whole app — but --everything is genuinely resource-intensive and should be paired with --covered-only:

./vendor/bin/pest --mutate --everything --covered-only --parallel

The Score is killed mutants over total mutants. 100% means every mutation was caught. Anything lower is a list of specific, addressable weak spots — not a vague feeling that "the tests could be better". To turn that pest covers() mutation score into a gate, set a floor and let CI fail below it:

./vendor/bin/pest --mutate --min=80

If the score drops under 80, the command exits non-zero and the build goes red.

Gotchas and Edge Cases#

Xdebug makes it crawl. Mutation testing re-runs your suite once per surviving mutant, so a slow coverage driver hurts badly. Use PCOV, lean on --parallel, and scope with covers() rather than mutating everything.

Don't gate every PR on a full run. A whole-app mutation pass is too slow for the critical path of code review. Run it scoped on changed files for PRs and a fuller pass nightly. If your normal suite is already slow, fix that first — splitting Pest across parallel CI runners with sharding gives you the headroom to afford mutation testing at all.

Equivalent mutants exist. Some mutations don't change observable behaviour — they can never be killed and will quietly cap your score below 100%. A classic case is mutating a value that's immediately overwritten. Don't chase them. Mark the line with a comment so Pest skips it:

$timeout = 30; // @pest-mutate-ignore

100% is rarely the right target. Treat mutation score the way you'd treat a smoke alarm, not a leaderboard. A realistic --min on your core domain logic (pricing, auth, permissions) is far more valuable than a vanity 100% across boilerplate.

Wrapping Up#

Don't try to mutation-test the whole app on day one. Pick one class where a silent bug would actually hurt, add covers(), run ./vendor/bin/pest --mutate --parallel, and kill the untested mutants one by one. Once it's green, set a --min floor in CI so the quality you just bought can't quietly erode.

From here, type-safe Pest tests with PestStan and PHPStan generics is the natural next layer of test-suite confidence, and the complete Laravel developer toolchain for 2026 shows where mutation testing fits alongside the rest of your quality tooling.

FAQ#

What is mutation testing in Pest?

Mutation testing is a technique that measures the quality of your tests rather than how much code they run. Pest introduces small changes (mutants) into your source — flipping operators, changing return values, altering conditionals — then re-runs the tests covering that code. If a test fails, the mutant was caught ("tested"); if every test still passes, the mutant survived ("untested"), which means nothing actually asserts on that logic.

How do I run mutation tests in Pest 3?

Scope a test to the class it covers with the covers() helper, then run ./vendor/bin/pest --mutate, ideally adding --parallel to spread the work across cores. The command is identical in Pest 3 and Pest 4 — mutation testing shipped in Pest 3 and the API hasn't changed. Add --min=80 to make Pest fail the run when the score drops below your threshold, which is how you wire it into CI.

Do I need Xdebug or PCOV for Pest mutation testing?

Yes. Pest needs a coverage driver to know which tests cover which lines, so you must have Xdebug 3.0+ or PCOV installed and coverage enabled. PCOV is significantly faster than Xdebug for this work, so it's the better choice in CI. With Xdebug you also need to run in coverage mode, for example by setting XDEBUG_MODE=coverage before the command.

What is a good mutation score?

A mutation score is killed mutants divided by total mutants, and while 100% sounds ideal, it's rarely the right target for a whole codebase because equivalent mutants can cap it artificially. A more useful approach is to set a high threshold on your critical domain logic — pricing, authentication, permissions — and a more relaxed floor elsewhere. Pick a --min you can actually hold green, then raise it over time as you strengthen the suite.

Is Pest mutation testing too slow for CI?

A full-app mutation run is too slow to sit on the critical path of every pull request, but you don't have to run it that way. Scope mutation to changed files or critical classes for PRs and run a fuller pass on a nightly schedule. Using PCOV, the --parallel flag, and tight covers() scoping keeps runtimes manageable, and a fast base test suite makes the whole thing affordable.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.