Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 58 additions & 7 deletions plugin/lifecycle/src/Internal/LifecycleInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@

namespace Testo\Lifecycle\Internal;

use Testo\Common\Messenger;
use Testo\Common\Reflection;
use Testo\Core\Context\CaseInfo;
use Testo\Core\Context\CaseResult;
use Testo\Core\Context\TestInfo;
use Testo\Core\Context\TestResult;
use Testo\Core\Definition\CaseDefinition;
use Testo\Core\Definition\CaseDefinitions;
use Testo\Core\Log\Level;
use Testo\Core\Value\CaseInstance;
use Testo\Core\Value\Status;
use Testo\Core\Value\TestType;
use Testo\Lifecycle\AfterClass;
use Testo\Lifecycle\AfterTest;
Expand Down Expand Up @@ -49,6 +52,10 @@
TestRunInterceptor,
TestCaseRunInterceptor
{
public function __construct(
private Messenger $messenger,
) {}

#[\Override]
public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions
{
Expand Down Expand Up @@ -101,6 +108,10 @@ public function runTestCase(CaseInfo $info, callable $next): CaseResult
}
}

/**
* A hook failure makes the test an {@see Status::Error}, never an abort, and never replaces the failure
* of a failed test; hook throwables that do not become the failure go to {@see Messenger::CHANNEL_STDERR}.
*/
#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
Expand All @@ -110,17 +121,29 @@ public function runTest(TestInfo $info, callable $next): TestResult
return $next($info);
}

foreach ($hooks[BeforeTest::class] ?? [] as $hook) {
self::execute($info->caseInfo->instance, $hook);
}
$instance = $info->caseInfo->instance;
$errors = self::executeAll($instance, $hooks[BeforeTest::class] ?? [], stopOnError: true);

$result = null;
try {
return $next($info);
$errors === [] and $result = $next($info);
} finally {
foreach ($hooks[AfterTest::class] ?? [] as $hook) {
self::execute($info->caseInfo->instance, $hook);
}
$errors = [...$errors, ...self::executeAll($instance, $hooks[AfterTest::class] ?? [])];
}

$failure = $result?->status->isFailure() ? null : \array_shift($errors);
$this->report(...$errors);

return match (true) {
$failure === null => $result,
$result === null => new TestResult(
info: $info,
status: Status::Error,
failure: $failure,
attributes: ['description' => $info->testDefinition->getDescription()],
),
default => $result->with(status: Status::Error)->withFailure($failure),
};
}

/**
Expand Down Expand Up @@ -190,6 +213,27 @@ private static function collectHooks(CaseDefinition $definition): array
return $hooks;
}

/**
* @param list<\ReflectionFunctionAbstract> $hooks
* @return list<\Throwable> In execution order.
*/
private static function executeAll(?CaseInstance $instance, array $hooks, bool $stopOnError = false): array
{
$errors = [];
foreach ($hooks as $hook) {
try {
self::execute($instance, $hook);
} catch (\Throwable $e) {
$errors[] = $e;
if ($stopOnError) {
break;
}
}
}

return $errors;
}

private static function execute(?CaseInstance $instance, \ReflectionFunctionAbstract $reflection): void
{
if ($reflection instanceof \ReflectionMethod) {
Expand All @@ -200,4 +244,11 @@ private static function execute(?CaseInstance $instance, \ReflectionFunctionAbst
\assert($reflection instanceof \ReflectionFunction);
$reflection->invoke();
}

private function report(\Throwable ...$errors): void
{
foreach ($errors as $error) {
$this->messenger->log(Messenger::CHANNEL_STDERR, "Lifecycle hook failed: {$error}", Level::Error);
}
}
}
2 changes: 1 addition & 1 deletion plugin/lifecycle/src/LifecyclePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@
#[\Override]
public function configure(Container $container): void
{
$container->get(InterceptorCollector::class)->addInterceptor(new LifecycleInterceptor());
$container->get(InterceptorCollector::class)->addInterceptor(LifecycleInterceptor::class);
}
}
72 changes: 72 additions & 0 deletions plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Feature;

use Testo\Assert;
use Testo\Assert\State\Assertion\ComparisonFailure;
use Testo\Codecov\Covers;
use Testo\Common\Messenger;
use Testo\Core\Context\TestResult;
use Testo\Core\Value\Status;
use Testo\Lifecycle\Internal\LifecycleInterceptor;
use Testo\Test;
use Testo\Testing\Attribute\TestingSuite;
use Testo\Testing\Helper\TestRunner;
use Tests\Lifecycle\Stub\HookFailure\FailedTestTeardownStub;
use Tests\Lifecycle\Stub\HookFailure\PassedTestTeardownStub;
use Tests\Lifecycle\Stub\HookFailure\SetupFailureStub;

#[Test]
#[Covers(LifecycleInterceptor::class)]
#[TestingSuite(path: __DIR__ . '/../Stub/HookFailure')]
final class HookFailureFeatureTest
{
public function teardownFailureKeepsTheFailureOfAFailedTest(): void
{
$result = TestRunner::runTest([FailedTestTeardownStub::class, 'fails']);

Assert::same($result->status, Status::Failed);
Assert::instanceOf($result->failure, ComparisonFailure::class);
Assert::string(self::stderr($result))->contains('teardown of a failed test');
}

public function teardownFailureTurnsAPassedTestIntoAnError(): void
{
$calls = PassedTestTeardownStub::$afterTestCalls;

$result = TestRunner::runTest([PassedTestTeardownStub::class, 'passes']);

Assert::same($result->status, Status::Error);
Assert::same($result->failure?->getMessage(), 'first teardown');
Assert::same(PassedTestTeardownStub::$afterTestCalls - $calls, 2);
Assert::string(self::stderr($result))
->contains('second teardown')
->notContains('first teardown');
}

public function setupFailureSkipsTheBodyButRunsTheTeardown(): void
{
$secondSetUp = SetupFailureStub::$secondSetUpCalls;
$body = SetupFailureStub::$bodyCalls;
$afterTest = SetupFailureStub::$afterTestCalls;

$result = TestRunner::runTest([SetupFailureStub::class, 'body']);

Assert::same($result->status, Status::Error);
Assert::same($result->failure?->getMessage(), 'setup');
Assert::same(SetupFailureStub::$secondSetUpCalls - $secondSetUp, 0);
Assert::same(SetupFailureStub::$bodyCalls - $body, 0);
Assert::same(SetupFailureStub::$afterTestCalls - $afterTest, 1);
Assert::string(self::stderr($result))->contains('teardown after a failed setup');
}

private static function stderr(TestResult $result): string
{
return \implode("\n", \array_map(
static fn($message): string => $message->content,
$result->messages->channel(Messenger::CHANNEL_STDERR),
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Stub\HookFailure;

use Testo\Assert;
use Testo\Lifecycle\AfterTest;
use Testo\Test;

final class FailedTestTeardownStub
{
#[AfterTest]
public function tearDown(): void
{
throw new \RuntimeException('teardown of a failed test');
}

#[Test]
public function fails(): void
{
Assert::same(1, 2);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Stub\HookFailure;

use Testo\Assert;
use Testo\Lifecycle\AfterTest;
use Testo\Test;

/**
* Static counters accumulate across directory runs — feature tests assert deltas.
*/
final class PassedTestTeardownStub
{
public static int $afterTestCalls = 0;

#[AfterTest(priority: 1)]
public function firstTearDown(): void
{
++self::$afterTestCalls;
throw new \RuntimeException('first teardown');
}

#[AfterTest]
public function secondTearDown(): void
{
++self::$afterTestCalls;
throw new \RuntimeException('second teardown');
}

#[Test]
public function passes(): void
{
Assert::true(true);
}
}
46 changes: 46 additions & 0 deletions plugin/lifecycle/tests/Stub/HookFailure/SetupFailureStub.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Stub\HookFailure;

use Testo\Assert;
use Testo\Lifecycle\AfterTest;
use Testo\Lifecycle\BeforeTest;
use Testo\Test;

/**
* Static counters accumulate across directory runs — feature tests assert deltas.
*/
final class SetupFailureStub
{
public static int $secondSetUpCalls = 0;
public static int $bodyCalls = 0;
public static int $afterTestCalls = 0;

#[BeforeTest(priority: 1)]
public function firstSetUp(): void
{
throw new \RuntimeException('setup');
}

#[BeforeTest]
public function secondSetUp(): void
{
++self::$secondSetUpCalls;
}

#[AfterTest]
public function tearDown(): void
{
++self::$afterTestCalls;
throw new \RuntimeException('teardown after a failed setup');
}

#[Test]
public function body(): void
{
++self::$bodyCalls;
Assert::true(true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Tests\Lifecycle\Unit\Internal;

use Internal\Path;
use Psr\EventDispatcher\EventDispatcherInterface;
use Testo\Application\Internal\MessengerHub;
use Testo\Assert;
use Testo\Codecov\Covers;
use Testo\Core\Context\CaseInfo;
Expand Down Expand Up @@ -39,7 +41,13 @@ final class LifecycleInterceptorTest

public function __construct()
{
$this->interceptor = new LifecycleInterceptor();
$this->interceptor = new LifecycleInterceptor(new MessengerHub(new class() implements EventDispatcherInterface {
#[\Override]
public function dispatch(object $event): object
{
return $event;
}
}));
}

/**
Expand Down
4 changes: 3 additions & 1 deletion skills/testo-write-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ public function requiresPdoMysql(): void

Constraints:

- Must escape the **test method itself**. The runner's inner try/catch maps the throw to a status; raising from an interceptor or `#[BeforeTest]`/`#[AfterTest]` hook bubbles out of the pipeline and is treated as `Status::Aborted` instead. To skip from a hook, leave the precondition check inside the test body.
- Must escape the **test method itself**. The runner's inner try/catch maps the throw to a status; raising from an interceptor bubbles out of the pipeline and is treated as `Status::Aborted` instead, and raising from a `#[BeforeTest]`/`#[AfterTest]` hook makes the test `Status::Error` like any other hook failure. To skip from a hook, leave the precondition check inside the test body.
- These are not assertions — don't `try`/`catch` them inside the test, just `throw`.
- Subclasses work: `class MissingExtensionSkip extends SkipTest {}` is still recognized.
- Return type stays `void`, or `never` if the throw is unconditional.
Expand Down Expand Up @@ -248,6 +248,8 @@ public static function dropSchema(): void { /* once after all tests */ }

Hooks may be either instance methods or `static` — Testo invokes them accordingly. They run regardless of `#[Test]` on the method.

A throwing `#[BeforeTest]` hook stops the remaining setup hooks and the test body, and the test is reported as `Status::Error` with that exception. `#[AfterTest]` hooks still run, after a failed setup too, and each one runs even when a previous one threw. A throwing teardown makes a passed test `Status::Error`, but a failed test keeps its own failure. Hook exceptions that do not become the test's failure are written to the test's `stderr` channel.

In a **function-based test case** (a file of top-level `#[Test]` functions rather than a class), the same
attributes work on plain functions. The hooks apply to that file's case — `#[BeforeClass]`/`#[AfterClass]`
run once around the whole file, `#[BeforeTest]`/`#[AfterTest]` around each test function. A lifecycle
Expand Down
Loading