Skip to content
Back to skills

Testo Coverage

ASecurity

Configure code coverage in Testo via CodecovPlugin, choose coverage level (Line/Branch/Path), wire up reports (Clover/Cobertura/PHPUnit XML), and use #[Covers] / #[CoversNothing] on tests. Use when the user asks about "code coverage", "clover", "cobertura", "infection coverage XML", or `#[Covers]`.

  • 222 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added August 31, 2026
toolsphpdockertestinggit

Works with

  • terminal
  • cli

Security analysis

A100/100

Scanned October 1, 2026

npx -y skills add php-testo/testo --skill testo-coverage --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Testo Coverage?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Testo Coverage
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/php-testo-testo-coverage/badge)](https://www.skillsdirectory.com/skills/php-testo-testo-coverage)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: testo-coverage
description: 'Configure code coverage in Testo via CodecovPlugin, choose coverage level (Line/Branch/Path), wire up reports (Clover/Cobertura/PHPUnit XML), and use #[Covers] / #[CoversNothing] on tests. Use when the user asks about "code coverage", "clover", "cobertura", "infection coverage XML", or `#[Covers]`.'
---

# Code coverage with Testo

Coverage is opt-in via the **`CodecovPlugin`** in `testo.php`. The plugin needs:

1. A coverage **level** — `Line`, `Branch`, or `Path` (each adds cost and information).
2. One or more **report writers** — Clover, Cobertura, PHPUnit XML.
3. Xdebug ≥ 3.1 (in coverage mode) **or** PCOV available on the runner. Without one of them, the plugin will skip. The coverage mode can be set via `xdebug.mode=coverage`, the `-d xdebug.mode=coverage` CLI flag, **or** the `XDEBUG_MODE=coverage` env var — Testo resolves the active mode with `xdebug_info('mode')`, so the env override (used by `composer infect` and IDE coverage runners) counts.

Exact class names and constructor parameters are authoritative in the installed `vendor/testo/codecov` —
verify against it before editing.

## Canonical wiring in `testo.php`

```php
use Testo\Application\Config\ApplicationConfig;
use Testo\Codecov\CodecovPlugin;
use Testo\Codecov\Config\CoverageLevel;
use Testo\Codecov\Report\CloverReport;
use Testo\Codecov\Report\CoberturaReport;
use Testo\Codecov\Report\PhpUnitXmlReport;

return new ApplicationConfig(
    src: ['src'],
    suites: [/* ... */],
    plugins: [
        new CodecovPlugin(
            level: CoverageLevel::Line,
            reports: [
                new CloverReport(__DIR__ . '/runtime/clover.xml', 'MyProject'),
                new CoberturaReport(__DIR__ . '/runtime/cobertura.xml'),
                new PhpUnitXmlReport(outputDir: __DIR__ . '/runtime/coverage-xml'),
            ],
        ),
    ],
);
```

Then enable on the CLI:

```
vendor/bin/testo --coverage
vendor/bin/testo --no-coverage   # explicit off, overrides config
```

`--coverage` makes coverage **mandatory** (`CoverageMode::Always`): the run aborts with
`CoverageDriverNotAvailable` (non-zero exit) when no Xdebug/PCOV driver is present — even with no
report flags. That makes a bare `vendor/bin/testo --coverage` a handy CI gate to assert the driver
is actually available. `--no-coverage` always wins over everything.

## CLI report flags (no `testo.php` needed)

A `CodecovPlugin` ships in the application defaults in **shadow** (inert) mode, so three flags let
external tools (the IDE plugin, Infection) pin report destinations **without any `testo.php` change**:

```
vendor/bin/testo --coverage-clover=build/clover.xml
vendor/bin/testo --coverage-cobertura=build/cobertura.xml
vendor/bin/testo --coverage-xml=build/coverage-xml      # directory, for Infection
```

- **Soft activation.** Passing any of these implies coverage collection *if a driver is available*;
  with no Xdebug/PCOV the run skips silently (no file). `--no-coverage` still wins and disables it.
- **Parallel with your config.** If `testo.php` already declares a `CodecovPlugin`, the flag-driven
  reports run **alongside** your configured ones — both sets of files are written. The two are
  **merged** into a single coverage collection (no double overhead): the deepest requested level
  wins, test-type filters are unioned, and every report (yours + the CLI ones) is emitted.
- The shadow stays fully inert when no report flag is present, so default behavior is unchanged.

## Choosing the level on the CLI

```
vendor/bin/testo --coverage-level=branch --coverage-clover=build/clover.xml
```

`--coverage-level` takes `line`, `branch` or `path` (case-insensitive) and **pins** the depth for the
whole run — it wins over every `CodecovPlugin(level: …)`, including when it asks for less. An unknown
value aborts the run rather than falling back.

Without the flag the configured levels are merged and the deepest wins, so a
`new CodecovPlugin(level: CoverageLevel::Branch)` in `testo.php` still applies to a run that only the
CLI report flags activated.

## Picking the coverage level

| Level | Cost | When |
|---|---|---|
| `Line` | Low | Default for CI gates. |
| `Branch` | Medium | When you need to be sure `if`/`match`/`?:` branches are exercised. |
| `Path` | High | Mutation testing setup, exhaustive analysis. Usually local-only. |

Don't ship `Path` on every CI run — it's the slowest. Reserve it for mutation testing or scheduled jobs.

**With fibers (`#[RunInFiber]`), use `Line` only.** `Branch` and `Path` enable Xdebug's branch
analysis, which corrupts memory and crashes the process when it runs inside a fiber — no PHP error, no
report. The bug persists in current Xdebug builds (reproduced on 3.5.3). Testo tries to stop such a
test with `BranchCoverageUnsafeInFiber`, but the guard is tied to the Xdebug version and doesn't fire
on every build — don't rely on it. PCOV is unaffected (it only does `Line` anyway).

## `#[Covers]` and `#[CoversNothing]`

Declare which production classes a test exercises. This scopes coverage reports and surfaces dead tests.

```php
use Testo\Codecov\Covers;
use Testo\Codecov\CoversNothing;

#[Test]
#[Covers(UserService::class)]              // class-level — applies to every test in the class
final class UserServiceTest { /* ... */ }

#[Test]
#[Covers(OrderTotal::class)]
#[Covers(TaxCalculator::class)]            // repeatable: multiple covered targets
final class CheckoutTest { /* ... */ }

#[Test]
#[CoversNothing]                            // explicitly exclude from coverage attribution
final class SmokeTest { /* ... */ }
```

Rules (this is project policy in many Testo codebases — confirm before changing):

- **Class-level `#[Covers]`** when every test in the class covers the same production class. This is the default.
- **Method-level `#[Covers]`** when tests in the same class cover different classes.
- Free functions: pass the FQN as a string — `#[Covers('App\\helpers\\format_money')]`.

### Tests a plugin runs: `CoverageScope`

A plugin whose tests have no method of their own to carry `#[Covers]` (a harness running fixtures through one shared probe) attaches a `Testo\Codecov\CoverageScope` to the `TestInfo` from an interceptor outer to coverage (`order` below `InterceptorOptions::ORDER_COVERAGE`):

```php
return $next($info->withAttribute(CoverageScope::class, new CoverageScope(new Covers(Rule::class))));
```

The scope is a default — `#[Covers]` / `#[CoversNothing]` on the test method or its class still win — and it opts the test into collection whatever its type, so it needs no `testTypes` entry in `CodecovPlugin`. `testo/bridge-rector`'s harness scopes each rule's fixtures to the rule this way.

## Reports cheat-sheet

| Report | Format id | Typical consumer |
|---|---|---|
| `CloverReport` | `clover` | Codecov, Coveralls, GitHub coverage diffs. |
| `CoberturaReport` | `cobertura` | GitLab/Jenkins coverage UI. |
| `PhpUnitXmlReport` | `coverage-xml` | **Infection** (mutation testing). |

For Infection, point `infection.json`'s `coverage.path` at the directory you gave to `PhpUnitXmlReport`.

Every written report is announced, not printed: the run dispatches
`Testo\Event\Report\ReportFileGenerating` once it knows coverage will be collected and
`ReportFileGenerated` after the file is written, and whichever renderer owns stdout states it — a plain
line in a terminal, a `##teamcity[testoReport …]` service message under `--teamcity`. The format id in
the table is what a consumer switches on.

`CoverageReport` therefore has two methods: `generate()` and `info(): ReportInfo`
(`Testo\Core\Report\ReportInfo` — format, label, and a `Stringable` location). The location is what a
consumer opens: for a report that fills a directory, the index inside it rather than the directory; for
one that uploads its data, whatever URL it lands on.

A report that cannot write its target throws `Testo\Codecov\Exception\CoverageReportNotWritten`, naming
the path and the filesystem's reason. The remaining reports are still written, then the run exits
non-zero. A custom report should throw the same exception on a failed write.

```php
final readonly class MyReport implements CoverageReport
{
    public function __construct(private string $path) {}

    public function generate(CoverageResult $result): void { /* write $this->path */ }

    public function info(): ReportInfo
    {
        return new ReportInfo('my-format', 'My coverage', Path::create($this->path));
    }
}
```

## Pitfalls

- No coverage written? Check the active Xdebug mode includes `coverage` — set it via `xdebug.mode`, `-d xdebug.mode=coverage`, or `XDEBUG_MODE=coverage` (or load PCOV). Testo skips the driver if neither is available.
- A report landed in the project root under a name like `C:\Users\...`? A Windows path reached a Linux PHP (WSL, Docker): it has no `/`, so it reads as a relative file name. Pass the path as the interpreter sees it (`/mnt/c/...`).
- `clover.xml` empty? Suite-level finder probably excludes the `src` directory you expected — verify the `FinderConfig` covers it.
- Don't enable coverage in benchmark suites — it falsifies timings.
- Coverage under `#[RunInFiber]` costs an extra driver stop/start per suspension (the window is closed
  around every fiber switch so each test keeps its own lines). Suspension-heavy tests pay for it — a
  reason to keep coverage runs on `Line` and off the interleaving-heavy suites.
- Don't write `#[Covers(SomeInterface::class)]` — point at concrete classes that own the executable code.
- Don't combine `#[Covers]` and `#[CoversNothing]` on the same class/method — pick one.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.