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. Path coverage is expensive 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
Coverage Targets by Code Type
| Code type | Target coverage | Rationale |
|---|---|---|
| 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