skip to content

With Xdebug 3 installed, why does a PHPUnit coverage run collect nothing, and how do you enable coverage mode for that run only?

level: middleimportance: should knowfreq 42%

answer

  1. default mode lacks coverage
  2. XDEBUG_MODE=coverage vendor/bin/phpunit
  3. replaces xdebug.coverage_enable
  4. instruments every executed opcode
  5. branch and path coverage cost more

basics

~10 s

Xdebug 3 only collects coverage when coverage is in its mode, and the default mode is develop, so PHPUnit finds no active driver. Run XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html build/coverage to enable it for that run.

solid answer

~40 s

Xdebug 3 enables code coverage only when `coverage` is part of `xdebug.mode`; the default mode is `develop`, and Xdebug 2's `xdebug.coverage_enable` no longer exists. Without it Xdebug cannot serve as a coverage driver, so PHPUnit reports a warning instead of a report, and a direct `xdebug_start_code_coverage()` call warns that coverage must be enabled by setting `xdebug.mode` to `coverage`. Since the mode is read at process start, enable it per command: `XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html build/coverage`, or `php -d xdebug.mode=coverage vendor/bin/phpunit …`. Keep it out of the everyday ini: coverage mode instruments every executed opcode, so the whole suite runs slower even when no report is requested, and `--path-coverage` (which includes branch coverage) costs more still.

code

bash · 8 lines
bash
# everyday ini keeps xdebug.mode=off (or develop); coverage only here
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html build/coverage

# equivalent, through -d at process start
php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-text

# branch and path data, noticeably slower
XDEBUG_MODE=coverage vendor/bin/phpunit --path-coverage --coverage-html build/coverage

go deeper

for a junior

Recall that Xdebug 3 needs coverage in xdebug.mode before PHPUnit can collect coverage, and that XDEBUG_MODE=coverage turns it on for one command.

for a middle

Explain why the default develop mode yields no coverage, why a bootstrap ini_set() is too late, and what -d and XDEBUG_MODE do differently.

for a senior

Keep suites fast: coverage only in the jobs that publish reports, branch and path data only when needed, and filters that stop vendor code being analysed.

for a principal

Decide how often coverage is gathered and at what granularity, weighing CI minutes against the value of the data to the team.

## How Xdebug collects coverage **Code coverage** is the record of which lines — and optionally which branches and paths — ran during a test suite. PHP itself does not collect it; an extension has to hook the engine. Xdebug is one such **coverage driver**: PHPUnit, through its code-coverage library, starts collection before each test with `xdebug_start_code_coverage()`, stops it with `xdebug_stop_code_coverage()`, and reads the hits with `xdebug_get_code_coverage()`. In Xdebug 3, that machinery exists only when **`coverage` is in the mode**. With it, Xdebug installs a catch-all override for the engine's opcode handlers so it can see every executed line; without it, none of that is registered. ## Why the run collects nothing 1. `xdebug.mode` defaults to **`develop`**, and many setups use `debug` or `off`. None of those include `coverage`. 2. Xdebug 2's `xdebug.coverage_enable=1` is ignored by Xdebug 3; the upgrade guide replaces it with `xdebug.mode=coverage`. 3. So when PHPUnit looks for a working driver, Xdebug does not qualify. PHPUnit emits a warning and produces no report, and CI jobs that only check the exit code can miss that. 4. Code that calls `xdebug_start_code_coverage()` directly gets an `E_WARNING`: *"Code coverage needs to be enabled in php.ini by setting 'xdebug.mode' to 'coverage'"*. ## Enabling it for one run Because the mode is read once at process start, the reliable fix is per command: ```bash XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html build/coverage php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-text ``` - `XDEBUG_MODE` overrides `xdebug.mode` for that process only; nothing in the ini changes. - `-d xdebug.mode=coverage` sets the ini value at startup, which works on the CLI. - `ini_set('xdebug.mode', 'coverage')` in a bootstrap file does **not** work — too late. - If you also need step debugging in the same run, combine: `XDEBUG_MODE=coverage,debug`. PHPUnit starts fresh PHP processes for tests run in isolation and passes the parent's Xdebug settings along; when coverage is not being collected and no debugger is active, it switches those children to `xdebug.mode=off` to speed them up, unless the job sets `xdebug.mode` itself. ## What coverage mode costs | Collection | Option passed to `xdebug_start_code_coverage()` | Extra work | |---|---|---| | Lines hit | none | Only the base instrumentation of coverage mode | | Lines that could run but did not | `XDEBUG_CC_UNUSED`, `XDEBUG_CC_DEAD_CODE` | Analysing which lines hold executable code | | Branches and paths | `XDEBUG_CC_BRANCH_CHECK` | Instrumenting control flow inside each function | The Xdebug documentation notes that the analysis beyond plain line hits comes with an additional performance impact. PHPUnit's `--branch-coverage` and `--path-coverage` flags request the branch and path data; plain line coverage is the default. Key cost facts: - The opcode instrumentation is active **whenever the mode includes `coverage`**, even in runs that request no report, so leaving `coverage` in a developer's everyday ini slows every test run and every script. - Xdebug can restrict analysis to your own code with `xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, [...])`, loaded through `auto_prepend_file` before any code is compiled; the Xdebug docs report roughly a two-fold speed-up for a well-set filter. ## Coverage in a CI pipeline A common layout keeps the cost where it pays: - **Fast jobs** run the suite with `XDEBUG_MODE=off` (or without Xdebug), so no instrumentation slows them down. - **One coverage job** sets `XDEBUG_MODE=coverage` and publishes the report; branch and path data run less often, for example nightly. - **Verify the driver.** When coverage is active, PHPUnit's `Runtime:` header line names the coverage driver next to the PHP version; its absence in the coverage job's log means Xdebug was not in coverage mode. - **Fail loudly.** Treat PHPUnit's coverage warning as an error in that job, so a silently empty report cannot pass. ## Where the neighbouring decisions live Which driver PHPUnit uses and how reports are configured in `phpunit.xml` — the `<source>` element, report formats, the choice between Xdebug and a lighter coverage-only extension — belong to PHPUnit's configuration. What line, branch and path coverage *mean* belongs to testing theory. The Xdebug part is narrow and exact: put `coverage` in the mode, at process start, only for the runs that need it.

  • Why not simply put xdebug.mode=coverage in the developer image's php.ini?
    Coverage mode instruments every executed opcode whenever it is in the mode, so every script, every test run and every web request in that image gets slower, even when no report is requested. Keeping the ini at `off` or `develop` and setting `XDEBUG_MODE=coverage` only for coverage jobs pays the cost only where it buys something.
  • A CI job sets xdebug.mode=coverage with ini_set() in tests/bootstrap.php and still gets no coverage. Why?
    `xdebug.mode` is fixed when the PHP process starts, before PHPUnit loads the bootstrap file, so `ini_set()` cannot change it. Set `XDEBUG_MODE=coverage` in the job's environment or run PHP with `-d xdebug.mode=coverage` instead.

saying these in an interview costs you the question

  • Loading Xdebug is enough for PHPUnit to collect coverage
  • xdebug.coverage_enable=1 still switches coverage on in Xdebug 3
  • Coverage mode only costs time while a report is being generated
  • Setting xdebug.mode in the PHPUnit bootstrap file enables coverage