DEV Community

Cover image for Mutation Testing in PHP: Beyond Code Coverage with Infection Framework
Patoliya Infotech
Patoliya Infotech

Posted on

Mutation Testing in PHP: Beyond Code Coverage with Infection Framework

Your CI pipeline is green. Code coverage sits at 100%. You ship the feature.

Three days later, a bug report lands in your inbox, one that your tests should have caught.

Sound familiar? This is the dirty secret of code coverage metrics: they measure what lines ran, not whether your tests actually verify anything meaningful. You can hit 100% coverage with tests that assert nothing.

Mutation testing fixes this. It doesn't ask "did this code run?" it asks "would your tests notice if this code was wrong?"

PHP remains one of the most widely used server-side languages, and as PHP applications grow in complexity, powering everything from APIs to full-scale SaaS platforms, the cost of weak test suites compounds fast.

In this post, you'll learn:

  • Why code coverage is a flawed proxy for test quality
  • What mutation testing actually measures
  • How to use Infection, the leading PHP mutation testing framework
  • How to interpret results and improve your test suite

The Problem with Code Coverage

Code coverage tells you which lines, branches, or paths were executed during your test run. That's it. It says nothing about what you verified.

If you've spent any time thinking about software testing strategies, you've likely encountered this gap: teams celebrate high coverage numbers while their production defect rates tell a different story.

Here's a classic trap. Consider this simple class:

class Discount
{
    public function calculate(float $price, int $percentage): float
    {
        if ($percentage < 0 || $percentage > 100) {
            throw new \InvalidArgumentException('Percentage must be between 0 and 100');
        }

        return $price - ($price * $percentage / 100);
    }
}
Enter fullscreen mode Exit fullscreen mode

And here's a test that gives you 100% coverage:

class DiscountTest extends TestCase
{
    public function test_calculate_runs(): void
    {
        $discount = new Discount();
        $result = $discount->calculate(100.0, 20);

        // Look - we called it. Coverage: 100%.
        $this->assertIsFloat($result);
    }
}
Enter fullscreen mode Exit fullscreen mode

Every line executed. PHPUnit reports 100%. But this test is nearly useless. It doesn't verify the value of the result. If your formula had a bug, say $price + ($price * $percentage / 100), this test would still pass.

Coverage shows you that code was touched. Mutation testing shows you that your tests would actually catch a defect.

What is Mutation Testing?

Mutation testing works by making small, deliberate code changes, called mutants, and then running your test suite against each one.

The logic is simple: if you change > to >= in production code and your tests still pass, your tests aren't really protecting that boundary condition. This is one of several advanced techniques covered in a broader guide to software testing types, from unit and integration tests all the way to security and performance testing.

Key Terms

Mutant, A copy of your source code with one small syntactic change applied (e.g., + becomes -, true becomes false, > becomes >=).

Killed mutant, A mutant your tests detected. At least one test failed when running against the mutated code. This is what you want.

Survived mutant, A mutant your tests didn't catch. The test suite passed even though the code was wrong. This is a gap in your coverage.

Mutation Score Indicator (MSI), The percentage of mutants your tests killed. Higher is better, but 100% isn't always the goal.

A Real Example

Original code:

public function isEligibleForDiscount(int $orderCount): bool
{
    return $orderCount > 5;
}
Enter fullscreen mode Exit fullscreen mode

Infection might generate this mutant:

// Mutant: > changed to >=
public function isEligibleForDiscount(int $orderCount): bool
{
    return $orderCount >= 5;
}
Enter fullscreen mode Exit fullscreen mode

If your test only checks isEligibleForDiscount(10) === true, both the original and mutant return true, the mutant survives. Your test never exercised the boundary at 5 vs 6.

A better test that kills this mutant:

$this->assertFalse($this->service->isEligibleForDiscount(5));
$this->assertTrue($this->service->isEligibleForDiscount(6));
Enter fullscreen mode Exit fullscreen mode

Now the mutant is killed, because isEligibleForDiscount(5) returns true with >=, and your test expects false.

Introducing Infection

Infection is the de facto mutation testing framework for PHP. It integrates with PHPUnit and Pest, supports parallel execution, and plays nicely with CI pipelines.

Why Infection specifically?

  • PHPUnit/Pest integration, runs your existing test suite, no rewrites needed
  • MSI thresholds, fail CI builds if mutation score drops below a configured threshold
  • Parallel execution, runs mutants concurrently to cut execution time
  • HTML/text/JSON reports, flexible output for local review or CI dashboards
  • Active maintenance, regularly updated with new mutators and PHP version support

It ships with over 60 mutators covering arithmetic operators, logical operators, comparisons, return values, and more.

Setting Up Infection (Step-by-Step)

1. Installation

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

Or download the PHAR if you prefer:

wget https://github.com/infection/infection/releases/latest/download/infection.phar
chmod +x infection.phar
Enter fullscreen mode Exit fullscreen mode

2. Basic Configuration

Create infection.json in your project root:

{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": ["src"]
    },
    "logs": {
        "text": "infection-log.txt",
        "html": "infection-report.html"
    },
    "mutators": {
        "@default": true
    },
    "minMsi": 70,
    "minCoveredMsi": 80,
    "testFramework": "phpunit",
    "testFrameworkOptions": "--stop-on-failure"
}
Enter fullscreen mode Exit fullscreen mode

minMsi, minimum mutation score across all code. Build fails if it drops below this.

minCoveredMsi, minimum score for covered code only (more useful in practice).

3. Running Infection

vendor/bin/infection
Enter fullscreen mode Exit fullscreen mode

Infection will:

  1. Run your test suite once to collect code coverage
  2. Generate mutants for each covered file
  3. Run tests against each mutant
  4. Report which mutants were killed vs survived

4. Reading the Output

171 mutations were generated:
     143 mutants were killed
       4 mutants were not covered by tests
      24 mutants survived

Metrics:
         Mutation Score Indicator (MSI): 84%
Mutation Code Coverage: 98%
Covered Code MSI: 86%
Enter fullscreen mode Exit fullscreen mode

Those 24 survived mutants are your real test gaps, not missing coverage, but missing assertions.

Improving Tests with Mutation Testing

Here's a real workflow. Start with this code and test:

// src/PricingService.php
class PricingService
{
    public function applyTax(float $price, float $taxRate): float
    {
        return $price * (1 + $taxRate / 100);
    }
}
Enter fullscreen mode Exit fullscreen mode
// tests/PricingServiceTest.php - weak test
public function test_apply_tax(): void
{
    $service = new PricingService();
    $result = $service->applyTax(100.0, 10.0);

    $this->assertNotNull($result); // just checks it returned something
}
Enter fullscreen mode Exit fullscreen mode

Infection generates this mutant:

// Mutant: + changed to -
return $price * (1 - $taxRate / 100);
Enter fullscreen mode Exit fullscreen mode

The test survives. assertNotNull doesn't care about the value.

Now improve the test:

// tests/PricingServiceTest.php - strong test
public function test_apply_tax_adds_correct_percentage(): void
{
    $service = new PricingService();

    $this->assertSame(110.0, $service->applyTax(100.0, 10.0));
    $this->assertSame(100.0, $service->applyTax(100.0, 0.0));
    $this->assertSame(120.0, $service->applyTax(100.0, 20.0));
}
Enter fullscreen mode Exit fullscreen mode

Now the +- mutant is killed - because $price * (1 - 0.1) returns 90.0, not 110.0.

The pattern: weak tests assert existence or type. Strong tests assert specific values, boundaries, and behaviors.

Beyond mutation testing, application security testing follows the same principle - don't just check that an endpoint responds, verify what it actually does with the input it receives.

When (and When Not) to Use Mutation Testing

Use It When:

Critical business logic - pricing engines, permission checks, financial calculations. If a bug here costs money or trust, mutation testing is worth every minute.

Libraries and packages - if other teams or projects depend on your code, a high MSI gives them (and you) confidence.

High-risk refactors - before a large restructure, a good mutation score proves your tests will actually catch regressions.

Be Cautious When:

Early-stage prototypes - interfaces change fast. Spending time improving MSI on code you'll rewrite next week isn't the best ROI. Teams running Agile workflows often defer mutation testing to post-stabilization sprints for exactly this reason.

Very large legacy codebases - running Infection on 500k lines without a strategy will produce thousands of survived mutants and overwhelm the team. Start with one module.

Generated or boilerplate code - getters, setters, DTOs. The cost of mutation testing these rarely pays off.

Don't treat MSI as a vanity metric. A 75% MSI on critical payment logic is more valuable than 95% MSI on a CRUD controller.

Best Practices for Using Infection

Run Infection on CI with thresholds. Integrating mutation testing into a mature CI/CD pipeline is one of the highest-leverage moves you can make for long-term code quality. Add it alongside PHPUnit, set minMsi conservatively at first (e.g., 60%), then raise it over time.

# GitHub Actions example
- name: Run Mutation Tests
  run: vendor/bin/infection --min-msi=70 --min-covered-msi=80
Enter fullscreen mode Exit fullscreen mode

Start with the most critical module, not the whole project. Use the --filter option or scope infection.json to src/Payments before going wide. If your team needs help establishing a DevOps consulting strategy that incorporates quality gates like mutation testing thresholds into automated pipelines, that's a conversation worth having early.

Don't chase 100% MSI. Some mutants are genuinely equivalent - the mutated code behaves identically to the original in practice. Killing every mutant isn't the goal; killing the meaningful ones is.

Combine with code coverage, don't replace it. Coverage finds untested code. Mutation testing finds undertested code. You need both signals.

Review survived mutants as a team. Survived mutants are conversation starters: "Do we care about this boundary?" Sometimes the answer is no - and that's valid. But the conversation is worth having.

Conclusion

Code coverage answers: "Did these lines execute?"

Mutation testing answers: "Would your tests catch a defect here?"

These are fundamentally different questions. A 100% coverage badge with weak assertions gives you false confidence. A 75% mutation score with strong, specific tests gives you a suite you can actually trust.

Infection is mature, fast (especially with parallelism), and integrates cleanly into any PHP project. If you've never run it, start today: pick your most critical service class, run vendor/bin/infection, and look at what survives.

The results might surprise you.

FAQs

What is a good mutation score?

There's no universal answer, but 70–85% MSI is a reasonable target for business-critical code. Don't obsess over 100% - some mutants are equivalent and practically impossible to kill without meaningless tests.

Is mutation testing slow?

It can be. Infection runs your test suite once per mutant, which adds up. Mitigate this by using --threads for parallelism, scoping runs to specific directories, and skipping mutators that aren't relevant to your domain.

Can I use it with Laravel?

Yes. Infection works with any PHPUnit-compatible test suite, which includes Laravel's testing layer. Point source.directories at your app/ folder (or a specific subdirectory) and it works out of the box.

How is mutation testing different from unit testing?

Unit testing is your test suite - Infection uses it. Mutation testing is a quality check on your unit tests. It tells you how effective those tests actually are at catching bugs.

Should I run it on every commit?

Not necessarily. Mutation testing is slower than a normal test run. A common strategy is to run it nightly on CI, or only when files in critical directories change. Use path filtering to keep it fast and targeted.

Top comments (0)