Плагины
Плагин в Testo — это независимый модуль, отвечающий за конкретную функциональность фреймворка. Обнаружение тестов, проверки (Assert), жизненный цикл (Lifecycle), бенчмарки (Bench), фильтрация (Filter) — всё это отдельные плагины. Чем больше плагинов подключено, тем больше возможностей. Любой из них можно отключить, заменить или дополнить собственным.
Плагин может состоять из конфигуратора, интерцепторов, атрибутов, слушателей событий — в любой комбинации. Например, плагин Assert использует конфигуратор для регистрации интерцепторов, а Retry обходится без конфигуратора и работает исключительно через атрибут или интерцептор.
Конфигуратор плагина
Конфигуратор — это класс, реализующий интерфейс PluginConfigurator\Testo\Common\PluginConfigurator:
interface PluginConfigurator
{
public function configure(Container $container): void;
}При загрузке модуля вызывается метод configure(), в который передаётся внутренний DI-контейнер Testo. Через него конфигуратор получает доступ к API фреймворка. Конфигураторы подключаются на двух уровнях: приложения и Test Suite — подробности и примеры на странице Конфигурация — Плагины.
Основных точек расширения три:
Интерцепторы
Интерцепторы (перехватчики) — это мидлвари, которые встраиваются в пайплайны поиска и выполнения тестов. Подробнее о пайплайнах и интерцепторах в разделе перехватчики.
Интерцепторы регистрируются через InterceptorCollector\Testo\Pipeline\InterceptorCollector:
$container->get(InterceptorCollector::class)->addInterceptor(new MyInterceptor());Слушатели событий
Testo генерирует события на каждом этапе выполнения: старт и завершение сессии, Test Suite, Test Case и отдельных тестов. Конфигуратор может подписаться на любое из этих событий через EventListenerCollector\Testo\Common\EventListenerCollector:
$container->get(EventListenerCollector::class)->addListener(
TestFinished::class,
function (TestFinished $event) {
// Реакция на завершение теста
},
);Этот механизм использует стандарт PSR-14 с одним лишь ограничением: события всегда иммутабельны. Это ограничение рекомендуется применять и к пользовательским событиям, если вы решите их создавать.
Подробнее о доступных событиях — на странице События.
Биндинги в контейнере
Конфигуратор может регистрировать сервисы в DI-контейнере, которые затем будут доступны интерцепторам и другим компонентам фреймворка.
Каждый Test Suite запускается в собственной области видимости контейнера. Это значит, что биндинги и закешированные сервисы из конфигуратора уровня Test Suite изолированы — разные Test Suite не будут делить состояние.
// Фабрика — сервис создаётся лениво при первом обращении
$container->bind(MyService::class, static fn(Container $c) => new MyService(
$c->get(EventDispatcherInterface::class),
));
// Готовый экземпляр — сразу доступен через get()
$container->set(new MyConfig(timeout: 30));
// Получение сервиса из контейнера
$dispatcher = $container->get(EventDispatcherInterface::class);
// Создание экземпляра без сохранения в контейнере
$handler = $container->make(MyHandler::class, ['verbose' => true]);Создание плагина
Логгер упавших тестов
Допустим, вы хотите логировать информацию о каждом упавшем тесте в файл. Для этого достаточно создать конфигуратор, который слушает событие TestPipelineFinished\Testo\Event\Test\TestPipelineFinished и записывает результат:
use Internal\Container\Container;
use Testo\Common\EventListenerCollector;
use Testo\Common\PluginConfigurator;
use Testo\Event\Test\TestPipelineFinished;
final readonly class FailureLoggerPlugin implements PluginConfigurator
{
public function __construct(
private string $logFile = 'test-failures.log',
) {}
#[\Override]
public function configure(Container $container): void
{
$container->get(EventListenerCollector::class)->addListener(
TestPipelineFinished::class,
$this->onTestFinished(...),
);
}
private function onTestFinished(TestPipelineFinished $event): void
{
if (!$event->testResult->status->isFailure()) {
return;
}
$line = \sprintf(
"[%s] %s %s::%s: %s\n",
\date('Y-m-d H:i:s'),
\strtoupper($event->testResult->status->name),
$event->testInfo->caseInfo->definition->reflection?->getName(),
$event->testInfo->testDefinition->reflection->getName(),
\str_replace("\n", ' ', $event->testResult->failure?->getMessage() ?? 'unknown'),
);
\file_put_contents($this->logFile, $line, \FILE_APPEND);
}
}Несколько моментов, на которые стоит обратить внимание:
- Конфигуратор принимает параметр
$logFileв конструкторе. Это позволяет настраивать поведение при регистрации в конфигурации. - Событие TestPipelineFinished
\Testo\Event\Test\TestPipelineFinishedсрабатывает после прохождения всех интерцепторов, поэтому содержит финальный результат теста. - Метод
$event->testResult->status->isFailure()возвращаетtrueдля статусовFailedиError.
Отчёт о Flaky в Pull Request
Давайте напишем плагин, который собирает flaky-тесты (прошедшие только после повторной попытки через Retry) и оставляет комментарий в GitHub PR с их списком:
use Internal\Container\Container;
use Testo\Common\EventListenerCollector;
use Testo\Common\PluginConfigurator;
use Testo\Core\Value\Status;
use Testo\Event\Framework\SessionFinished;
use Testo\Event\Test\TestPipelineFinished;
final class FlakyPRCommentPlugin implements PluginConfigurator
{
#[\Override]
public function configure(Container $container): void
{
// Check that we're in GitHub Actions and this is a PR
$token = \getenv('GITHUB_TOKEN');
$repo = \getenv('GITHUB_REPOSITORY'); // owner/repo
$ref = (string) \getenv('GITHUB_REF'); // refs/pull/123/merge
if (!$token || !$repo || !\preg_match('#^refs/pull/(\d+)/#', $ref, $m)) {
return; // not in CI or not a PR — do nothing
}
$prNumber = $m[1];
$listeners = $container->get(EventListenerCollector::class);
$listeners->addListener(TestPipelineFinished::class, $this->onTestFinished(...));
$listeners->addListener(
SessionFinished::class,
fn(SessionFinished $e) => $this->postComment($token, $repo, $prNumber),
);
}
/** @var list<string> */
private array $flakyTests = [];
private function onTestFinished(TestPipelineFinished $event): void
{
if ($event->testResult->status !== Status::Flaky) {
return;
}
$case = $event->testInfo->caseInfo->definition->reflection?->getShortName();
$test = $event->testInfo->testDefinition->reflection->getName();
$this->flakyTests[] = $case === null ? "{$test}()" : "{$case}::{$test}()";
}
private function postComment(string $token, string $repo, string $prNumber): void
{
if ($this->flakyTests === []) {
return;
}
$list = \implode("\n", \array_map(
static fn(string $name) => "- `{$name}`",
$this->flakyTests,
));
@\file_get_contents(
"https://api.github.com/repos/{$repo}/issues/{$prNumber}/comments",
context: \stream_context_create([
'http' => [
'method' => 'POST',
'header' => \implode("\r\n", [
"Authorization: Bearer {$token}",
'Content-Type: application/json',
'User-Agent: Testo',
]),
'content' => \json_encode([
'body' => "⚠️ **Flaky tests detected**\n\n{$list}",
]),
],
]),
);
}
}Конфигуратор подписывается на два события: TestPipelineFinished\Testo\Event\Test\TestPipelineFinished для сбора нестабильных тестов и SessionFinished\Testo\Event\Framework\SessionFinished для отправки комментария в конце сессии. При локальном запуске переменные окружения отсутствуют, поэтому configure() завершается досрочно и слушатели не регистрируются — плагин просто ничего не делает.
Этот плагин нужно регистрировать на уровне приложения (ApplicationConfig::$plugins), а не Test Suite: событие SessionFinished\Testo\Event\Framework\SessionFinished выбрасывается вне области видимости Test Suite.
return new ApplicationConfig(
suites: [...],
plugins: [
new FlakyPRCommentPlugin(),
],
);Демонстрация работы этого плагина в php-testo/testo#107.
Как настроить GitHub Actions для этого плагина?
Дайте workflow разрешение на запись в PR и прокиньте токен в шаге запуска тестов:
on: [ 'pull_request' ]
permissions:
pull-requests: write
jobs:
tests:
steps:
- run: vendor/bin/testo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Плагины без конфигуратора
Не все плагины требуют конфигуратора. Некоторые, например Retry и Data, работают исключительно через PHP-атрибуты с активацией интерцепторов. Подробнее об этом механизме читайте на странице перехватчиков.