Reading PHPUnit Coverage Correctly: Signal, Not a Vanity Metric
AI generated
@test
assert
PHPUnit · Coverage · Branch Coverage · Quality Assurance
Reading PHPUnit Coverage Correctly
Signal, Not a Vanity Metric

80% line coverage sounds good. But line coverage says nothing about whether the right cases are being tested. A branch that gets executed for one particular input counts as covered, no matter whether the other branch is ever tested. Coverage is a tool, not a goal. Anyone who treats it as a vanity metric ends up optimizing the number instead of the quality.

13 min read Line · Branch · Path · Mutation Testing · Xdebug PHPUnit 10/11 · Xdebug 3 · PCOV · PHP 8.x

1. What coverage really measures, and what it does not

Coverage measures which parts of the production code were executed while the tests ran. It says nothing about whether that execution produced correct results, whether all input variants were checked, or whether the tests contain meaningful assertions. A test that calls a method but asserts nothing raises coverage to 100%, and still provides zero confidence.

This limitation is fundamental and is systematically underestimated in practice. Teams that use coverage targets as a KPI inevitably create incentives to hit those targets with weak tests. The result: high coverage numbers, a fragile test suite, and false confidence. Coverage is most useful as a tool that shows what has not been tested yet, not as proof that the tests are good. Red areas in the coverage report are valid signals of missing tests. Green areas only guarantee that the code was executed.

There are two important drivers for a meaningful coverage analysis. First, use coverage as a gap finder: regularly go through the report for uncovered paths, especially in business logic classes and error handling code. Second, evaluate coverage together with branch coverage to make sure branches are tested in both directions. Only that combination gives a reasonably complete picture.

2. Line coverage: the fastest and weakest metric

Line coverage counts which lines of source code were executed at least once. A line counts as covered as soon as one test executes it, regardless of context or input values. That makes line coverage the easiest metric to reach. A test that runs the happy path of a method usually covers all lines, including lines that are only relevant in the error case, if they happen to sit on the same physical lines as the success code.

The blind spot of line coverage: any line that contains a condition can be covered from the "true" side without the "false" side ever being executed. if ($price > 0) { return $price; } return 0;: if every test passes a positive price, line coverage is 100%. The branch for $price ≤ 0 was never executed. Branch coverage would make that visible.


<?php

declare(strict_types=1);

namespace App\Domain;

/**
 * Calculates discount based on order amount and customer tier.
 * All branches must be tested to detect hidden logic errors.
 */
final class DiscountCalculator
{
    /**
     * Returns the discount percentage for a given order amount and tier.
     * Line coverage: one test with $amount=200, tier='gold' covers all lines.
     * Branch coverage: requires tests for all combinations of conditions.
     */
    public function calculate(float $amount, string $tier): float
    {
        if ($amount <= 0) {
            throw new \InvalidArgumentException('Amount must be positive');
        }

        // Branch 1: premium tier, amount threshold matters
        if ($tier === 'premium') {
            return $amount >= 500 ? 0.20 : 0.10;
        }

        // Branch 2: gold tier, flat discount
        if ($tier === 'gold') {
            return 0.05;
        }

        // Branch 3: default, no discount
        return 0.0;
    }
}

// Test that achieves 100% LINE coverage but misses branches:
final class WeakCoverageTest extends TestCase
{
    /** @test */
    public function calculates_discount(): void
    {
        $calc = new DiscountCalculator();
        // Only tests premium tier with amount >= 500, misses 4 other branches
        $this->assertSame(0.20, $calc->calculate(600, 'premium'));
    }
    // Line coverage: 100%. Branch coverage: about 30%
}

// Test that achieves 100% BRANCH coverage:
final class FullBranchCoverageTest extends TestCase
{
    /**
     * @test
     * @dataProvider discountProvider
     */
    public function calculates_discount_for_all_cases(
        float $amount, string $tier, float $expected
    ): void {
        $this->assertSame($expected, (new DiscountCalculator())->calculate($amount, $tier));
    }

    public static function discountProvider(): array
    {
        return [
            'premium_high'    => [600, 'premium', 0.20],
            'premium_low'     => [200, 'premium', 0.10],
            'gold_any'        => [100, 'gold',    0.05],
            'default_tier'    => [100, 'standard', 0.0],
        ];
    }

    /** @test */
    public function throws_on_non_positive_amount(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        (new DiscountCalculator())->calculate(0, 'gold');
    }
}

3. Branch coverage: checking branches deliberately

Branch coverage (also called decision coverage) counts whether every branch was executed in both possible directions. An if-else has two branches: true and false. A match with five arms has five branches. Branch coverage is satisfied when all of these branches were executed at least once. That is a considerably stronger guarantee than line coverage.

In PHPUnit, branch coverage is captured via Xdebug (with xdebug.mode=coverage enabled) or PCOV. The HTML coverage report shows branches with color markers: green for covered branches, red for uncovered ones. In the text report, branch coverage values appear as a separate column. The configuration in phpunit.xml enables coverage for specific directories via <include> under <source>.


<!-- phpunit.xml: Coverage configuration for PHPUnit 10/11 -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache">

  <testsuites>
    <testsuite name="Unit">
      <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="Integration">
      <directory>tests/Integration</directory>
    </testsuite>
  </testsuites>

  <!-- Source paths for coverage analysis, excludes generated code -->
  <source>
    <include>
      <directory suffix=".php">src</directory>
    </include>
    <exclude>
      <directory>src/Generated</directory>
      <file>src/Kernel.php</file>
    </exclude>
  </source>

  <!-- Coverage output formats -->
  <coverage>
    <report>
      <html outputDirectory="coverage/html" lowUpperBound="50" highLowerBound="90"/>
      <clover outputFile="coverage/clover.xml"/>
      <text outputFile="coverage/coverage.txt" showUncoveredFiles="true"/>
    </report>
  </coverage>

</phpunit>

<!-- Run with: vendor/bin/phpunit --coverage-html coverage/html -->
<!-- Branch coverage requires Xdebug: XDEBUG_MODE=coverage vendor/bin/phpunit -->

4. Path coverage and mutation testing

Path coverage is the strongest and most expensive coverage metric: it counts whether all possible execution paths through a function were tested. A function with three independent conditions has eight possible paths (2³). Reaching 100% path coverage is practically impossible for complex methods, but the concept is valuable because it shows just how underdetermined line coverage and branch coverage really are.

Mutation testing (with Infection for PHP) is the most effective tool for measuring test quality. Infection modifies the production code slightly (changing > to >=, removing return values, inverting conditions) and checks whether the tests catch these mutations. Tests that do not catch mutations are weak: they cover the code but do not really check it. The Mutation Score Indicator (MSI) is a more honest quality metric than line coverage.

5. Generating and reading PHPUnit coverage reports

The HTML coverage report is the most powerful analysis tool. It shows, at file, class and method level, which lines and branches are covered. Colors: green means fully covered, yellow means partially covered (some branches are missing), red means not covered. It is particularly valuable to search for red areas in critical business logic classes, these should be tested with priority.

The Clover XML report is meant for CI integration: tools like SonarQube, Codecov and Coveralls consume this format. It is generated with --coverage-clover coverage/clover.xml. In CI pipelines, --coverage-filter can restrict coverage to changed files to save runtime. With PHPUnit 10+, minimum coverage thresholds can be defined in phpunit.xml that fail the test run if not met.

6. Setting sensible coverage targets

Coverage targets expressed as absolute percentages are problematic: they create incentives to write weak tests. It makes more sense to look at coverage differentiated by code layer. Domain logic and business rules should aim for high branch coverage (80-90%). Framework boilerplate, configuration and generated code should be excluded from coverage analysis. Reaching 60% branch coverage on real production code is worth more than 95% line coverage on a mix of real code and boilerplate.

A practical coverage strategy: define coverage thresholds per directory or module, not globally. New code paths must have tests before they are merged (a coverage ratchet). Existing gaps are captured as tickets and prioritized. The coverage report is analyzed weekly, not as a metric, but as a gap finder.

Coverage Type Measures Strength Blind Spot
Line Coverage Executed lines Fast, easy to understand Ignores branches
Branch Coverage Both sides of every branch Reveals untested paths Combinations of branches
Path Coverage All execution paths Most complete guarantee Exponentially many paths
Mutation Score Detected code mutations Measures test quality directly Runtime intensive
Statement Coverage Executed statements Finer than line coverage Similar weaknesses to line

7. The most common coverage traps

The first and most common coverage trap: tests without assertions. A test that calls a method and then ends without a $this->assert...() raises coverage without any quality guarantee at all. PHPUnit 10 warns about tests without assertions, but the warning is often ignored. The fix: explicitly count with $this->addToAssertionCount(1) when a test intentionally contains no classic assertion (for example exception tests using expectException).

The second trap: getter spam. Many teams reach high coverage by calling getter methods and asserting the result, for fields that contain no business logic at all. That raises the coverage number but does not test any domain behavior. Getters are not a test target. Business logic is the test target. The third trap: excluding difficult code from coverage. Writing @codeCoverageIgnore on classes or methods because they are hard to test is a capitulation to the actual problem. Code that is hard to test is often a design signal: the class has too many dependencies or responsibilities.


<?php

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use App\Domain\DiscountCalculator;

/**
 * Demonstrates the difference between weak and strong coverage tests.
 */
final class CoverageQualityTest extends TestCase
{
    // TRAP 1: Test without assertion, increases coverage, tests nothing
    /** @test */
    public function weak_no_assertion(): void
    {
        $calc = new DiscountCalculator();
        $calc->calculate(100, 'gold'); // coverage: yes, assertion: NONE
    }

    // TRAP 2: Only happy path, 100% line coverage, 30% branch coverage
    /** @test */
    public function weak_happy_path_only(): void
    {
        $calc = new DiscountCalculator();
        $result = $calc->calculate(600, 'premium');
        $this->assertSame(0.20, $result);
        // Misses: premium <500, gold, default, negative amount
    }

    // STRONG: All branches explicitly covered
    /** @test */
    public function strong_premium_below_threshold(): void
    {
        $this->assertSame(0.10, (new DiscountCalculator())->calculate(200, 'premium'));
    }

    /** @test */
    public function strong_gold_tier_flat_discount(): void
    {
        $this->assertSame(0.05, (new DiscountCalculator())->calculate(1000, 'gold'));
    }

    /** @test */
    public function strong_unknown_tier_returns_zero(): void
    {
        $this->assertSame(0.0, (new DiscountCalculator())->calculate(100, 'bronze'));
    }

    /** @test */
    public function strong_zero_amount_throws(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Amount must be positive');
        (new DiscountCalculator())->calculate(0, 'gold');
    }

    /** @test */
    public function strong_negative_amount_throws(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        (new DiscountCalculator())->calculate(-50, 'premium');
    }
}

8. Coverage metrics compared

A differentiated view of coverage metrics helps to apply the right metric for the right purpose. Not every metric is equally suited to every code layer.

9. Summary

Reading PHPUnit coverage correctly means treating line coverage as a gap finder, not as proof of quality. Branch coverage as a stronger guarantee for branching code. Mutation testing as the most honest metric for test quality. Coverage targets differentiated by code layer, not as a single global percentage. Using the coverage report weekly as a gap finder rather than managing it as a KPI.

The most important principle: coverage is a means, not a goal. Anyone who defines high coverage as the goal ends up writing weak tests. Anyone who uses coverage as a tool to find and close gaps builds a test suite that gives genuine confidence.

PHPUnit Coverage: The Essentials at a Glance

Line Coverage Is Not Quality

100% line coverage with tests that have no assertions, or that only cover the happy path, is worthless. Coverage measures execution, not correctness.

Prioritize Branch Coverage

Test every branch in both directions. Enable XDEBUG_MODE=coverage. Check the HTML report for red branches.

Mutation Testing

Use Infection for PHP. The MSI (Mutation Score Indicator) is a more honest metric than line coverage.

Coverage as a Gap Finder

Red areas in the report are valid signals. Coverage exclusions via @codeCoverageIgnore are usually a design signal.

Mironsoft

PHP development, test quality assurance and coverage analysis

Coverage that shows real quality instead of dressing up a number?

We analyze existing test suites for real branch coverage gaps, set up mutation testing, and define realistic coverage strategies that provide genuine confidence.

Coverage Analysis

Evaluation of line and branch coverage with identification of critical gaps

Mutation Testing

Infection setup and an MSI baseline for honest test quality measurement

CI Integration

Setting up coverage thresholds in the CI pipeline and building a coverage ratchet

10. FAQ: Reading PHPUnit Coverage Correctly

1What does line coverage measure?
Which code lines were executed at least once. Nothing about correctness, assertions, or test quality.
2Branch coverage vs. line coverage?
Branch coverage checks both sides of every branch. 100% line coverage can exist alongside 30% branch coverage, only the happy path tested.
3Enabling branch coverage?
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html coverage/html. Xdebug required. phpunit.xml must have a source configuration.
4What is mutation testing?
Modifies production code slightly, checks whether tests detect the mutations. MSI is a more honest metric than line coverage. For PHP: Infection.
5Sensible coverage targets?
No global percentages. Branch coverage by layer: domain logic 80-90%, exclude boilerplate. Coverage ratchet for new paths.
6@codeCoverageIgnore, when justified?
For generated code, framework boilerplate, configuration. NOT for business logic that is hard to test, that is a design signal.
7Reading the HTML coverage report?
Green = fully covered. Yellow = missing branches. Red = not covered. Prioritize red areas in business logic classes.
8Tests without assertions and coverage?
Increase coverage, give zero confidence. PHPUnit 10 warns. addToAssertionCount(1) when intentionally no classic assertion.
9Coverage tools for PHP?
Xdebug (branch coverage, slower), PCOV (faster, line coverage only). For CI: PCOV. Locally for branch analysis: Xdebug.
10Integrating coverage into a CI pipeline?
--coverage-clover clover.xml for Codecov/SonarQube. Define minimum coverage thresholds in phpunit.xml that fail the test run if not met.