Skip to content
advanced Phase 12 · Testing with PHPUnit

Coverage Reports and Thresholds

Measure which lines your tests actually execute.

45m
0 problems
Topic Progress 0%

No Code Coverage Driver Available: Xdebug, PCOV, and What Each Costs

You type the command every tutorial shows:

vendor/bin/phpunit --coverage-text

and PHPUnit prints Warning: No code coverage driver available before running your tests with no report at all. The runner does not collect coverage itself — the php-code-coverage library does, and it needs an extension in the PHP process that instruments opcodes. You have two realistic choices, and they are not equivalent.

Xdebug is the debugger you probably already have installed, and it only collects coverage when its coverage mode is on:

# per-run, without editing php.ini
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
# or persist it: xdebug.mode=coverage in php.ini

PCOV (pecl install pcov) exists only to collect coverage — no debugger, no trace, far less overhead. The difference is measurable. On a 2,804-test suite with PHPUnit 10:

Driver configuration Wall time Memory What you get
No driver 22.1 s 38 MB nothing
PCOV 34.2 s 96 MB line coverage
Xdebug, lines 49.5 s 90 MB line coverage
Xdebug, lines + branches + paths 7 min 30 s 451 MB line, branch, path

PCOV costs roughly 1.5× the plain run; Xdebug line coverage about 2.2×; Xdebug with branch and path coverage about 20×. A second data point, from a Laravel framework run: 33 s with no coverage, 77 s with PCOV, 154 s with Xdebug. PCOV is consistently about twice as fast as Xdebug for the same line-level report.

Verify what your process actually has before blaming the configuration:

php -m | grep -E 'xdebug|pcov'              # is a driver loaded for the CLI at all?
php -r 'echo ini_get("xdebug.mode"), PHP_EOL;'   # off | develop | debug | coverage

Watch the SAPI while you are there: php -m reports the CLI's php.ini, which is the only interpreter PHPUnit uses, but many machines carry a separate php-fpm.ini for the web server. An extension visible in a browser request can be invisible to your tests, and the reverse — a mismatch that looks like a PHPUnit problem and is an installation problem.

Switching collection on for one job needs no image change at all:

- name: Tests with coverage
  run: vendor/bin/phpunit --coverage-clover build/coverage.xml
  env:
    XDEBUG_MODE: coverage

An Xdebug left in develop or debug mode taxes every run whether or not you asked for coverage, so keep it off for daily work and switch it on only for the job that publishes a report.

The trade-off is one line: PCOV collects line coverage only — branch and path coverage require Xdebug (with --branch-coverage / --path-coverage on PHPUnit versions that support them). If your team's gate is a line-percentage and CI minutes cost money, PCOV is the default. If you are investigating whether both arms of a condition are exercised, Xdebug is the only way to see it — and you now know why that job belongs in a scheduled CI run, not in your inner loop.

Two operational habits follow from the table:

  • Daily runs stay driver-free. vendor/bin/phpunit --testsuite unit with Xdebug loaded but xdebug.mode=off is the fast path; toggling coverage on for every local run turns a 22-second suite into a 50-second one, which is how teams quietly stop running tests.

Notice how this composes with Mocking Dependencies: mocking already stripped network latency out of the suite, and the coverage driver puts a multiplier back on top of whatever is left. The two costs together — execution and instrumentation — decide whether your inner loop stays near a second or drifts past a minute, which is exactly why drivers are toggled per run instead of left on.

  • Coverage runs are deliberate. CI job, nightly job, or a pre-merge check — one of them, on one machine, with the driver enabled for that invocation only.

With a driver active, the report still depends on a setting that decides what is being measured at all.

Reading the HTML Report: The Source Filter Decides Your Percentage

--coverage-text gives you one number. The HTML report tells you where to work:

vendor/bin/phpunit --coverage-html build/coverage --coverage-text
open build/coverage/index.html

The index lists every file you declared as source, its class/method/line percentages, and — when you open a file — the actual source with unexecuted lines highlighted in red. That red is the report's only real content: the number is a summary, the red lines are the worklist.

Which files appear is decided once, in phpunit.xml:

<source>
  <include>
    <directory suffix=".php">src</directory>
  </include>
  <exclude>
    <directory>src/Migrations</directory>
    <directory>src/Generated</directory>
  </exclude>
</source>

<coverage>
  <report>
    <html outputDirectory="build/coverage"/>
    <clover outputFile="build/coverage.xml"/>
    <text outputFile="php://stdout" showUncoveredFiles="true"/>
  </report>
</coverage>

The split trips everyone once: <source> answers "what is my code", <coverage> answers "which report files to write". If you skip <source>, there is nothing to measure and you pass --coverage-filter src on the command line instead.

There is a stricter mode worth knowing, because it changes what the number means. Declare intent on the test class:

#[CoversClass(TaxCalculator::class)]
final class TaxCalculatorTest extends TestCase
{
    // ...
}

and set requireCoverageMetadata="true" in phpunit.xml. Now lines executed by a test that never declared TaxCalculator do not count toward TaxCalculator — the percentage reflects what your tests claim responsibility for, and any test without metadata is flagged. It is extra ceremony, and teams usually adopt it together with the threshold enforcement in the next chapter.

Output files land wherever outputDirectory says, so put build/ in .gitignore and, in CI, publish the HTML as a build artifact: a reviewer who can click the red line in a pull request fixes the gap without checking anything out locally.

Guard the denominator, because the percentage is only ever as honest as the file set behind it:

Include Effect on the number
tests/ in <source> inflated — test code is covered by itself, near 100%
vendor/ meaningless churn from framework internals
src/Migrations, generated code red lines nobody can or should test
only src/ a number you can actually defend in review

Then read the red lines as untested branches, not untested files:

public function totalWithTax(float $rate): int
{
    if ($rate < 0) {
        throw new InvalidArgumentException('rate must be >= 0');   // red: guard never ran
    }

    $total = (int) round($this->subtotal * (1 + $rate));
    return $total ?? 0;                                             // red: fallback never needed
}

A red guard clause means no test ever sent a negative rate — the error path that fails only in production. A red ?? 0 means the fallback is unproven. Paying lines that execute the happy path but skip every validation, recovery, and boundary branch is exactly how a suite reports 90% while the features nobody tested keep breaking.

Work the report in file order, not percentage order. A file at 60% with three red guard clauses is a day of focused work; a file at 45% that is one large match over a constant mapping table may be correct to leave alone. The summary lists classes, methods, and lines separately and they routinely disagree — trust the lines, because they are the ones rendered beside the actual source. When a file reads 100% and you still do not trust it, that unease is justified: nothing in this report measures whether an assertion exists.

For CI and third-party services, --coverage-clover build/coverage.xml writes machine-readable XML — a <metrics> element with statements and coveredstatements — consumed by SonarQube, Codecov, and Coveralls, and by the gate you are about to build in the next chapter.

One known honesty limit worth mentioning in an interview: coverage is collected at the opcode level, so match expression arms can be reported as covered even when only one arm ran. Line coverage is an approximation of execution, not proof of anything — which is the argument for the threshold and mutation work ahead.

Failing the Build When Coverage Drops: Clover Gates and Mutation Testing

A coverage report nobody enforces is a report nobody maintains. PHPUnit will print the percentage; it has no built-in minimum, so the gate is yours to build, and it takes five lines.

vendor/bin/phpunit --coverage-clover build/coverage.xml
php -r '
$x = simplexml_load_file("build/coverage.xml");
$m = $x->project->metrics;
$p = 100 * (float) $m["coveredstatements"] / (float) $m["statements"];
printf("coverage: %.1f%%\n", $p);
exit($p >= 80.0 ? 0 : 1);
'

Non-zero exit fails the job. Two design rules make this a gate instead of a nuisance:

  • Fail down, never re-ratchet up automatically. The threshold is a floor written by the team; a script that recomputes the floor from the current number protects nothing.
  • Exclude what you deliberately do not count in <source>, so the gate cannot be satisfied by deleting tests from tests/ while the denominator quietly shrinks.

The deeper problem is that coverage measures execution, not verification. A line is "covered" the moment any test passes through it — even if the test asserts nothing about what happened:

if ($rate >= 0.0) {        // mutant: if ($rate > 0.0)  — survives a weak suite
    $total = round($base * (1 + $rate));
}

Your suite can execute that line 50 times and still not notice that a zero rate is now rejected, because no test asserts the boundary. Mutation testing closes the gap: Infection rewrites your source — >= becomes >, && becomes ||, an increment disappears — runs the suite against each mutant, and reports every mutant your tests failed to kill.

composer require --dev infection/infection
vendor/bin/infection --threads=4 --min-msi=80 --min-covered-msi=90
Metric Meaning
MSI (Mutation Score Indicator) share of all mutants your suite killed — escaped mutants are survivors
Covered MSI same, counting only mutants in code your tests execute, so legacy uncovered code does not drown the signal
Surviving mutant a real defect class: the test ran and did not care

Both flags fail the build when the score drops, which is the same ratchet philosophy as the coverage floor — applied to a number that actually measures whether your assertions assert something. Infection reuses your coverage data to mutate only covered code, which is why the PCOV/Xdebug decision from the first chapter has a direct effect on how long a mutation run takes: it runs your suite many times over.

Put the gate in the repository so it cannot be forgotten in someone's local override:

// infection.json5
{
  "source": { "directories": ["src"] },
  "minMsi": 80,
  "minCoveredMsi": 90,
  "timeout": 10
}

Cost then decides how you schedule it. Infection re-runs your suite once per mutant — hundreds of runs on a real codebase — so on a pull request you mutate only what changed:

vendor/bin/infection --threads=max --git-diff-lines --min-msi=80

--git-diff-lines restricts mutants to the lines your branch touched, turning a twenty-minute full run into a two-minute check on the files in the diff; the complete run then lives in a nightly job. The timeout key marks pathologically slow mutants as escaped instead of letting one hang the build, and every escaped mutant is printed with a diff so a reviewer can judge it — the loop that actually improves the suite, because each survivor is either a missing assertion or the next test worth writing.

Enforced in CI, the combination reads well in a review and better in an interview: coverage tells you what was touched, mutation tells you what would have broken, and the threshold stops either from quietly rotting. The phase's tooling ends here — Creating a Laravel Project moves from a bare PHPUnit setup to a framework that ships its own test harness, factories, and fakes built on everything you have just configured.

Quiz

1. `vendor/bin/phpunit --coverage-text` prints a warning and produces no report. What is missing?

Question 1 options

2. Your team wants branch coverage showing whether both arms of every condition run. Which configuration is required?

Question 2 options

3. A suite reports 92% line coverage while a negative-rate guard clause shows red in the HTML report. What does that combination tell you?

Question 3 options

4. CI must fail when test quality regresses. Which pair of mechanisms does the topic argue for?

Question 4 options

Flashcards

Question

What does the 'No code coverage driver available' warning mean and how do you fix it?

Answer

php-code-coverage needs an opcode-level extension in the PHP process. Fix by installing PCOV, or enabling Xdebug's coverage mode (xdebug.mode=coverage in php.ini, or XDEBUG_MODE=coverage vendor/bin/phpunit for one run). Without a driver, coverage flags are ignored and no report is produced.

Question

PCOV vs Xdebug for coverage — what is the cost and what data does each produce?

Answer

PCOV: ~1.5× a plain run, line coverage only. Xdebug with lines: ~2.2× a plain run, line coverage. Xdebug with lines+branches+paths: ~20× a plain run and far more memory. Real numbers on a 2,804-test suite: 22 s plain, 34 s PCOV, 49 s Xdebug lines, 7 min 30 s Xdebug branch+path.

Question

What do <source> and <coverage> each control in phpunit.xml?

Answer

<source> declares which directories are your own code (include/exclude) — the denominator of every percentage. <coverage> configures report output (html, clover, text). Mixing them up yields either no report or a number computed over the wrong file set; tests/ in <source> inflates results.

Question

How do you enforce a minimum coverage level in CI?

Answer

PHPUnit only reports. Run with --coverage-clover, parse the clover metrics (coveredstatements / statements) in a small script, and exit non-zero below the agreed floor — failing only downward so the threshold is a ratchet the team raises deliberately.

Question

What does mutation testing measure that coverage cannot?

Answer

Coverage says a line executed; mutation testing says an assertion would notice. Infection mutates the source (>= to >, && to ||, removed increments), reruns the suite, and fails the build on --min-msi / --min-covered-msi when mutants survive — surviving mutants are lines your tests touch but do not verify.

Revision Notes

Key Takeaways

  • 1. Coverage is collected by PCOV or Xdebug, not PHPUnit: without a driver the coverage flags silently do nothing and print a warning
  • 2. PCOV gives line coverage at roughly 1.5× a plain run; Xdebug line coverage costs about 2.2× and branch/path coverage about 20× — schedule the expensive data, keep the inner loop driver-free
  • 3. <source> is the denominator: including tests/ or vendor/ inflates the number, and excluding generated code removes red lines nobody should fix
  • 4. Red lines in the HTML report are unexecuted branches — guard clauses, exception paths, and fallbacks, which is where production-only failures hide under a high percentage
  • 5. Enforce the floor by parsing clover XML in CI (fail downward only) and pair it with Infection's --min-msi/--min-covered-msi, because mutation score is what distinguishes executed lines from verified ones

Interview Tips

  • • "What does 90% coverage actually tell you?" — only that 90% of declared source lines executed during the run; it says nothing about assertions. Pair it with mutation testing (Infection, MSI) as the metric that proves tests would notice a change
  • • "Why is coverage slow on my machine?" — you are running Xdebug in coverage mode; PCOV cuts collection cost to roughly 1.5× the plain run and covers lines only, so daily runs stay driver-free and CI collects the report
  • • "How would you stop coverage from dropping in CI?" — no built-in flag exists: run --coverage-clover, parse coveredstatements/statements, exit non-zero below the team floor, and ratchet only upward; mention that the same ratchet pattern applies to --min-msi
  • • "What is the difference between <source> and <coverage> in phpunit.xml?" — source declares which files are yours (the denominator), coverage declares report outputs (html/clover/text); the commonest bad percentage is a wrong denominator, not missing tests

Cheat Sheet

Coverage Reports and Thresholds

Get a driver:

XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text   # Xdebug 3, per run
# xdebug.mode=coverage in php.ini                        # Xdebug, persisted
pecl install pcov                                          # PCOV: coverage only, faster

Cost (2,804-test suite, PHPUnit 10):

Config Time Memory Data
no driver 22.1 s 38 MB —
PCOV 34.2 s (1.5×) 96 MB lines
Xdebug lines 49.5 s (2.2×) 90 MB lines
Xdebug lines+branches+paths 7:30 (≈20×) 451 MB lines, branches, paths

Config split: <source> = what is my code (include/exclude) · <coverage><report> = which files to write. Keep tests/, vendor/, migrations, generated code out of <source>.

Reports:

vendor/bin/phpunit --coverage-html build/coverage          # red lines = never executed
vendor/bin/phpunit --coverage-clover build/coverage.xml    # CI / SonarQube / Codecov
vendor/bin/phpunit --coverage-text --coverage-filter src   # stdout, one-off scope

CI floor (PHPUnit has no built-in minimum):

php -r '$m=simplexml_load_file("build/coverage.xml")->project->metrics;
$p=100*(float)$m["coveredstatements"]/(float)$m["statements"];
printf("coverage: %.1f%%\n",$p); exit($p>=80.0?0:1);'

Fail down only; never auto-raise the floor.

Beyond coverage:

composer require --dev infection/infection
vendor/bin/infection --threads=4 --min-msi=80 --min-covered-msi=90

MSI = mutants killed. Surviving mutant = line executed but unverified.

Continue Learning

Before You Start

Related Reading