dvaknheo / libcoverage
Test-coverage toolkit for PHP libraries: generates PHPUnit test templates and merges every test into one HTML coverage report.
Requires
- php: >=7.4.0
- phpunit/php-code-coverage: ^9.2.32
Requires (Dev)
Suggests
- ext-pcov: Faster line coverage driver, an alternative to Xdebug
- ext-xdebug: Line coverage driver, enable it with xdebug.mode=coverage
Provides
None
Conflicts
None
Replaces
None
README
*** v1.1.0 ***
Author QQ: 85811616
Official QQ Group: 714610448
LibCoverage helps PHP library developers achieve full code-coverage testing, making your PHP code more robust.
Using an AI coding agent? Point it at AGENTS.md (in an installed project:
vendor/dvaknheo/libcoverage/AGENTS.md). It covers the per-class loop, the machine-readable output and the pitfalls. The per-class check is one command:LIBCOVERAGE_PER_CLASS=1 php -d xdebug.mode=coverage vendor/bin/phpunit tests/AppTest.php.
Requirements
- PHP 7.4 or newer.
- A coverage driver: Xdebug with
xdebug.mode=coverage, PCOV, or phpdbg. Without one,help,setupandclozestill work, but nothing is traced. - PHPUnit 9.6 in the project.
phpunit/php-code-coverageis installed as a dependency of this package and keeps PHPUnit on the 9.x line.
Usage
composer require --dev dvaknheo/libcoverage ## install
composer exec libcoverage # show help
composer exec libcoverage setup # set up the project
phpunit # run PHPUnit unit tests
cat test_reports/index.html # view the report; you can also open it in a browser
cat test_reports/index.jsonl # the same report, machine-readable, one JSON object per line
# composer exec libcoverage cloze # new classes added; fill in test templates
# phpunit tests/AppTest.php && phpunit tests/support.php # regenerate when only a single class has changed
# composer exec libcoverage report # alternative report generation; already generated during phpunit
composer exec libcoverage setup creates phpunit.xml (if it does not exist) and the supporting files tests/bootstrap.php and tests/support.php.
Run phpunit, then open index.html in the test_reports directory.
The setup command also generates corresponding tests/*Test.php test templates based on the class files in the src directory.
For example, for src/App.php:
<?php namespace MyProject; class App { public function foo() { var_dump(DATE(DATE_ATOM)); } }
tests/AppTest.php will be generated:
<?php namespace tests\MyProject; use MyProject\App; use LibCoverage\LibCoverage; class AppTest extends \PHPUnit\Framework\TestCase { public function testAll() { LibCoverage::Begin(App::class); /* // // public methods App::_()->foo(); //*/ LibCoverage::End(); } }
The test code in the middle is up to you — write tests that achieve 100% coverage for the current class.
Methods are taken from reflection and grouped by visibility: public methods are written as usable calls, while protected / private ones are listed as commented-out calls, because a test cannot call them from the outside. Methods inherited from a parent class are not listed — they belong to the parent's own test template.
composer exec libcoverage cloze is used to fill in test files that were added later. Existing test files will not be overwritten.
phpunit tests/AppTest.php && phpunit tests/support.php is a trick for when only a single file has been modified.
Below is a screenshot of LibCoverage running PHPUnit with full coverage. Example of test_reports/index.html:
JSONL Report for AI Agents
Every time the HTML report is generated, LibCoverage also writes
test_reports/index.jsonl next to index.html. It is one JSON object per line:
one line per source file, then a final summary line.
{"type":"file","path":"src/App.php","lines_total":8,"lines_tested":6,"lines_percent":75,"uncovered_lines":[7,9],"uncovered_methods":[{"name":"MyProject\\App::bar","lines_total":2,"lines_tested":1,"start_line":7,"end_line":9}]}
{"type":"summary","schema":1,"files":1,"lines_total":8,"lines_tested":6,"lines_percent":75,"generated_at":"2026-10-30T20:40:00+08:00","html_report":"test_reports/index.html"}
pathis relative to the project root; a file outside the project keeps its absolute path.uncovered_lineslists the executable lines that no test has reached.uncovered_methodscomes from static analysis, so methods that never ran are listed too, with their line range.lines_percentis a number from 0 to 100; a file with no executable line counts as 100.- File rows are sorted by path and the summary is always the last line, so the file diffs and streams cleanly.
Because it is plain JSONL, grep, jq or an AI agent can read it without touching the HTML:
grep '"type":"file"' test_reports/index.jsonl | jq -c 'select(.lines_tested < .lines_total)'
LibCoverage Class Options
Default options:
public $options = [ 'namespace' => null, 'path' => null, 'path_src' => 'src', 'path_dump' => 'test_coveragedumps', 'path_report' => 'test_reports', 'path_test' => 'tests', 'path_data' => 'tests/data_for_tests', 'auto_detect_namespace' => true, 'report_per_class' => false, ];
When running composer exec libcoverage, these options can be passed in, for example:
vendor/bin/libcoverage --path='abc' --path-test=test
Option names may be spelled with - or _, so --path-test=test and --path_test=test are the same option.
You can also adjust these options in tests/bootstrap.php.
namespace is the base namespace of the classes to be tested. If left empty, auto_detect_namespace will detect and fill it.
path is the base path.
path_data is used to store test data.
report_per_class (default false): when enabled, End() prints the coverage of the class it just traced — Coverage: 12/15 (80.00%), plus the uncovered lines and the methods that still have gaps. The LIBCOVERAGE_PER_CLASS environment variable takes precedence over this option, so a one-off run does not need to touch tests/bootstrap.php:
LIBCOVERAGE_PER_CLASS=1 vendor/bin/phpunit tests/AppTest.php
On Windows PowerShell: $env:LIBCOVERAGE_PER_CLASS=1; vendor/bin/phpunit tests/AppTest.php. Keep the switch per run — do not put it in phpunit.xml: a full run does not need per-class output and would print a block for every class.
True values are 1, true, on, yes (case-insensitive); anything else is false. The dump is still written, so tests/support.php keeps building the merged HTML report.
Every per-class run also ends with one machine-readable line, prefixed with LIBCOVERAGE_CLASS , carrying the class totals and a files array (same row shape as the JSONL report). For tooling, grep that prefix instead of tailing the console.
When PHPUnit is invoked with its own --coverage-* option, LibCoverage disables its own tracing and prints a warning. Two coverage collectors cannot share one Xdebug — a nested start/stop wipes the outer session — so LibCoverage's dumps would be empty. Run PHPUnit without --coverage-* when you want LibCoverage to trace.
LibCoverage Class Public Method Reference
Static methods — common needs are met by calling static methods:
LibCoverage::_($object=null); // mutable singleton; pass $object to replace the singleton LibCoverage::Begin($class); // start full-coverage tracing for a class LibCoverage::End(); // end tracing for a class LibCoverage::Report(); // used by support.php to display all reports LibCoverage::NewProject(); // create project files; used by the setup command LibCoverage::Cloze(); // create test files; used by the cloze command LibCoverage::CreateTestDir($class=null); // create the test data directory path_data/[ClassName]; $class defaults to the class of Begin() LibCoverage::CleanTestDir($class=null); // clean that directory
Additional methods:
LibCoverage::_()->init(array $options, ?object $context = null); // used by bootstrap.php for initialization
Other less commonly used public methods:
LibCoverage::_()->doPause() // pause LibCoverage::_()->doResume() // resume LibCoverage::_()->hasCoverageDriver() // whether a coverage driver (Xdebug, PCOV or phpdbg) is available LibCoverage::_()->addExtFile($extFile); // add extra test files, such as global function files LibCoverage::_()->getClassTestPath($class); // get the dedicated directory for the test class; default is tests/data_for_tests/[ClassName] LibCoverage::_()->doCreateTestDir($class=null); // create that directory, returns its path LibCoverage::_()->doCleanTestDir($class=null); // clean that directory, returns its path LibCoverage::_()->cleanDirectory($dir); // helper method for deleting test directories, etc.
Full Coverage Passing Does Not Equal All Functional Tests Passing
The code may use @codeCoverageIgnore, @codeCoverageIgnoreStart, and @codeCoverageIgnoreEnd to skip sections.
Also, conditional short-circuiting may skip subsequent branches.
Also, someone forced to achieve full coverage may not necessarily exercise all logic paths.
Changelog
See CHANGELOG.md for the changes in every release.
Contributing
- AGENTS.md is the workflow guide for AI coding agents working in a project that depends on LibCoverage: the per-class loop, the machine-readable output, and the common pitfalls. Requirements, the report schema and every option stay here in the README.
- DEVELOPMENT.md documents the internals, the rules for changes, the commands and the release checklist for maintainers.
