diff --git a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php index 17dc882a..6935b9d8 100644 --- a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php +++ b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php @@ -4,6 +4,8 @@ namespace Testo\Lifecycle\Internal; +use Testo\Common\ErrorReporter; +use Testo\Common\Messenger; use Testo\Common\Reflection; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; @@ -11,6 +13,7 @@ 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\TestType; use Testo\Lifecycle\AfterClass; @@ -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 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 { @@ -110,16 +121,15 @@ 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; try { + foreach ($hooks[BeforeTest::class] ?? [] as $hook) { + self::execute($instance, $hook); + } + return $next($info); } finally { - foreach ($hooks[AfterTest::class] ?? [] as $hook) { - self::execute($info->caseInfo->instance, $hook); - } + $this->report(...self::executeAll($instance, $hooks[AfterTest::class] ?? [])); } } @@ -190,6 +200,24 @@ private static function collectHooks(CaseDefinition $definition): array return $hooks; } + /** + * @param list<\ReflectionFunctionAbstract> $hooks + * @return list<\Throwable> + */ + private static function executeAll(?CaseInstance $instance, array $hooks): array + { + $errors = []; + foreach ($hooks as $hook) { + try { + self::execute($instance, $hook); + } catch (\Throwable $e) { + $errors[] = $e; + } + } + + return $errors; + } + private static function execute(?CaseInstance $instance, \ReflectionFunctionAbstract $reflection): void { if ($reflection instanceof \ReflectionMethod) { @@ -200,4 +228,15 @@ 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: ' . ErrorReporter::format($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..c7120dc6 --- /dev/null +++ b/plugin/lifecycle/tests/Feature/HookFailureFeatureTest.php @@ -0,0 +1,97 @@ +status, Status::Failed); + Assert::instanceOf($result->failure, ComparisonFailure::class); + Assert::string(self::stderr($result))->contains('teardown of a failed test'); + } + + public function teardownFailureKeepsAPassedTestPassed(): void + { + $calls = PassedTestTeardownStub::$afterTestCalls; + + $result = TestRunner::runTest([PassedTestTeardownStub::class, 'passes']); + + Assert::same($result->status, Status::Passed); + Assert::same(PassedTestTeardownStub::$afterTestCalls - $calls, 2); + Assert::string(self::stderr($result)) + ->contains('first teardown') + ->contains('second teardown'); + } + + 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 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; + $body = SetupFailureStub::$bodyCalls; + $afterTest = SetupFailureStub::$afterTestCalls; + + $result = TestRunner::runTest([SetupFailureStub::class, 'body']); + + 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); + } + + 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 + { + 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/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 @@ +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..e50170d3 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 or a `#[BeforeTest]` hook bubbles out of the pipeline and is treated as `Status::Aborted` instead, and raising from an `#[AfterTest]` hook only reaches the `stderr` channel. 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::Aborted`, which fails the run. `#[AfterTest]` hooks always run, after a failed setup too, and each one runs even when a previous one threw. A throwing teardown never changes the test result: its exception is written to the `stderr` channel, so a passed test stays passed. Keep cleanup that a test's verdict depends on inside the test body. + 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