Author a Testo plugin — interceptors (middleware), event listeners, container bindings, custom attributes, or full test-environment provisioning. Use when the user wants to extend how Testo runs tests (wrap every test, provision a database/service, custom reporters, attribute-driven behaviour, integrating an external system) rather than writing a single test.
Installs into .claude/skills of the current project.
Are you the author of Testo Plugin Author?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/php-testo-testo-plugin-author)
---
name: testo-plugin-author
description: Author a Testo plugin — interceptors (middleware), event listeners, container bindings, custom attributes, or full test-environment provisioning. Use when the user wants to extend how Testo runs tests (wrap every test, provision a database/service, custom reporters, attribute-driven behaviour, integrating an external system) rather than writing a single test.
---
# Authoring a Testo plugin
A Testo plugin is a class implementing `Testo\Common\PluginConfigurator`. Its one method,
`configure(Container $container)`, runs **once per suite** and wires the plugin into that suite's DI
container. From there a plugin can:
- Register **interceptors** (middleware) that wrap test / test-case / suite execution and discovery,
from the suite list down to a single file.
- Subscribe to lifecycle **events** (PSR-14) — `TestFinished`, `TestSuiteStarting`, …
- **Bind / scope services** in the container — provision resources, replace Testo defaults.
- Define and act on **custom attributes** placed on test classes/methods.
`testo-write-tests` covers test authoring; this skill covers the plugin surface. For anything not
here, read the installed source under `vendor/testo/`. **Verify type/namespace names against it
before relying on memory** — the APIs below are stable but version-specific.
## Going deeper
- Building a **reporter** — a TeamCity/IDE consumer, a report-file writer, anything keyed by *which
test* an event belongs to → read `references/reporting.md` (identity fields, TeamCity protocol
details, the `ReportFileGenerating`/`ReportFileGenerated` announcement events).
- **Provisioning an external resource** (database, service) per case/test → full worked plugin in
`references/database-plugin-example.md`.
## Vocabulary (get this right)
- **Test** — one `#[Test]` method / function / inline case.
- **Test Case** — file-scope group: the methods of one class (or functions of one file).
- **Test Suite** — a configured collection of cases (`SuiteConfig`). **The suite is the smallest unit
a plugin applies to.** Each suite gets its own container, so different suites can run different plugins.
Pipeline events fire top-down: `Session → Worker → TestSuite → TestCase → TestPipeline → TestBatch → Test`.
## Registering a plugin
```php
// testo.php
new SuiteConfig(
name: 'Acceptance',
location: new FinderConfig(include: ['tests/Acceptance/Driver']),
plugins: [new DatabasePlugin()], // this suite only
);
// or ApplicationConfig(plugins: [...]) for every suite (coverage, JUnit, …)
```
## The container
`configure()` receives `Internal\Container\Container`, which **extends PSR-11
`Psr\Container\ContainerInterface`** — so the container itself can be handed to anything expecting a
PSR-11 container (e.g. a framework Facade). Its surface:
```php
$c->get(Foo::class, $args = []); // resolve (lazy-instantiate + cache); $args used on first build
$c->has(Foo::class): bool; // is there a binding or cached instance?
$c->set($instance, Foo::class); // register an existing instance under an id
$c->make(Foo::class, $args = []); // build WITHOUT caching
$c->bind(Foo::class, fn(Container $c) => …); // factory / alias / arg-array for lazy construction
$c->scope(fn(Container $scope) => …); // run a closure in a CHILD scope (see below)
```
> Importing `Internal\Container\Container` is unavoidable — it is the declared `configure()` parameter
> type. Otherwise avoid `Internal\*` types; prefer `Testo\*`.
## Interceptors (middleware) — the main tool
Interceptors wrap execution at a chosen pipeline level. All live in `Testo\Pipeline\Middleware`; a
single class may implement several. Register them in `configure()`:
```php
$container->get(InterceptorCollector::class)->addInterceptor(new MyInterceptor(/* deps */));
```
Discovery stages, outermost first:
| Interface | Method | Stage |
|---------------------------|-------------------------------------------------------------------------------|------------------------------------------------|
| `SuiteLocatorInterceptor` | `locateTestSuites(ApplicationConfig $c, callable $next): array` | the suite list, once per session |
| `FileLocatorInterceptor` | `locateFile(TokenizedFile $f, callable $next): ?bool` | one file, tokens only (not loaded yet) |
| `CaseLocatorInterceptor` | `locateTestCases(FileDefinitions $f, callable $next): CaseDefinitions` | one file, reflections available |
Execution stages, outermost first:
| Interface | Method | Wraps |
|---------------------------|-------------------------------------------------------------------------------|--------------------------|
| `TestSuiteRunInterceptor` | `runTestSuite(SuiteInfo $i, callable $next): SuiteResult` | one suite |
| `TestCaseRunInterceptor` | `runTestCase(CaseInfo $i, callable $next): CaseResult` | one case (all its tests) |
| `TestRunInterceptor` | `runTest(TestInfo $i, callable $next): TestResult` | one test |
`$next` is the rest of the chain (and ultimately the test). **Always call it once** unless you are
deliberately short-circuiting (see skipping). Use `try { return $next($i); } finally { … }` for
cleanup — later interceptors can throw.
`locateTestSuites` returns the `list<SuiteConfig>` to run: call `$next($c)` to get the configured
suites, then drop, reorder, narrow (`$suite->with(location: …)`), duplicate, or add entries. It runs
**before any suite container exists**, so it is only seen when registered by an application-level
plugin (`ApplicationConfig(plugins: [...])`); a suite-level plugin registering it is silently ignored.
`locateFile` answers `true` (load the file), `false` (never load it), or `null` (let the next
interceptor decide); the file is not loaded yet, so judge by path and tokens only.
### Ordering & scoping with `#[InterceptorOptions]`
```php
use Testo\Pipeline\Attribute\InterceptorOptions;
use Testo\Core\Value\TestType;
#[InterceptorOptions(order: InterceptorOptions::ORDER_CLOSE_TO_TEST, testType: TestType::Test)]
final readonly class MyInterceptor implements TestRunInterceptor { … }
```
- `order` — **higher = closer to the test**. Constants: `ORDER_FILTER`, `ORDER_DATA_PROVIDER`,
`ORDER_DEFAULT` (0), `ORDER_ASSERTIONS`, `ORDER_CLOSE_TO_TEST`, `ORDER_RIGHT_BEFORE_TEST`.
- `testType` — restrict to a kind of test (e.g. `TestType::Test`, not benches/inline). Omit to apply to all.
### Context objects you read/return
```php
TestInfo { string $name; CaseInfo $caseInfo; TestDefinition $testDefinition;
array $arguments; array $attributes; TestIdentity $identity; }
// testDefinition->reflection: ReflectionFunctionAbstract
CaseInfo { CaseDefinition $definition; ?CaseInstance $instance; array $attributes;
CaseIdentity $identity; } // definition->reflection: ?ReflectionClass
SuiteInfo { string $name; CaseDefinitions $testCases; array $attributes; SuiteIdentity $identity; }
TestResult{ TestInfo $info; Status $status; mixed $result; ?\Throwable $failure; … }
```
Read the test method via `$info->testDefinition->reflection`; the test class via
`$info->caseInfo->definition->reflection` (null for function-based cases — guard it).
Every context object also carries an `identity` — the test's stable address (`fqn()`, suite, case,
data-set index) plus process-local run ids (`runtimeId`, `pipelineId`, `parentId`). Anything that keys
state per test — a reporter, a channel grouping, a tree consumer — must key by those ids, not by a
"current test" field: see `references/reporting.md`.
### Passing state down the pipeline — prefer attributes over mutable fields
`TestInfo`, `CaseInfo`, and `TestResult` use the `Attributed` trait: `withAttribute(string $name,
mixed $value): static` and `getAttribute(string $name, mixed $default = null)`. Attach state in
`runTestCase`, read it in `runTest` — a `CaseInfo` modified and passed to `$next` reaches each test as
`$info->caseInfo`:
```php
public function runTestCase(CaseInfo $info, callable $next): CaseResult {
$fixture = $this->buildFixture(...);
return $next($info->withAttribute('db.fixture', $fixture));
}
public function runTest(TestInfo $info, callable $next): TestResult {
$fixture = $info->caseInfo->getAttribute('db.fixture');
…
}
```
This keeps the interceptor **stateless** — safer than mutable `$this->current` fields. A container
`scope` (below) is an even cleaner carrier when the state is a set of services.
The same channel scopes coverage for tests your plugin synthesizes: attach a `Testo\Codecov\CoverageScope`
under its class name — see `testo-coverage`.
### Skipping from an interceptor — return, do not throw
`throw new SkipTest(...)` only works inside the test body; from an interceptor it bubbles past the
handler and becomes `Status::Aborted`. To skip, **return a `TestResult` without calling `$next`**:
```php
use Testo\Core\Value\Status;
use Testo\Core\Exception\SkipTest;
if (!$reachable) {
return new TestResult(info: $info, status: Status::Skipped, failure: new SkipTest('db down'));
}
```
### Skipping ahead of the run — flag the definition
At **case** level, returning a `CaseResult` without `$next` drops the case whole — its
`#[BeforeClass]`/`#[AfterClass]` hooks and every test it still had to run. When the decision is known
before the run (an attribute, a static rule), do not skip from a case interceptor at all: flag the
tests instead, from a `CaseLocatorInterceptor`, with `$definition->skipped = true` on each of them
(`TestDefinition::$skipped`). A flagged test stays active, so the case keeps it and reports it: the
core returns a `Status::Skipped` result at the entry of its pipeline (no `TestStarting`, no body),
the lifecycle plugin runs no per-test hooks for it, and a case whose active tests are all flagged
gets no class-level hooks either. To attach a reason, pair the flag with a per-test interceptor that
returns the `Skipped` result itself, ordered outer to data providers and fibers
(`InterceptorOptions::ORDER_FILTER + 1_000` is the slot `#[Skip]` uses).
The shipped implementation of exactly this shape is `plugin/skip`: `SkipLocatorInterceptor` flags,
`SkipInterceptor` reports with the reason, serving the `#[Skip]` attribute (whose contract is in the
`testo-write-tests` skill). Read them as a reference — they are `@internal` (and `final`), don't
import them.
## Container scopes — provision per-case / per-suite resources
`$container->scope($closure)` runs `$closure` in a **child scope**: services bound inside live only for
the closure, and `$container` resolves the active scope while it runs. This is the clean way to build a
resource once, expose it (even to a PSR-11 consumer), and tear it down automatically.
```php
public function runTestCase(CaseInfo $info, callable $next): CaseResult {
return $this->container->scope(function (Container $scope) use ($info, $next) {
$service = $this->build(...);
$scope->set($service, Service::class); // visible to this case's tests
try {
return $next($info); // tests run inside the scope
} finally {
// scope (and its bindings) discarded automatically afterwards
}
});
}
public function runTest(TestInfo $info, callable $next): TestResult {
if (!$this->container->has(Service::class)) { /* scope not opened → skip or next */ }
$service = $this->container->get(Service::class); // same instance, no rebuild per test
…
}
```
Because the container resolves the **current** scope, `runTest` reads back exactly what `runTestCase`
bound — no need to thread the objects through attributes. Use this to build expensive things **once per
case** and only do cheap per-test work (e.g. reset state) in `runTest`.
## Event listeners — observe, don't mutate
```php
use Testo\Common\EventListenerCollector;
use Testo\Event\Test\TestFinished;
public function configure(Container $container): void {
$logger = $container->get(MyLogger::class); // resolve deps HERE
$container->get(EventListenerCollector::class)
->addListener(TestFinished::class, static fn(TestFinished $e) => $logger->record($e));
}
```
Listeners are observers. To **change** behaviour (skip, wrap, retry, inject), write an interceptor.
**Never capture `$container` inside the listener closure** — resolve services in `configure()` and close
over those.
`addListener()` takes a third `int $priority` argument, highest first. It matters when several listeners
share one event and the order is part of the behaviour.
Writing a report file? Announce it via `ReportFileGenerating`/`ReportFileGenerated` instead of printing
anything yourself — the flow, the `ReportInfo` card, and the priority trick for large files are in
`references/reporting.md`.
## Custom attributes
Define the attribute, then act on it from an interceptor:
```php
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
final readonly class WithoutTransaction {}
```
Read it hierarchy-aware with `Testo\Common\Reflection` (walks parents + traits; `MERGE_FIRST` =
closest layer wins, like `#[Covers]` resolution), or plain reflection for the simple case:
```php
$method = $info->testDefinition->reflection;
$optedOut = $method->getAttributes(WithoutTransaction::class) !== [];
```
An attribute can also bring its own interceptor, so users need no plugin registration at all —
`#[Retry]`, `#[Repeat]` and `#[Skip]` ship this way. Implement `Testo\Pipeline\Attribute\Interceptable`
and name the handler with `#[FallbackInterceptor(MyInterceptor::class)]` (repeatable — one per
pipeline position); the core instantiates the interceptor with the attribute instance as a constructor
argument when the attribute is found on a class (case and test pipelines) or on a test (test pipeline
only). A test-level attribute never reaches the case pipeline: whatever has to be known about the
case ahead of the run (a skipped test, say) is flagged on the definitions from a
`CaseLocatorInterceptor` registered by a plugin instead.
## Pitfalls
- **Skipping**: return a `Status::Skipped` `TestResult`; never `throw SkipTest` from an interceptor.
Never return early from a **case** interceptor to skip — flag the definitions as `skipped` from a
locator instead, and let the core report them.
- **Cleanup**: wrap `$next()` in `try/finally`; a later interceptor may throw.
- **State**: prefer pipeline attributes / container scope over mutable interceptor fields.
- **Listeners** observe; **interceptors** change behaviour. Don't try to alter a run from a listener.
- **Don't capture `$container` in listener closures** — resolve and inject in `configure()`.
- **Don't write a plugin to fix one test** — a `#[BeforeTest]` hook in that class is enough. Reach for a
plugin when behaviour spans every test of a suite.
- **Test the plugin**: mirror Testo's own `plugin/<name>/tests/` layout so it can be extracted later.