Coverage state is per-process. Under parallel runners — brianium/paratest or
pest --parallel (which delegates to paratest) — each worker boots its own
PHPUnit, runs a slice of the suite, and would otherwise emit its own slice
report. Without coordination the output_file ends up containing whichever
worker finished last, and the GITHUB_STEP_SUMMARY ends up with N partial
reports stacked on top of each other.
The coverage extension solves this with a two-step workflow that mirrors
phpunit/php-code-coverage:
- Workers drop a JSON sidecar per process. The extension auto-detects
paratest by looking at
TEST_TOKEN(set in every paratest child) and short-circuits rendering — no console output, nooutput_filewrite, noGITHUB_STEP_SUMMARYappend from the worker. - A single merge step reads the sidecars, union-merges them via the
same rules
OpenApiCoverageTracker::recordResponse()applies, and emits the combined report.
# 1. Run tests in parallel — workers write sidecars only.
vendor/bin/pest --parallel --processes=4
# (or `vendor/bin/paratest --processes=4`)
# 2. Merge sidecars into a single coverage report.
vendor/bin/openapi-coverage-merge \
--spec-base-path=openapi/bundled \
--specs=front,admin \
--output-file=coverage-report.mdvendor/bin/openapi-coverage-merge flags:
| Flag | Default | Description |
|---|---|---|
--spec-base-path=<path> |
— (required) | Path to bundled spec directory |
--specs=<a,b> |
front |
Comma-separated spec names |
--strip-prefixes=<a,b> |
— | Comma-separated request-path prefixes to strip |
--sidecar-dir=<path> |
sys_get_temp_dir()/openapi-coverage-sidecars |
Where workers wrote sidecars |
--output-file=<path> |
— | Markdown report output path |
--junit-output=<path> |
— | JUnit XML report output path (CI dashboards). See Coverage output formats |
--json-output=<path> |
— | Machine-readable JSON report output path. Schema: coverage-json-schema.md |
--html-output=<path> |
— | Self-contained HTML report output path. See coverage-html-output.md |
--github-step-summary=<path> |
$GITHUB_STEP_SUMMARY |
Append Markdown report to this file |
--console-output=<mode> |
default |
default / all / uncovered_only |
--min-endpoint-coverage=<pct> |
— | Threshold gate (see Coverage threshold gate) |
--min-response-coverage=<pct> |
— | Threshold gate at (method, path, status, content-type) granularity |
--min-coverage-strict |
false (warn-only) |
Treat threshold misses as exit non-zero |
--strict-required=<mode> |
off |
off / warn / fail. Assert no schema under-description drift across worker observations. See strict-required.md |
--no-cleanup |
(cleanup is on by default) | Keep sidecar files after merge |
Sidecar dir defaults are deliberately stable — workers and the merge CLI
use the same sys_get_temp_dir()/openapi-coverage-sidecars path, so a
trivial CI step has no extra config to keep in sync. Set sidecar_dir (in
phpunit.xml) and --sidecar-dir= (on the merge CLI) to the same custom
path if sys_get_temp_dir() is unavailable in your runner.
- Sequential runs are unchanged. Without
TEST_TOKENthe extension renders inline as before. There is no need to wire the merge CLI into non-parallel CI jobs. - Pest plugin works under
--parallel. The expectations registered by the Pest plugin record coverage through the sameOpenApiCoverageTrackerstatic, so each Pest worker drops a sidecar exactly like a paratest worker would. No additional wiring needed beyond the merge step shown above. strict_requiredaggregates across workers. Workers always export observations via the sidecar envelope (v2). The merge CLI's--strict-requiredflag decides whether to assert the gate; thestrict_requiredparameter on the PHPUnit extension does not propagate to the merge step. Seestrict-required.md.- Worker counts are not exposed by paratest. A child cannot reliably
tell how many siblings it has, so the merge has to run as a separate
step rather than auto-firing from "the last worker." This matches how
PHPUnit's own coverage merging works (
phpcov merge). - Sidecars are cleaned up by default. Run with
--no-cleanupif you want to inspect the per-worker JSON for debugging. - A failed sidecar write does not fail the test run. Workers log a
warning to
STDERRand let the suite finish — your contract assertions already passed; sidecar I/O is a CI artifact concern. - Stale sidecars across runs. Cleanup-on-success removes sidecars after every successful merge. If a previous run crashed before the merge step, any leftover sidecars in the dir will be picked up by the next merge — delete the sidecar dir at the start of CI if you can't trust the previous run's exit code.
- Worker write failures fail the merge loudly. When a worker can't
persist its sidecar, it drops a
failed-<token>.jsonmarker. The merge CLI exits non-zero (FATAL) when any markers are present, since a missing worker would silently under-count coverage. - HTTP
$refauto-resolution from the merge CLI. The CLI callsOpenApiSpecLoader::configure()with onlyspec_base_pathandstrip_prefixes—allowRemoteRefscannot be set via CLI flags. If your spec uses HTTP(S)$ref, run the merge step from a process that callsOpenApiSpecLoader::configure(..., allowRemoteRefs: true, ...)first (e.g. a Composer script), or pre-bundle remote refs offline.