Authoring a Testo plugin

SkillDatabases & data

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Authoring a Testo plugin skill

What this skill tells your AI

The instructions your AI receives, as published by php-testo/testo in skills/testo-plugin-author/SKILL.md and read by ahel’s review.

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.

llms.txt covers test authoring; this skill covers the plugin surface. Escalate to https://php-testo.github.io/llms-full.txt only for things not here. Verify type/namespace names against the installed vendor/testo/ 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

// 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:

$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():

$container->get(InterceptorCollector::class)->addInterceptor(new MyInterceptor(/* deps */));

Discovery stages, outermost first:

InterfaceMethodStage
SuiteLocatorInterceptorlocateTestSuites(ApplicationConfig $c, callable $next): arraythe suite list, once per session
FileLocatorInterceptorlocateFile(TokenizedFile $f, callable $next): ?boolone file, tokens only (not loaded yet)
CaseLocatorInterceptorlocateTestCases(FileDefinitions $f, callable $next): CaseDefinitionsone file, reflections available

Execution stages, outermost first:

InterfaceMethodWraps
TestSuiteRunInterceptorrunTestSuite(SuiteInfo $i, callable $next): SuiteResultone suite
TestCaseRunInterceptorrunTestCase(CaseInfo $i, callable $next): CaseResultone case (all its tests)
TestRunInterceptorrunTest(TestInfo $i, callable $next): TestResultone 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]

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 { … }
  • orderhigher = 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

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:

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.

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:

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'));
}

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.

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

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:

#[\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:

$method = $info->testDefinition->reflection;
$optedOut = $method->getAttributes(WithoutTransaction::class) !== [];

Pitfalls

  • Skipping: return a Status::Skipped TestResult; never throw SkipTest from an interceptor.
  • 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.

Signals

GitHub stars
213
Forks
16
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
testo-plugin-author
Source
github.com/php-testo/testo