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.
Table of Contents
- 1. What coverage really measures, and what it does not
- 2. Line coverage: the fastest and weakest metric
- 3. Branch coverage: checking branches deliberately
- 4. Path coverage and mutation testing
- 5. Generating and reading PHPUnit coverage reports
- 6. Setting sensible coverage targets
- 7. The most common coverage traps
- 8. Coverage metrics compared
- 9. Summary
- 10. FAQ
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