Coverage Metrics Strategy
When to Use
Measuring how much of your code is executed during tests, identifying untested code paths, setting coverage targets for CI/CD.
Decision: PCOV vs Xdebug
| Criteria | PCOV | Xdebug |
|---|---|---|
| Speed | 2-5x faster (15s vs 50s for typical suite) | Slower but acceptable for thorough analysis |
| Coverage types | Line coverage ONLY | Line, branch, path, function coverage |
| Setup complexity | Simple (single PHP extension) | Moderate (configure modes) |
| Maintenance status | Not actively maintained (last release 2021) | Actively developed, well-supported |
| PHP 8.4+ compatibility | Requires patches | Native support |
| Use case | Daily dev workflow, CI line coverage | Deep analysis, branch/path coverage |
Recommendation: Use PCOV for fast feedback in development and CI/CD (line coverage is 90% of value). Use Xdebug when branch/path coverage matters (security-critical code, complex conditionals). For projects targeting PHP 8.4+, prefer Xdebug due to PCOV maintenance concerns.
Coverage Types Explained
| Type | What it measures | Example | Why it matters |
|---|---|---|---|
| Line coverage | % of executable lines run | 50 lines total, 40 executed = 80% | Quick indicator of test breadth |
| Branch coverage | % of conditional branches taken | if ($x) { A } else { B } -- both A and B executed? |
Finds untested edge cases |
| Path coverage | % of unique execution paths | Function with 3 ifs = 8 possible paths | Ensures all combinations tested |
| Function coverage | % of functions/methods called | 10 methods, 8 called = 80% | Identifies dead code |
Line coverage is the baseline -- fast to collect, easy to understand. Branch coverage catches the "happy path only" problem where you test success but not failure. Path coverage is expensive (combinatorial explosion) but critical for security-sensitive code. Function coverage is a sanity check (0% = completely untested class).
Pattern
Installing PCOV (DDEV example):
# In .ddev/config.yaml
webimage_extra_packages: [php-pcov]
# Or manually
ddev exec sudo apt-get install php-pcov
ddev exec sudo phpenmod pcov
ddev restart
Installing Xdebug (DDEV includes it by default):
ddev xdebug on # Enable Xdebug in coverage mode
Running PHPUnit with line coverage (PCOV or Xdebug):
# HTML report (open reports/index.html in browser)
./vendor/bin/phpunit --coverage-html reports/
# Clover XML (for CI/CD tools like GitLab, SonarQube)
./vendor/bin/phpunit --coverage-clover coverage.xml
# Text report (terminal output)
./vendor/bin/phpunit --coverage-text
# Filter to specific directory
./vendor/bin/phpunit --coverage-html reports/ web/modules/custom/my_module/
Running PHPUnit with branch/path coverage (Xdebug only):
# Requires Xdebug 3.1+
XDEBUG_MODE=coverage ./vendor/bin/phpunit \
--coverage-html reports/ \
--path-coverage
Interpreting coverage reports:
Code Coverage Report:
Classes: 85.00% (17/20)
Methods: 78.57% (22/28)
Lines: 82.35% (140/170)
MyService.php
Lines: 92.31% (12/13) -- GOOD
Untested line 45: Exception handler
DiscountCalculator.php
Lines: 50.00% (5/10) -- NEEDS WORK
Untested lines 20-24: Edge case validation
Coverage in CI/CD (GitLab CI example):
test:
script:
- composer install
- ./vendor/bin/phpunit --coverage-text --colors=never
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
What coverage a mature module tends to have, by code type
These are descriptions of a module after months of TDD, not a floor for the change in front of you. A change adds exactly the tests its new behaviors require -- see the universal guide's When Not to Write a Test. If a change lowers coverage, that is a signal to look, recorded as a finding. It is never an instruction to add tests inside that change.
| Code type | Typical mature coverage | Why |
|---|---|---|
| Services (business logic) | 80-90% | High reuse, critical to functionality |
| Plugins (blocks, formatters) | 70-85% | Well-defined contracts, moderate complexity |
| Controllers | 60-75% | HTTP overhead, test logic not routing |
| Forms (validation/submit) | 50-70% | Setup expensive, focus on custom logic |
| Hooks/event subscribers | 60-80% | Integration points, side effects matter |
| Entity classes (custom) | 70-85% | Data integrity critical |
| Utility classes | 90%+ | Pure functions, easy to test thoroughly |
| Admin UI / config forms | 30-50% | Mostly CRUD, low custom logic |
Why 100% is a waste: Chasing 100% coverage leads to testing trivial getters/setters, framework code, and defensive branches that never execute. Meaningful coverage tests behavior, not code structure.
Common Coverage Anti-Patterns
- Testing getters/setters -- 100% line coverage, 0% value (trivial code)
- Covering dead branches --
if (FALSE) { unreachable }counts toward coverage but tests nothing - Ignoring branch coverage -- 80% line coverage but only "happy path" tested, failures uncaught
- Coverage theater -- hitting lines without asserting behavior (test runs code but verifies nothing)
- Excluding too much --
--coverage-excludehides real gaps instead of writing tests - Coverage targets without context -- 80% of what? If 80% excludes all security checks, target is meaningless
- Reading a coverage target as a floor to reach in the current change -- targets describe a mature module, not a single commit