Code coverage — Xdebug coverage, --coverage-html
Concept
Code coverage is a metric that measures which lines, branches, paths, or conditions in your source code are executed during a test run. It answers "which code is tested?" not "is the tested code correct?"—a distinction that's critical. A test that calls a function without asserting on its output counts as 100% line coverage but may catch zero bugs.
PHPUnit supports four coverage modes, listed from cheapest to most precise:
Line coverage: Was this line executed at least once? The default and most reported metric.
Branch coverage: For every if, was both the true and false branch taken at least once?
Path coverage: Every unique path through the function (combinatorially expensive, rarely used).
Mutation coverage: See php-14-011 for the superior alternative.
To collect coverage, you need either Xdebug (mode set to coverage in php.ini: xdebug.mode=coverage) or PCOV (a lightweight coverage-only extension, much faster than Xdebug for CI).
Run ./vendor/bin/phpunit --coverage-html coverage/ to generate an HTML report, or --coverage-clover coverage.xml for a machine-readable format that CI systems like GitHub Actions and SonarQube can ingest.
PHPUnit 10+ uses the <source> element in phpunit.xml to define which files count toward coverage. Files in the source set but not touched by any test appear as 0% covered—this is important because without it, coverage metrics are inflated by only measuring files that tests happen to load.
The #[CoversClass(MyClass::class)] attribute (replacing @covers) tells PHPUnit to only count coverage for the specified class in this test. This prevents a class from getting "accidental" coverage from integration tests and skews unit-level metrics more honestly.
| Driver | Speed | Branch coverage | Installation complexity |
|---|---|---|---|
| Xdebug | Slow (10-50x) | Yes | Moderate |
| PCOV | Fast (2-5x) | No | Easy |
| phpdbg | Medium | Partial | Built into PHP |
In CI, collect coverage with PCOV. In development, use Xdebug only when you need branch coverage details or step-debugging.
Code Example
<?php
// phpunit.xml: enable coverage configuration
/*
<phpunit bootstrap="vendor/autoload.php">
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
<coverage>
<report>
<html outputDirectory="coverage"/>
<clover outputFile="coverage.xml"/>
<text outputFile="php://stdout" showUncoveredFiles="true"/>
</report>
</coverage>
</phpunit>
*/
// Run with:
// XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage/
// Or with PCOV: php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html coverage/
declare(strict_types=1);
namespace Tests\Unit;
use App\Domain\Discount;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
// CoversClass restricts coverage attribution to only Discount
// Prevents integration tests from inflating unit coverage numbers
#[CoversClass(Discount::class)]
final class DiscountTest extends TestCase
{
#[Test]
public function it_applies_flat_discount(): void
{
$discount = new Discount(type: 'flat', value: 10.00);
$this->assertSame(90.0, $discount->apply(100.0));
}
#[Test]
public function it_applies_percentage_discount(): void
{
$discount = new Discount(type: 'percentage', value: 20.0);
$this->assertSame(80.0, $discount->apply(100.0));
}
// Without this test, the 'unknown type' branch is uncovered
// Branch coverage requires BOTH paths of every if to be exercised
#[Test]
public function it_throws_on_unknown_discount_type(): void
{
$this->expectException(\InvalidArgumentException::class);
new Discount(type: 'bitcoin', value: 5.0);
}
}
// The source file being covered
namespace App\Domain;
final class Discount
{
public function __construct(
private readonly string $type,
private readonly float $value,
) {
// This branch: if type is invalid — uncovered unless test 3 exists
if (! in_array($this->type, ['flat', 'percentage'], true)) {
throw new \InvalidArgumentException("Unknown discount type: {$this->type}");
}
}
public function apply(float $price): float
{
return match ($this->type) {
'flat' => $price - $this->value,
'percentage' => $price * (1 - $this->value / 100),
};
}
}Interview Q&A
Q: What is the difference between line coverage and branch coverage, and why does branch coverage matter more?
Line coverage counts whether each line was executed at least once. Branch coverage checks whether both the true and false outcomes of every conditional were tested. A function with if ($user->isAdmin()) { return 'admin'; } return 'user'; achieves 100% line coverage if you only test an admin user—both lines are hit. But you've only tested one branch; if there's a bug in the non-admin path, your tests won't catch it. Branch coverage requires you to also test a non-admin user. In practice, aiming for high branch coverage reveals far more gaps in your test suite than chasing line coverage percentages.
Q: You have 90% line coverage and the team is proud of it. What would you tell them about why that number may be misleading?
Coverage percentage tells you what code was executed, not what was actually verified. A test suite that calls every function but asserts nothing can achieve 100% coverage while testing nothing. Coverage also doesn't measure: whether edge cases are handled (a loop body may be covered with 1 iteration but not 0 or 1000), whether exception paths are tested, or whether the right behavior is verified when the code produces a result. The better question isn't "how much is covered?" but "what are the critical paths that are NOT covered?"—and for that, you look at the uncovered lines and branches, not the percentage.
Q: What is the performance impact of collecting coverage, and how do you handle it in CI?
Xdebug in coverage mode instruments every opcode, slowing PHP execution by 10-50x. For a suite that runs in 5 seconds without coverage, that's 50-250 seconds with Xdebug—unacceptable for rapid CI. The solutions: use PCOV instead of Xdebug (2-5x overhead, no step-debugging overhead), run coverage collection only on the main branch merge (not on every PR push), or split the suite into a fast "smoke" run without coverage and a slower scheduled nightly run with full coverage. PCOV is the modern default for CI because it does nothing except coverage—no step-debugging, no profiling.