Skip to content
...

Code Coverage

The plugin collects code coverage data during test execution and generates reports in standard formats. Reports can be used in CI services (Codecov.io, SonarQube, GitHub Actions) and in IDEs — for example, PhpStorm can display coverage directly in your code from a Clover report.

Requirements

One of the following PHP extensions is required:

  • PCOV — lightweight, fast, line coverage only.
  • XDebug ≥ 3.1 in coverage mode. You can enable the mode any standard way — xdebug.mode=coverage in the ini file, the -d xdebug.mode=coverage flag, or the XDEBUG_MODE=coverage environment variable: Testo detects the active mode via xdebug_info('mode'), so the env override (used by composer infect and IDEs) is honored correctly.

When both extensions are available, Testo prefers PCOV due to its lower overhead. If neither extension is installed, behavior depends on the plugin's activation mode (CoverageModeenum CoverageModeControls whether coverage is collected.).

Setup

Registering the plugin in testo.php is optional — you can enable coverage with CLI flags alone, without touching the config. See the CLI activation section for how.

Register CodecovPluginnew CodecovPlugin(CoverageLevel $level = CoverageLevel::Line, CoverageMode $collect = CoverageMode::IfAvailable, array $testTypes = [TestType::Test, TestType::TestInline], array $reports = [])Configures code coverage collection: analysis depth, activation mode, and report formats. in the plugins section of your configuration:

php
return new ApplicationConfig(
    src: ['src'],
    //...
    plugins: [
        new CodecovPlugin(
            level: CoverageLevel::Line,
            reports: [
                new CloverReport(__DIR__ . '/clover.xml', 'MyProject'),
                new CoberturaReport(__DIR__ . '/cobertura.xml'),
            ],
        ),
    ],
);
php
return new ApplicationConfig(
    src: ['src'],
    suites: [
        new SuiteConfig(
            // ...
            plugins: [
                new CodecovPlugin(
                    reports: [
                        new CloverReport(__DIR__ . '/clover.xml', 'MyProject'),
                    ],
                ),
            ],
        ),
    ],
);

At the application level, coverage is collected across all test suites. At the test suite level — only for that specific suite. Reports are generated after tests complete. Coverage is filtered to files matching the src parameter from ApplicationConfig\Testo\Application\Config\ApplicationConfig.

CodecovPlugin

Configures code coverage collection: analysis depth, activation mode, and report formats.

new CodecovPlugin(CoverageLevel $level = CoverageLevel::Line, CoverageMode $collect = CoverageMode::IfAvailable, array $testTypes = [TestType::Test, TestType::TestInline], array $reports = [])

Parameters:

$level
Coverage analysis depth. Defaults to CoverageLevel::Lineenum CoverageLevelDefines the depth of coverage analysis. Each successive level includes all data from the previous one.LineWhich source lines were executed. Supported by both PCOV and XDebug..
$collect
Default activation mode. CLI flags (--coverage, --no-coverage) take priority over this value.
$testTypes
Test types to collect coverage for. Coverage collection adds overhead to each run, so benchmarks are excluded by default — otherwise performance measurements would be skewed. By default, coverage is collected only for regular tests and inline tests (TestType::Test\Testo\Core\Value\TestType::Test, TestType::TestInline\Testo\Core\Value\TestType::TestInline). An empty array means all types. Accepts TestType\Testo\Core\Value\TestType cases or custom string identifiers.
$reports
Report generators to run after all tests complete. Each element must implement the CoverageReport\Testo\Codecov\Report\CoverageReport interface.

CoverageLevel

Defines the depth of coverage analysis. Each successive level includes all data from the previous one.

enum CoverageLevel

Each successive level increases analysis overhead. PCOV only supports Line — when a deeper level is requested, it silently falls back to Line.

Example. Consider the following code:

php
function greet(bool $loud, bool $formal): string
{
    $greeting = $formal ? 'Good day' : 'Hi';         // 2 branches
    return $loud ? strtoupper($greeting) : $greeting; // 2 branches
}

A test calling greet(true, true):

  • Line — 100%: both lines executed.
  • Branch — 50%: 2 of 4 branches taken.
  • Path — 25%: 1 of 4 paths followed (true+true, true+false, false+true, false+false).

Cases:

Line
Which source lines were executed. Supported by both PCOV and XDebug.
Branch
Line + which branches (if/else, switch, ?:, ??) were taken. XDebug only.
Path
Branch + which complete execution paths through each function were followed. XDebug only.

CoverageMode

Controls whether coverage is collected.

enum CoverageMode

The default behavior is set by the collect parameter of the CodecovPluginnew CodecovPlugin(CoverageLevel $level = CoverageLevel::Line, CoverageMode $collect = CoverageMode::IfAvailable, array $testTypes = [TestType::Test, TestType::TestInline], array $reports = [])Configures code coverage collection: analysis depth, activation mode, and report formats. constructor, and CLI flags can override it at runtime. This means the plugin can safely remain in testo.php across all environments — on CI without PCOV/XDebug, tests will run normally, just without reports.

Cases:

IfAvailable
Default. Coverage is collected if an extension is available and configured, otherwise silently skipped.
Always
Coverage is mandatory. If no extension is installed, tests will fail with a CoverageDriverNotAvailable\Testo\Codecov\Exception\CoverageDriverNotAvailable exception. Set by the --coverage CLI flag.
Never
Coverage is fully disabled, zero overhead. Set by the --no-coverage CLI flag.

Reports

All built-in report generators implement the CoverageReport\Testo\Codecov\Report\CoverageReport interface. You can implement it to add your own output format.

CloverReport

Generates a Clover XML report.

new CloverReport(string $outputPath, string $projectName = '')

The format contains <file>, <line>, and <metrics> elements — line-level statement coverage only. Branch and path data is not included, as the format does not support it.

Compatible with: Codecov.io, SonarQube, Atlassian Clover.

Parameters:

$outputPath
Absolute path to the output XML file.
$projectName
Project name in the <project> element. Defaults to an empty string.

Examples:

php
new CloverReport(__DIR__ . '/clover.xml', 'MyProject')

CoberturaReport

Generates a Cobertura XML report.

new CoberturaReport(string $outputPath, string $sourceRoot = '')

Files are grouped into <package> elements by directory, with relative paths from sourceRoot.

When branch data is available (CoverageLevel::Branchenum CoverageLevelDefines the depth of coverage analysis. Each successive level includes all data from the previous one.BranchLine + which branches (if/else, switch, ?:, ??) were taken. XDebug only. or higher):

  • branch-rate, branches-covered, branches-valid are populated at all levels (coverage, package, class).
  • Lines with branch points get branch="true" and condition-coverage="50% (1/2)" attributes.

Without branch data, all branch attributes are 0.

Compatible with: GitHub Actions, GitLab CI, Jenkins.

Parameters:

$outputPath
Absolute path to the output XML file.
$sourceRoot
Source root for relative file paths. Defaults to getcwd().

Examples:

php
new CoberturaReport(__DIR__ . '/cobertura.xml')

PhpUnitXmlReport

Generates a PHPUnit Coverage XML report — a directory with an index and one file per source.

new PhpUnitXmlReport(string $outputDir)

The format matches the output of phpunit --coverage-xml. Its main use case is mutation testing: Infection reads this exact format to determine which tests cover each line, so it can run only the relevant subset for every mutation.

Output directory layout:

  • <outputDir>/index.xml — overview with <file href="..."> links to each source file.
  • <outputDir>/<relative path>/<basename>.xml — per-source-file coverage with <covered by="..."> elements, where by is the test method that executed the line.

Only line coverage is recorded; the format does not support branch or path data. The directory is created automatically if it does not exist.

Compatible with: Infection.

Parameters:

$outputDir
Absolute path to the directory where the report files will be written. Created automatically if missing.

Examples:

php
new PhpUnitXmlReport(__DIR__ . '/coverage-xml')

CLI activation

The Codecov plugin is part of the default application plugin set in a "shadow" (inert) mode. Because of that, coverage can be enabled and its reports pointed at chosen paths straight from the command line, without touching testo.php. This is handy for CI, IDE plugins, and tools like Infection that need a report at a known path.

Flags

FlagEffect
--coverageForces coverage collection (mode CoverageMode::Alwaysenum CoverageModeControls whether coverage is collected.AlwaysCoverage is mandatory. If no extension is installed, tests will fail with a CoverageDriverNotAvailable\Testo\Codecov\Exception\CoverageDriverNotAvailable exception. Set by the --coverage CLI flag.); fails with CoverageDriverNotAvailable\Testo\Codecov\Exception\CoverageDriverNotAvailable (non-zero exit code) if no driver is available — even when no reports are requested. This makes a bare vendor/bin/testo run --coverage a handy CI check that a driver is available at all.
--no-coverageDisables coverage entirely (mode CoverageMode::Neverenum CoverageModeControls whether coverage is collected.NeverCoverage is fully disabled, zero overhead. Set by the --no-coverage CLI flag.). Highest priority.
--coverage-clover=<file>Writes a Clover report (CloverReportnew CloverReport(string $outputPath, string $projectName = '')Generates a Clover XML report.) to the given file.
--coverage-cobertura=<file>Writes a Cobertura report (CoberturaReportnew CoberturaReport(string $outputPath, string $sourceRoot = '')Generates a Cobertura XML report.) to the given file.
--coverage-xml=<dir>Writes a PHPUnit Coverage XML report (PhpUnitXmlReportnew PhpUnitXmlReport(string $outputDir)Generates a PHPUnit Coverage XML report — a directory with an index and one file per source.) to the given directory — the format Infection consumes.
bash
# Enable coverage and produce a Clover report — without a single line in testo.php
vendor/bin/testo run --coverage-clover=runtime/clover.xml

# Several reports at once
vendor/bin/testo run \
    --coverage-clover=runtime/clover.xml \
    --coverage-cobertura=runtime/cobertura.xml \
    --coverage-xml=runtime/coverage-xml

Soft activation

Specifying a report path (--coverage-clover, --coverage-cobertura, --coverage-xml) by itself enables coverage in CoverageMode::IfAvailableenum CoverageModeControls whether coverage is collected.IfAvailableDefault. Coverage is collected if an extension is available and configured, otherwise silently skipped. mode: if a driver (PCOV/XDebug) is present, the report is written; if not, the run silently proceeds without it. --no-coverage always wins and disables coverage, ignoring any report paths.

Working alongside a plugin from testo.php

If testo.php already registers its own CodecovPluginnew CodecovPlugin(CoverageLevel $level = CoverageLevel::Line, CoverageMode $collect = CoverageMode::IfAvailable, array $testTypes = [TestType::Test, TestType::TestInline], array $reports = [])Configures code coverage collection: analysis depth, activation mode, and report formats., the flag-driven reports do not conflict with it — they are merged into a single coverage collection:

  • coverage is collected once — no double overhead, no repeated driver-filter initialization;
  • the resulting analysis level is the deepest one requested, and test types are unioned;
  • all reports are generated — both those declared in testo.php and those passed via flags.

So an IDE plugin or Infection can run Testo with their own paths in parallel with whatever the user configured, without breaking anything.

Coverage Control

The src parameter in the ApplicationConfig\Testo\Application\Config\ApplicationConfig configuration defines the global set of files included in coverage. The #[Covers]#[Covers(string $classOrFunction, ?string $method = null)]Restricts which source code counts toward coverage for this test. and #[CoversNothing]#[CoversNothing]Excludes a test from coverage statistics. attributes allow fine-grained control over coverage for individual tests.

Global Filter

Includes and excludes are supported via FinderConfig\Testo\Application\Config\FinderConfig:

php
return new ApplicationConfig(
    src: new FinderConfig(
        include: ['src'],
        exclude: ['src/Generated'],
    ),
    // ...
);

Include only the directories you need in src to filter out unnecessary files before they are even loaded. This gives the best performance.

#[Covers]

Restricts which source code counts toward coverage for this test.

#[Covers(string $classOrFunction, ?string $method = null)]

Only lines belonging to the specified classes, traits, enums, methods, or functions will be included in the report. Everything else is discarded. The attribute is repeatable: multiple #[Covers]#[Covers(string $classOrFunction, ?string $method = null)]Restricts which source code counts toward coverage for this test. on the same test are combined.

When placed on a class, applies to all tests within it.

Parameters:

$classOrFunction
Fully qualified class, trait, enum (UserService::class, Cacheable::class, OrderStatus::class), or function name ('App\Helpers\format_name').
$method
Method name within the class, trait, or enum. When specified, only lines of that method are included, not the entire entity.

Examples:

Coverage for a class, trait, or enum — all executable lines:

php
#[Covers(UserService::class)]
public function testCreateUser(): void { ... }

#[Covers(Cacheable::class)]
public function testCacheableBehavior(): void { ... }

#[Covers(OrderStatus::class)]
public function testOrderStatusTransitions(): void { ... }

Coverage for a specific method — works with classes, traits, and enums:

php
#[Covers(UserService::class, 'create')]
public function testCreateUser(): void { ... }

#[Covers(Cacheable::class, 'invalidate')]
public function testCacheInvalidation(): void { ... }

#[Covers(OrderStatus::class, 'canTransitionTo')]
public function testStatusTransition(): void { ... }

Multiple targets — coverage is combined:

php
#[Covers(UserService::class)]
#[Covers(UserRepository::class, 'findById')]
public function testCreateUser(): void { ... }

#[CoversNothing]

Excludes a test from coverage statistics.

#[CoversNothing]

The test runs as usual, but the coverage driver is not started — zero overhead, no data is collected or included in reports. Useful for smoke tests and integration checks that touch a lot of code but shouldn't skew your coverage picture.

When placed on a class, applies to all tests within it.

Examples:

php
#[CoversNothing]
public function smokeTest(): void
{
    // Test runs, but coverage is not collected
    $response = $this->app->get('/health');
    Assert::same(200, $response->statusCode);
}

Attribute Priority

Coverage attributes are resolved layer by layer: the method is checked first, then the class. If the method has any coverage attribute, class-level attributes are ignored entirely. This allows overriding behavior in subclasses:

php
#[CoversNothing]
abstract class BaseIntegrationTest
{
    // By default, all tests in subclasses skip coverage
}

#[Covers(PaymentService::class)]
final class PaymentServiceTest extends BaseIntegrationTest
{
    // This subclass overrides the behavior — coverage is collected
    public function testCharge(): void { ... }
}

Metadata

Coverage data for each test is attached to the TestResult\Testo\Core\Context\TestResult metadata under the CoverageResult\Testo\Codecov\Result\CoverageResult key:

php
use Testo\Codecov\Result\CoverageResult;

$coverage = $testResult->getAttribute(CoverageResult::class);
// CoverageResult|null

This allows you to implement custom logic based on coverage data before results are reflected in the reports.

Which extension is better — PCOV or XDebug?

PCOV is faster and easier to set up, but only supports line coverage. XDebug is required for branch and path analysis. If CoverageLevel::Lineenum CoverageLevelDefines the depth of coverage analysis. Each successive level includes all data from the previous one.LineWhich source lines were executed. Supported by both PCOV and XDebug. is sufficient for your needs — use PCOV.

What happens if no coverage extension is installed?