From 6eda9bbe819e1defcf8e603bf2e1b12d5aebbb7b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 30 Sep 2026 22:36:38 +0400 Subject: [PATCH 1/3] fix(lifecycle): keep the test failure when a lifecycle hook throws docs(skills): describe how lifecycle hook failures are reported A throwing #[AfterTest] replaced the test result with an Aborted pipeline failure, so the test's own failure disappeared from the report, and a throwing #[BeforeTest] skipped every #[AfterTest]. Hook failures are now test errors, teardown always runs, and throwables that do not become the test's failure go to the stderr channel. Assisted-By: Claude Opus 5.5 --- .../src/Internal/LifecycleInterceptor.php | 65 +++++++++++++++-- plugin/lifecycle/src/LifecyclePlugin.php | 2 +- .../tests/Feature/HookFailureFeatureTest.php | 72 +++++++++++++++++++ .../HookFailure/FailedTestTeardownStub.php | 24 +++++++ .../HookFailure/PassedTestTeardownStub.php | 37 ++++++++++ .../Stub/HookFailure/SetupFailureStub.php | 46 ++++++++++++ .../Internal/LifecycleInterceptorTest.php | 10 ++- skills/testo-write-tests/SKILL.md | 4 +- 8 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php create mode 100644 plugin/lifecycle/tests/Stub/HookFailure/FailedTestTeardownStub.php create mode 100644 plugin/lifecycle/tests/Stub/HookFailure/PassedTestTeardownStub.php create mode 100644 plugin/lifecycle/tests/Stub/HookFailure/SetupFailureStub.php diff --git a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php index 17dc882a..e3d89c19 100644 --- a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php +++ b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php @@ -4,6 +4,7 @@ namespace Testo\Lifecycle\Internal; +use Testo\Common\Messenger; use Testo\Common\Reflection; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; @@ -11,7 +12,9 @@ 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; @@ -49,6 +52,10 @@ TestRunInterceptor, TestCaseRunInterceptor { + public function __construct( + private Messenger $messenger, + ) {} + #[\Override] public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions { @@ -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 { @@ -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), + }; } /** @@ -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) { @@ -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); + } + } } diff --git a/plugin/lifecycle/src/LifecyclePlugin.php b/plugin/lifecycle/src/LifecyclePlugin.php index df0084ab..b1c60e4b 100644 --- a/plugin/lifecycle/src/LifecyclePlugin.php +++ b/plugin/lifecycle/src/LifecyclePlugin.php @@ -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); } } diff --git a/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php b/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php new file mode 100644 index 00000000..a4cc2273 --- /dev/null +++ b/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php @@ -0,0 +1,72 @@ +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), + )); + } +} diff --git a/plugin/lifecycle/tests/Stub/HookFailure/FailedTestTeardownStub.php b/plugin/lifecycle/tests/Stub/HookFailure/FailedTestTeardownStub.php new file mode 100644 index 00000000..3fcc5b91 --- /dev/null +++ b/plugin/lifecycle/tests/Stub/HookFailure/FailedTestTeardownStub.php @@ -0,0 +1,24 @@ +interceptor = new LifecycleInterceptor(); + $this->interceptor = new LifecycleInterceptor(new MessengerHub(new class() implements EventDispatcherInterface { + #[\Override] + public function dispatch(object $event): object + { + return $event; + } + })); } /** diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index 26cc1d74..4b578653 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -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. @@ -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 From 451969bb08a84293ab8a662505960b7baa677aa5 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 1 Oct 2026 18:32:52 +0400 Subject: [PATCH 2/3] fix(lifecycle): abort the test on a setup failure, keep teardown failures out of the verdict Expected-exception handling runs outside the lifecycle hooks, so it could take a setup exception for the expected one, and it turned a test whose teardown failed after the expected exception back into a pass. A setup failure now escapes the pipeline as Aborted, and a teardown failure only reaches the stderr channel. Assisted-By: Claude Opus 5.5 --- .../src/Internal/LifecycleInterceptor.php | 37 +++++-------------- .../tests/Feature/HookFailureFeatureTest.php | 34 ++++++++++++----- .../ExpectedExceptionTeardownStub.php | 26 +++++++++++++ .../SetupMatchingExpectationStub.php | 22 +++++++++++ skills/testo-write-tests/SKILL.md | 4 +- 5 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 plugin/lifecycle/tests/Stub/HookFailure/ExpectedExceptionTeardownStub.php create mode 100644 plugin/lifecycle/tests/Stub/HookFailure/SetupMatchingExpectationStub.php diff --git a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php index e3d89c19..d70eb0c9 100644 --- a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php +++ b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php @@ -14,7 +14,6 @@ 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; @@ -109,8 +108,8 @@ 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}. + * A throwing {@see BeforeTest} hook aborts the test. {@see AfterTest} hooks always run, and their + * throwables go to {@see Messenger::CHANNEL_STDERR} without touching the test result. */ #[\Override] public function runTest(TestInfo $info, callable $next): TestResult @@ -122,28 +121,15 @@ public function runTest(TestInfo $info, callable $next): TestResult } $instance = $info->caseInfo->instance; - $errors = self::executeAll($instance, $hooks[BeforeTest::class] ?? [], stopOnError: true); - - $result = null; try { - $errors === [] and $result = $next($info); + foreach ($hooks[BeforeTest::class] ?? [] as $hook) { + self::execute($instance, $hook); + } + + return $next($info); } finally { - $errors = [...$errors, ...self::executeAll($instance, $hooks[AfterTest::class] ?? [])]; + $this->report(...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), - }; } /** @@ -215,9 +201,9 @@ private static function collectHooks(CaseDefinition $definition): array /** * @param list<\ReflectionFunctionAbstract> $hooks - * @return list<\Throwable> In execution order. + * @return list<\Throwable> */ - private static function executeAll(?CaseInstance $instance, array $hooks, bool $stopOnError = false): array + private static function executeAll(?CaseInstance $instance, array $hooks): array { $errors = []; foreach ($hooks as $hook) { @@ -225,9 +211,6 @@ private static function executeAll(?CaseInstance $instance, array $hooks, bool $ self::execute($instance, $hook); } catch (\Throwable $e) { $errors[] = $e; - if ($stopOnError) { - break; - } } } diff --git a/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php b/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php index a4cc2273..cc21c6ad 100644 --- a/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php +++ b/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php @@ -14,9 +14,11 @@ use Testo\Test; use Testo\Testing\Attribute\TestingSuite; use Testo\Testing\Helper\TestRunner; +use Tests\Lifecycle\Stub\HookFailure\ExpectedExceptionTeardownStub; use Tests\Lifecycle\Stub\HookFailure\FailedTestTeardownStub; use Tests\Lifecycle\Stub\HookFailure\PassedTestTeardownStub; use Tests\Lifecycle\Stub\HookFailure\SetupFailureStub; +use Tests\Lifecycle\Stub\HookFailure\SetupMatchingExpectationStub; #[Test] #[Covers(LifecycleInterceptor::class)] @@ -32,21 +34,28 @@ public function teardownFailureKeepsTheFailureOfAFailedTest(): void Assert::string(self::stderr($result))->contains('teardown of a failed test'); } - public function teardownFailureTurnsAPassedTestIntoAnError(): void + public function teardownFailureKeepsAPassedTestPassed(): 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($result->status, Status::Passed); Assert::same(PassedTestTeardownStub::$afterTestCalls - $calls, 2); Assert::string(self::stderr($result)) - ->contains('second teardown') - ->notContains('first teardown'); + ->contains('first teardown') + ->contains('second teardown'); } - public function setupFailureSkipsTheBodyButRunsTheTeardown(): void + public function teardownFailureKeepsATestThatMetItsExpectedExceptionPassed(): void + { + $result = TestRunner::runTest([ExpectedExceptionTeardownStub::class, 'throwsExpected']); + + Assert::same($result->status, Status::Passed); + Assert::string(self::stderr($result))->contains('teardown of a test that met its expectation'); + } + + public function setupFailureAbortsTheTestButRunsTheTeardown(): void { $secondSetUp = SetupFailureStub::$secondSetUpCalls; $body = SetupFailureStub::$bodyCalls; @@ -54,12 +63,19 @@ public function setupFailureSkipsTheBodyButRunsTheTeardown(): void $result = TestRunner::runTest([SetupFailureStub::class, 'body']); - Assert::same($result->status, Status::Error); - Assert::same($result->failure?->getMessage(), 'setup'); + Assert::same($result->status, Status::Aborted); + Assert::same($result->failure?->getPrevious()?->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'); + } + + public function setupFailureDoesNotSatisfyAnExpectedException(): void + { + $result = TestRunner::runTest([SetupMatchingExpectationStub::class, 'expectsRuntimeException']); + + Assert::same($result->status, Status::Aborted); + Assert::same($result->failure?->getPrevious()?->getMessage(), 'setup'); } private static function stderr(TestResult $result): string diff --git a/plugin/lifecycle/tests/Stub/HookFailure/ExpectedExceptionTeardownStub.php b/plugin/lifecycle/tests/Stub/HookFailure/ExpectedExceptionTeardownStub.php new file mode 100644 index 00000000..b9729fdd --- /dev/null +++ b/plugin/lifecycle/tests/Stub/HookFailure/ExpectedExceptionTeardownStub.php @@ -0,0 +1,26 @@ + Date: Thu, 1 Oct 2026 19:58:27 +0400 Subject: [PATCH 3/3] fix(lifecycle): report a hook exception without calling its own __toString() Interpolating the throwable ran a user-defined __toString(), and one that throws escaped the teardown and aborted the test. ErrorReporter::format() reads only the final Throwable accessors. Assisted-By: Claude Opus 5.5 --- .../src/Internal/LifecycleInterceptor.php | 7 +++++- .../tests/Feature/HookFailureFeatureTest.php | 9 +++++++ .../Stub/HookFailure/UnprintableException.php | 13 ++++++++++ .../HookFailure/UnprintableTeardownStub.php | 24 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 plugin/lifecycle/tests/Stub/HookFailure/UnprintableException.php create mode 100644 plugin/lifecycle/tests/Stub/HookFailure/UnprintableTeardownStub.php diff --git a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php index d70eb0c9..6935b9d8 100644 --- a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php +++ b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php @@ -4,6 +4,7 @@ namespace Testo\Lifecycle\Internal; +use Testo\Common\ErrorReporter; use Testo\Common\Messenger; use Testo\Common\Reflection; use Testo\Core\Context\CaseInfo; @@ -231,7 +232,11 @@ private static function execute(?CaseInstance $instance, \ReflectionFunctionAbst private function report(\Throwable ...$errors): void { foreach ($errors as $error) { - $this->messenger->log(Messenger::CHANNEL_STDERR, "Lifecycle hook failed: {$error}", Level::Error); + $this->messenger->log( + Messenger::CHANNEL_STDERR, + 'Lifecycle hook failed: ' . ErrorReporter::format($error), + Level::Error, + ); } } } diff --git a/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php b/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php index cc21c6ad..c7120dc6 100644 --- a/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php +++ b/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php @@ -19,6 +19,7 @@ use Tests\Lifecycle\Stub\HookFailure\PassedTestTeardownStub; use Tests\Lifecycle\Stub\HookFailure\SetupFailureStub; use Tests\Lifecycle\Stub\HookFailure\SetupMatchingExpectationStub; +use Tests\Lifecycle\Stub\HookFailure\UnprintableTeardownStub; #[Test] #[Covers(LifecycleInterceptor::class)] @@ -55,6 +56,14 @@ public function teardownFailureKeepsATestThatMetItsExpectedExceptionPassed(): vo Assert::string(self::stderr($result))->contains('teardown of a test that met its expectation'); } + public function teardownFailureKeepsTheResultWhenItsExceptionCannotBePrinted(): void + { + $result = TestRunner::runTest([UnprintableTeardownStub::class, 'passes']); + + Assert::same($result->status, Status::Passed); + Assert::string(self::stderr($result))->contains('unprintable teardown'); + } + public function setupFailureAbortsTheTestButRunsTheTeardown(): void { $secondSetUp = SetupFailureStub::$secondSetUpCalls; diff --git a/plugin/lifecycle/tests/Stub/HookFailure/UnprintableException.php b/plugin/lifecycle/tests/Stub/HookFailure/UnprintableException.php new file mode 100644 index 00000000..d38c8da9 --- /dev/null +++ b/plugin/lifecycle/tests/Stub/HookFailure/UnprintableException.php @@ -0,0 +1,13 @@ +