From 52895963cea9f747844e42d93c67df5c1cf67993 Mon Sep 17 00:00:00 2001 From: vdvukhzhilov Date: Fri, 7 Aug 2026 11:07:44 +0200 Subject: [PATCH] feat: treat hooks errors as allure report global errors --- composer.json | 19 +- phpunit.xml.dist | 3 + psalm.xml.dist | 10 + src/AllureAdapter.php | 125 +++++++++ src/AllureAdapterInterface.php | 32 +++ src/AllureExtension.php | 209 ++++++++++++++- src/Event/AbstractHookMethodSubscriber.php | 87 +++++++ .../AfterLastTestMethodCalledSubscriber.php | 21 ++ .../AfterLastTestMethodErroredSubscriber.php | 23 ++ .../AfterLastTestMethodFailedSubscriber.php | 25 ++ .../AfterLastTestMethodFinishedSubscriber.php | 21 ++ src/Event/AfterTestMethodCalledSubscriber.php | 24 ++ .../AfterTestMethodErroredSubscriber.php | 23 ++ src/Event/AfterTestMethodFailedSubscriber.php | 24 ++ .../AfterTestMethodFinishedSubscriber.php | 25 ++ .../BeforeFirstTestMethodCalledSubscriber.php | 21 ++ ...BeforeFirstTestMethodErroredSubscriber.php | 23 ++ .../BeforeFirstTestMethodFailedSubscriber.php | 25 ++ ...eforeFirstTestMethodFinishedSubscriber.php | 21 ++ .../BeforeTestMethodCalledSubscriber.php | 24 ++ .../BeforeTestMethodErroredSubscriber.php | 25 ++ .../BeforeTestMethodFailedSubscriber.php | 24 ++ .../BeforeTestMethodFinishedSubscriber.php | 23 ++ src/Event/PostConditionCalledSubscriber.php | 24 ++ src/Event/PostConditionErroredSubscriber.php | 23 ++ src/Event/PostConditionFailedSubscriber.php | 24 ++ src/Event/PostConditionFinishedSubscriber.php | 24 ++ src/Event/PreConditionCalledSubscriber.php | 24 ++ src/Event/PreConditionErroredSubscriber.php | 24 ++ src/Event/PreConditionFailedSubscriber.php | 24 ++ src/Event/PreConditionFinishedSubscriber.php | 21 ++ src/Event/TestErroredSubscriber.php | 8 +- src/Event/TestFailedSubscriber.php | 8 +- src/Event/TestFinishedSubscriber.php | 4 +- .../TestPreparationStartedSubscriber.php | 1 + src/Event/TestPreparedSubscriber.php | 1 + src/Event/TestSuiteFinishedSubscriber.php | 23 ++ src/Internal/HookFailureMessage.php | 22 ++ src/Internal/TestLifecycle.php | 239 +++++++++++++++++- src/Internal/TestLifecycleInterface.php | 37 +++ .../Fixture/SetUpAssertionFailureTest.php | 21 ++ .../Fixture/SetUpBeforeClassFailureTest.php | 22 ++ .../Fixture/SetUpEmptyMessageFailureTest.php | 22 ++ test/integration/Fixture/SetUpFailureTest.php | 28 ++ .../Fixture/TearDownAfterClassFailureTest.php | 22 ++ .../Fixture/TearDownFailureTest.php | 22 ++ .../HookFailureIntegrationTest.php | 176 +++++++++++++ test/integration/allure.config.php | 9 + test/integration/phpunit.hooks.xml | 23 ++ ...eforeFirstTestMethodHookSubscriberTest.php | 72 ++++++ .../BeforeTestMethodHookSubscriberTest.php | 116 +++++++++ test/unit/Event/EventTestTrait.php | 153 +++++++++++ test/unit/Event/TestErroredSubscriberTest.php | 19 +- test/unit/Event/TestFailedSubscriberTest.php | 4 + .../unit/Event/TestFinishedSubscriberTest.php | 30 ++- .../TestPreparationStartedSubscriberTest.php | 14 +- .../unit/Event/TestPreparedSubscriberTest.php | 20 +- test/unit/Internal/HookFailureMessageTest.php | 39 +++ .../Internal/TestLifecycleHookFixtureTest.php | 136 ++++++++++ test/unit/TestTestLifecycle.php | 66 +++++ 60 files changed, 2375 insertions(+), 27 deletions(-) create mode 100644 src/Event/AbstractHookMethodSubscriber.php create mode 100644 src/Event/AfterLastTestMethodCalledSubscriber.php create mode 100644 src/Event/AfterLastTestMethodErroredSubscriber.php create mode 100644 src/Event/AfterLastTestMethodFailedSubscriber.php create mode 100644 src/Event/AfterLastTestMethodFinishedSubscriber.php create mode 100644 src/Event/AfterTestMethodCalledSubscriber.php create mode 100644 src/Event/AfterTestMethodErroredSubscriber.php create mode 100644 src/Event/AfterTestMethodFailedSubscriber.php create mode 100644 src/Event/AfterTestMethodFinishedSubscriber.php create mode 100644 src/Event/BeforeFirstTestMethodCalledSubscriber.php create mode 100644 src/Event/BeforeFirstTestMethodErroredSubscriber.php create mode 100644 src/Event/BeforeFirstTestMethodFailedSubscriber.php create mode 100644 src/Event/BeforeFirstTestMethodFinishedSubscriber.php create mode 100644 src/Event/BeforeTestMethodCalledSubscriber.php create mode 100644 src/Event/BeforeTestMethodErroredSubscriber.php create mode 100644 src/Event/BeforeTestMethodFailedSubscriber.php create mode 100644 src/Event/BeforeTestMethodFinishedSubscriber.php create mode 100644 src/Event/PostConditionCalledSubscriber.php create mode 100644 src/Event/PostConditionErroredSubscriber.php create mode 100644 src/Event/PostConditionFailedSubscriber.php create mode 100644 src/Event/PostConditionFinishedSubscriber.php create mode 100644 src/Event/PreConditionCalledSubscriber.php create mode 100644 src/Event/PreConditionErroredSubscriber.php create mode 100644 src/Event/PreConditionFailedSubscriber.php create mode 100644 src/Event/PreConditionFinishedSubscriber.php create mode 100644 src/Event/TestSuiteFinishedSubscriber.php create mode 100644 src/Internal/HookFailureMessage.php create mode 100644 test/integration/Fixture/SetUpAssertionFailureTest.php create mode 100644 test/integration/Fixture/SetUpBeforeClassFailureTest.php create mode 100644 test/integration/Fixture/SetUpEmptyMessageFailureTest.php create mode 100644 test/integration/Fixture/SetUpFailureTest.php create mode 100644 test/integration/Fixture/TearDownAfterClassFailureTest.php create mode 100644 test/integration/Fixture/TearDownFailureTest.php create mode 100644 test/integration/HookFailureIntegrationTest.php create mode 100644 test/integration/allure.config.php create mode 100644 test/integration/phpunit.hooks.xml create mode 100644 test/unit/Event/BeforeFirstTestMethodHookSubscriberTest.php create mode 100644 test/unit/Event/BeforeTestMethodHookSubscriberTest.php create mode 100644 test/unit/Internal/HookFailureMessageTest.php create mode 100644 test/unit/Internal/TestLifecycleHookFixtureTest.php diff --git a/composer.json b/composer.json index e81f8c7..3fc2892 100644 --- a/composer.json +++ b/composer.json @@ -56,16 +56,17 @@ "Qameta\\Allure\\PHPUnit\\": "src/" } }, - "autoload-dev": { - "psr-0": { - "Yandex": "test/" + "autoload-dev": { + "psr-0": { + "Yandex": "test/" + }, + "psr-4": { + "Qameta\\Allure\\PHPUnit\\Test\\Unit\\": "test/unit/", + "Qameta\\Allure\\PHPUnit\\Test\\Report\\": "test/report/", + "Qameta\\Allure\\PHPUnit\\Test\\Integration\\": "test/integration/", + "Qameta\\Allure\\PHPUnit\\Scripts\\": "scripts/" + } }, - "psr-4": { - "Qameta\\Allure\\PHPUnit\\Test\\Unit\\": "test/unit/", - "Qameta\\Allure\\PHPUnit\\Test\\Report\\": "test/report/", - "Qameta\\Allure\\PHPUnit\\Scripts\\": "scripts/" - } - }, "scripts": { "test-cs": "vendor/bin/phpcs -sp", "test-unit": "vendor/bin/phpunit --coverage-text", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e6191bc..df4c930 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,6 +14,9 @@ test/unit/ + + test/integration/HookFailureIntegrationTest.php + diff --git a/psalm.xml.dist b/psalm.xml.dist index 828d139..dec02b2 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -5,6 +5,7 @@ findUnusedCode="false" findUnusedPsalmSuppress="false" findUnusedVariablesAndParams="false" + findUnusedIssueHandlerSuppression="false" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"> @@ -18,4 +19,13 @@ + + + + + + + + diff --git a/src/AllureAdapter.php b/src/AllureAdapter.php index 2e8e3be..60af350 100644 --- a/src/AllureAdapter.php +++ b/src/AllureAdapter.php @@ -27,8 +27,34 @@ final class AllureAdapter implements AllureAdapterInterface */ private array $lastRuns = []; + /** + * @var array + */ + private array $startedTests = []; + + /** + * @var array + */ + private array $writtenTests = []; + + /** + * @var array + */ + private array $classContainers = []; + + /** + * @var array + */ + private array $emittedHookGlobals = []; + private ?Throwable $lastException = null; + private ?string $activeFixtureUuid = null; + + private ?string $activeHookName = null; + + private ?TestInfo $pendingFinalizeTest = null; + private function __construct() { } @@ -73,6 +99,7 @@ public function registerStart(ContainerResult $containerResult, TestResult $test containerId: $containerResult->getUuid(), testId: $testResult->getUuid(), ); + unset($this->startedTests[$info->getTest()], $this->writtenTests[$info->getTest()]); return $testResult->getUuid(); } @@ -115,6 +142,104 @@ public function registerRun(TestResult $testResult, TestInfo $info): TestRunInfo return $currentRunInfo; } + #[\Override] + public function markTestStarted(TestInfo $info): void + { + $this->startedTests[$info->getTest()] = true; + } + + #[\Override] + public function wasTestStarted(TestInfo $info): bool + { + return isset($this->startedTests[$info->getTest()]); + } + + #[\Override] + public function markTestWritten(TestInfo $info): void + { + $this->writtenTests[$info->getTest()] = true; + } + + #[\Override] + public function wasTestWritten(TestInfo $info): bool + { + return isset($this->writtenTests[$info->getTest()]); + } + + #[\Override] + public function registerClassContainer(string $className, string $containerUuid): void + { + $this->classContainers[$className] = $containerUuid; + } + + #[\Override] + public function getClassContainerId(string $className): ?string + { + return $this->classContainers[$className] ?? null; + } + + #[\Override] + public function clearClassContainer(string $className): void + { + unset($this->classContainers[$className]); + } + + #[\Override] + public function setActiveFixture(string $uuid, string $hookName): void + { + $this->activeFixtureUuid = $uuid; + $this->activeHookName = $hookName; + } + + #[\Override] + public function getActiveFixtureUuid(): ?string + { + return $this->activeFixtureUuid; + } + + #[\Override] + public function getActiveHookName(): ?string + { + return $this->activeHookName; + } + + #[\Override] + public function clearActiveFixture(): void + { + $this->activeFixtureUuid = null; + $this->activeHookName = null; + } + + #[\Override] + public function hasEmittedHookGlobalError(string $fixtureUuid): bool + { + return isset($this->emittedHookGlobals[$fixtureUuid]); + } + + #[\Override] + public function markHookGlobalErrorEmitted(string $fixtureUuid): void + { + $this->emittedHookGlobals[$fixtureUuid] = true; + } + + #[\Override] + public function markPendingFinalize(TestInfo $info): void + { + $this->pendingFinalizeTest = $info; + } + + #[\Override] + public function getPendingFinalizeTest(): ?TestInfo + { + return $this->pendingFinalizeTest; + } + + #[\Override] + public function clearPendingFinalize(): void + { + $this->pendingFinalizeTest = null; + } + /** * @param TestResult $test * @return list diff --git a/src/AllureAdapterInterface.php b/src/AllureAdapterInterface.php index 4f5b432..45e7b53 100644 --- a/src/AllureAdapterInterface.php +++ b/src/AllureAdapterInterface.php @@ -25,4 +25,36 @@ public function resetLastException(): void; public function setLastException(Throwable $e): void; public function getLastException(): ?Throwable; + + public function markTestStarted(TestInfo $info): void; + + public function wasTestStarted(TestInfo $info): bool; + + public function markTestWritten(TestInfo $info): void; + + public function wasTestWritten(TestInfo $info): bool; + + public function registerClassContainer(string $className, string $containerUuid): void; + + public function getClassContainerId(string $className): ?string; + + public function clearClassContainer(string $className): void; + + public function setActiveFixture(string $uuid, string $hookName): void; + + public function getActiveFixtureUuid(): ?string; + + public function getActiveHookName(): ?string; + + public function clearActiveFixture(): void; + + public function hasEmittedHookGlobalError(string $fixtureUuid): bool; + + public function markHookGlobalErrorEmitted(string $fixtureUuid): void; + + public function markPendingFinalize(TestInfo $info): void; + + public function getPendingFinalizeTest(): ?TestInfo; + + public function clearPendingFinalize(): void; } diff --git a/src/AllureExtension.php b/src/AllureExtension.php index 3c3a37a..3d817c8 100644 --- a/src/AllureExtension.php +++ b/src/AllureExtension.php @@ -18,7 +18,9 @@ use Qameta\Allure\PHPUnit\Internal\TestUpdater; use RuntimeException; +use function class_exists; use function file_exists; +use function interface_exists; use function is_array; use const DIRECTORY_SEPARATOR; @@ -99,7 +101,7 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete $testLifecycle = $this->testLifecycle ?? $this->createTestLifecycle($configSource); - $facade->registerSubscribers( + $subscribers = [ new Event\TestPreparationStartedSubscriber($testLifecycle), new Event\TestPreparedSubscriber($testLifecycle), new Event\TestFinishedSubscriber($testLifecycle), @@ -110,6 +112,211 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete new Event\TestWarningTriggeredSubscriber($testLifecycle), new Event\TestConsideredRiskySubscriber($testLifecycle), new Event\TestPassedSubscriber($testLifecycle), + new Event\TestSuiteFinishedSubscriber($testLifecycle), + ]; + + $facade->registerSubscribers(...$subscribers, ...$this->createHookSubscribers($testLifecycle)); + } + + /** + * @return list + */ + private function createHookSubscribers(TestLifecycleInterface $testLifecycle): array + { + $subscribers = []; + + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\BeforeTestMethodCalledSubscriber::class, + static fn () => new Event\BeforeTestMethodCalledSubscriber($testLifecycle), + ); + $this->addFailedHookSubscriberIfSupported( + $subscribers, + 'PHPUnit\\Event\\Test\\BeforeTestMethodFailedSubscriber', + Event\BeforeTestMethodFailedSubscriber::class, + $testLifecycle, + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\BeforeTestMethodErroredSubscriber::class, + static fn () => new Event\BeforeTestMethodErroredSubscriber($testLifecycle), + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\BeforeTestMethodFinishedSubscriber::class, + static fn () => new Event\BeforeTestMethodFinishedSubscriber($testLifecycle), + ); + + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\AfterTestMethodCalledSubscriber::class, + static fn () => new Event\AfterTestMethodCalledSubscriber($testLifecycle), + ); + $this->addFailedHookSubscriberIfSupported( + $subscribers, + 'PHPUnit\\Event\\Test\\AfterTestMethodFailedSubscriber', + Event\AfterTestMethodFailedSubscriber::class, + $testLifecycle, + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\AfterTestMethodErroredSubscriber::class, + static fn () => new Event\AfterTestMethodErroredSubscriber($testLifecycle), ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\AfterTestMethodFinishedSubscriber::class, + static fn () => new Event\AfterTestMethodFinishedSubscriber($testLifecycle), + ); + + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\PreConditionCalledSubscriber::class, + static fn () => new Event\PreConditionCalledSubscriber($testLifecycle), + ); + $this->addFailedHookSubscriberIfSupported( + $subscribers, + 'PHPUnit\\Event\\Test\\PreConditionFailedSubscriber', + Event\PreConditionFailedSubscriber::class, + $testLifecycle, + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\PreConditionErroredSubscriber::class, + static fn () => new Event\PreConditionErroredSubscriber($testLifecycle), + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\PreConditionFinishedSubscriber::class, + static fn () => new Event\PreConditionFinishedSubscriber($testLifecycle), + ); + + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\PostConditionCalledSubscriber::class, + static fn () => new Event\PostConditionCalledSubscriber($testLifecycle), + ); + $this->addFailedHookSubscriberIfSupported( + $subscribers, + 'PHPUnit\\Event\\Test\\PostConditionFailedSubscriber', + Event\PostConditionFailedSubscriber::class, + $testLifecycle, + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\PostConditionErroredSubscriber::class, + static fn () => new Event\PostConditionErroredSubscriber($testLifecycle), + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\PostConditionFinishedSubscriber::class, + static fn () => new Event\PostConditionFinishedSubscriber($testLifecycle), + ); + + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\BeforeFirstTestMethodCalledSubscriber::class, + static fn () => new Event\BeforeFirstTestMethodCalledSubscriber($testLifecycle), + ); + $this->addFailedHookSubscriberIfSupported( + $subscribers, + 'PHPUnit\\Event\\Test\\BeforeFirstTestMethodFailedSubscriber', + Event\BeforeFirstTestMethodFailedSubscriber::class, + $testLifecycle, + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\BeforeFirstTestMethodErroredSubscriber::class, + static fn () => new Event\BeforeFirstTestMethodErroredSubscriber($testLifecycle), + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\BeforeFirstTestMethodFinishedSubscriber::class, + static fn () => new Event\BeforeFirstTestMethodFinishedSubscriber($testLifecycle), + ); + + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\AfterLastTestMethodCalledSubscriber::class, + static fn () => new Event\AfterLastTestMethodCalledSubscriber($testLifecycle), + ); + $this->addFailedHookSubscriberIfSupported( + $subscribers, + 'PHPUnit\\Event\\Test\\AfterLastTestMethodFailedSubscriber', + Event\AfterLastTestMethodFailedSubscriber::class, + $testLifecycle, + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\AfterLastTestMethodErroredSubscriber::class, + static fn () => new Event\AfterLastTestMethodErroredSubscriber($testLifecycle), + ); + $this->addSubscriberIfSupported( + $subscribers, + \PHPUnit\Event\Test\AfterLastTestMethodFinishedSubscriber::class, + static fn () => new Event\AfterLastTestMethodFinishedSubscriber($testLifecycle), + ); + + return $subscribers; + } + + /** + * Failed-hook subscriber interfaces exist only on PHPUnit >= 12.5. + * Build the implementing wrapper at runtime so Psalm on older PHPUnit + * does not see missing interface names in source. + * + * @param list $subscribers + * @param non-empty-string $subscriberInterface + * @param class-string $implementationClass + * + * @psalm-suppress ForbiddenCode + * @psalm-suppress MixedAssignment + * @psalm-suppress MixedArgument + * @psalm-suppress MixedMethodCall + * @psalm-suppress ReferenceConstraintViolation + */ + private function addFailedHookSubscriberIfSupported( + array &$subscribers, + string $subscriberInterface, + string $implementationClass, + TestLifecycleInterface $testLifecycle, + ): void { + if (!interface_exists($subscriberInterface)) { + return; + } + + /** @var object $inner */ + $inner = new $implementationClass($testLifecycle); + + /** @var object $wrapper */ + $wrapper = eval( + 'return new class ($inner) implements \\' . $subscriberInterface + . ' { + public function __construct(private readonly object $inner) + { + } + + public function notify(object $event): void + { + $this->inner->notify($event); + } + };' + ); + + $subscribers[] = $wrapper; + } + + /** + * @param list $subscribers + * @param class-string|non-empty-string $subscriberInterface + * @param callable():object $factory + */ + private function addSubscriberIfSupported(array &$subscribers, string $subscriberInterface, callable $factory): void + { + if (!interface_exists($subscriberInterface) && !class_exists($subscriberInterface)) { + return; + } + + $subscribers[] = $factory(); } } diff --git a/src/Event/AbstractHookMethodSubscriber.php b/src/Event/AbstractHookMethodSubscriber.php new file mode 100644 index 0000000..2b6c396 --- /dev/null +++ b/src/Event/AbstractHookMethodSubscriber.php @@ -0,0 +1,87 @@ +methodName(); + } + + /** + * PHPUnit < 12.5 exposes only testClassName() on per-test hook events; + * PreparationStarted / the active test already switched the lifecycle. + * PHPUnit >= 12.5 provides test() with the concrete TestMethod. + */ + protected function switchToHookEvent(object $event): bool + { + if (method_exists($event, 'test')) { + /** @var mixed $test */ + $test = $event->test(); + if (!$test instanceof TestMethod) { + return false; + } + + $this->testLifecycle->switchTo($test); + + return true; + } + + return method_exists($event, 'testClassName'); + } + + protected function eventThrowable(object $event): ?Throwable + { + if (!method_exists($event, 'throwable')) { + return null; + } + + /** @var mixed $throwable */ + $throwable = $event->throwable(); + + return $throwable instanceof Throwable ? $throwable : null; + } + + protected function eventTestClassName(object $event): ?string + { + if (!method_exists($event, 'testClassName')) { + return null; + } + + /** @var mixed $className */ + $className = $event->testClassName(); + + return is_string($className) ? $className : null; + } + + protected function completeFailure(Throwable $throwable, Status $status): void + { + $this + ->testLifecycle + ->completeHookFixtureFailure( + $status, + $throwable->message(), + $throwable->asString(), + ); + } +} diff --git a/src/Event/AfterLastTestMethodCalledSubscriber.php b/src/Event/AfterLastTestMethodCalledSubscriber.php new file mode 100644 index 0000000..3a845ad --- /dev/null +++ b/src/Event/AfterLastTestMethodCalledSubscriber.php @@ -0,0 +1,21 @@ +testLifecycle + ->ensureClassContainer($event->testClassName()) + ->startAfterHookFixture($this->hookName($event->calledMethod())); + } +} diff --git a/src/Event/AfterLastTestMethodErroredSubscriber.php b/src/Event/AfterLastTestMethodErroredSubscriber.php new file mode 100644 index 0000000..3ffc37b --- /dev/null +++ b/src/Event/AfterLastTestMethodErroredSubscriber.php @@ -0,0 +1,23 @@ +testLifecycle + ->ensureClassContainer($event->testClassName()); + $this->completeFailure($event->throwable(), Status::broken()); + $this->testLifecycle->writeClassContainer($event->testClassName()); + } +} diff --git a/src/Event/AfterLastTestMethodFailedSubscriber.php b/src/Event/AfterLastTestMethodFailedSubscriber.php new file mode 100644 index 0000000..a9e487d --- /dev/null +++ b/src/Event/AfterLastTestMethodFailedSubscriber.php @@ -0,0 +1,25 @@ +eventTestClassName($event); + $throwable = $this->eventThrowable($event); + if ($className === null || $throwable === null) { + return; + } + + $this + ->testLifecycle + ->ensureClassContainer($className); + $this->completeFailure($throwable, Status::failed()); + $this->testLifecycle->writeClassContainer($className); + } +} diff --git a/src/Event/AfterLastTestMethodFinishedSubscriber.php b/src/Event/AfterLastTestMethodFinishedSubscriber.php new file mode 100644 index 0000000..d28a39d --- /dev/null +++ b/src/Event/AfterLastTestMethodFinishedSubscriber.php @@ -0,0 +1,21 @@ +testLifecycle + ->completeHookFixtureSuccess() + ->writeClassContainer($event->testClassName()); + } +} diff --git a/src/Event/AfterTestMethodCalledSubscriber.php b/src/Event/AfterTestMethodCalledSubscriber.php new file mode 100644 index 0000000..043e034 --- /dev/null +++ b/src/Event/AfterTestMethodCalledSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $this + ->testLifecycle + ->startAfterHookFixture($this->hookName($event->calledMethod())); + } +} diff --git a/src/Event/AfterTestMethodErroredSubscriber.php b/src/Event/AfterTestMethodErroredSubscriber.php new file mode 100644 index 0000000..5bc3c38 --- /dev/null +++ b/src/Event/AfterTestMethodErroredSubscriber.php @@ -0,0 +1,23 @@ +switchToHookEvent($event)) { + return; + } + + $this->completeFailure($event->throwable(), Status::broken()); + } +} diff --git a/src/Event/AfterTestMethodFailedSubscriber.php b/src/Event/AfterTestMethodFailedSubscriber.php new file mode 100644 index 0000000..8ea84df --- /dev/null +++ b/src/Event/AfterTestMethodFailedSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $throwable = $this->eventThrowable($event); + if ($throwable === null) { + return; + } + + $this->completeFailure($throwable, Status::failed()); + } +} diff --git a/src/Event/AfterTestMethodFinishedSubscriber.php b/src/Event/AfterTestMethodFinishedSubscriber.php new file mode 100644 index 0000000..7ff7b39 --- /dev/null +++ b/src/Event/AfterTestMethodFinishedSubscriber.php @@ -0,0 +1,25 @@ +switchToHookEvent($event)) { + return; + } + + $this + ->testLifecycle + ->completeHookFixtureSuccess() + ->flushPendingFinalize(); + } +} diff --git a/src/Event/BeforeFirstTestMethodCalledSubscriber.php b/src/Event/BeforeFirstTestMethodCalledSubscriber.php new file mode 100644 index 0000000..4b9fea7 --- /dev/null +++ b/src/Event/BeforeFirstTestMethodCalledSubscriber.php @@ -0,0 +1,21 @@ +testLifecycle + ->ensureClassContainer($event->testClassName()) + ->startBeforeHookFixture($this->hookName($event->calledMethod())); + } +} diff --git a/src/Event/BeforeFirstTestMethodErroredSubscriber.php b/src/Event/BeforeFirstTestMethodErroredSubscriber.php new file mode 100644 index 0000000..914bd83 --- /dev/null +++ b/src/Event/BeforeFirstTestMethodErroredSubscriber.php @@ -0,0 +1,23 @@ +testLifecycle + ->ensureClassContainer($event->testClassName()); + $this->completeFailure($event->throwable(), Status::broken()); + $this->testLifecycle->writeClassContainer($event->testClassName()); + } +} diff --git a/src/Event/BeforeFirstTestMethodFailedSubscriber.php b/src/Event/BeforeFirstTestMethodFailedSubscriber.php new file mode 100644 index 0000000..d0b227f --- /dev/null +++ b/src/Event/BeforeFirstTestMethodFailedSubscriber.php @@ -0,0 +1,25 @@ +eventTestClassName($event); + $throwable = $this->eventThrowable($event); + if ($className === null || $throwable === null) { + return; + } + + $this + ->testLifecycle + ->ensureClassContainer($className); + $this->completeFailure($throwable, Status::failed()); + $this->testLifecycle->writeClassContainer($className); + } +} diff --git a/src/Event/BeforeFirstTestMethodFinishedSubscriber.php b/src/Event/BeforeFirstTestMethodFinishedSubscriber.php new file mode 100644 index 0000000..6622d93 --- /dev/null +++ b/src/Event/BeforeFirstTestMethodFinishedSubscriber.php @@ -0,0 +1,21 @@ +testLifecycle + ->completeHookFixtureSuccess() + ->writeClassContainer($event->testClassName()); + } +} diff --git a/src/Event/BeforeTestMethodCalledSubscriber.php b/src/Event/BeforeTestMethodCalledSubscriber.php new file mode 100644 index 0000000..613d62f --- /dev/null +++ b/src/Event/BeforeTestMethodCalledSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $this + ->testLifecycle + ->startBeforeHookFixture($this->hookName($event->calledMethod())); + } +} diff --git a/src/Event/BeforeTestMethodErroredSubscriber.php b/src/Event/BeforeTestMethodErroredSubscriber.php new file mode 100644 index 0000000..ec6e875 --- /dev/null +++ b/src/Event/BeforeTestMethodErroredSubscriber.php @@ -0,0 +1,25 @@ +switchToHookEvent($event)) { + return; + } + + $this->completeFailure($event->throwable(), Status::broken()); + // Exception during setUp: PHPUnit may skip TestFinished — defer write until after tearDown. + $this->testLifecycle->markPendingFinalize(); + } +} diff --git a/src/Event/BeforeTestMethodFailedSubscriber.php b/src/Event/BeforeTestMethodFailedSubscriber.php new file mode 100644 index 0000000..cd81fd2 --- /dev/null +++ b/src/Event/BeforeTestMethodFailedSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $throwable = $this->eventThrowable($event); + if ($throwable === null) { + return; + } + + $this->completeFailure($throwable, Status::failed()); + } +} diff --git a/src/Event/BeforeTestMethodFinishedSubscriber.php b/src/Event/BeforeTestMethodFinishedSubscriber.php new file mode 100644 index 0000000..08b667c --- /dev/null +++ b/src/Event/BeforeTestMethodFinishedSubscriber.php @@ -0,0 +1,23 @@ +switchToHookEvent($event)) { + return; + } + + $this + ->testLifecycle + ->startAfterHookFixture($this->hookName($event->calledMethod())); + } +} diff --git a/src/Event/PostConditionErroredSubscriber.php b/src/Event/PostConditionErroredSubscriber.php new file mode 100644 index 0000000..0583a05 --- /dev/null +++ b/src/Event/PostConditionErroredSubscriber.php @@ -0,0 +1,23 @@ +switchToHookEvent($event)) { + return; + } + + $this->completeFailure($event->throwable(), Status::broken()); + } +} diff --git a/src/Event/PostConditionFailedSubscriber.php b/src/Event/PostConditionFailedSubscriber.php new file mode 100644 index 0000000..6721e18 --- /dev/null +++ b/src/Event/PostConditionFailedSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $throwable = $this->eventThrowable($event); + if ($throwable === null) { + return; + } + + $this->completeFailure($throwable, Status::failed()); + } +} diff --git a/src/Event/PostConditionFinishedSubscriber.php b/src/Event/PostConditionFinishedSubscriber.php new file mode 100644 index 0000000..ba25bb8 --- /dev/null +++ b/src/Event/PostConditionFinishedSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $this + ->testLifecycle + ->completeHookFixtureSuccess(); + } +} diff --git a/src/Event/PreConditionCalledSubscriber.php b/src/Event/PreConditionCalledSubscriber.php new file mode 100644 index 0000000..3158758 --- /dev/null +++ b/src/Event/PreConditionCalledSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $this + ->testLifecycle + ->startBeforeHookFixture($this->hookName($event->calledMethod())); + } +} diff --git a/src/Event/PreConditionErroredSubscriber.php b/src/Event/PreConditionErroredSubscriber.php new file mode 100644 index 0000000..abed727 --- /dev/null +++ b/src/Event/PreConditionErroredSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $this->completeFailure($event->throwable(), Status::broken()); + $this->testLifecycle->markPendingFinalize(); + } +} diff --git a/src/Event/PreConditionFailedSubscriber.php b/src/Event/PreConditionFailedSubscriber.php new file mode 100644 index 0000000..9381a5e --- /dev/null +++ b/src/Event/PreConditionFailedSubscriber.php @@ -0,0 +1,24 @@ +switchToHookEvent($event)) { + return; + } + + $throwable = $this->eventThrowable($event); + if ($throwable === null) { + return; + } + + $this->completeFailure($throwable, Status::failed()); + } +} diff --git a/src/Event/PreConditionFinishedSubscriber.php b/src/Event/PreConditionFinishedSubscriber.php new file mode 100644 index 0000000..1209292 --- /dev/null +++ b/src/Event/PreConditionFinishedSubscriber.php @@ -0,0 +1,21 @@ +throwable(); $this ->testLifecycle ->switchTo($test) - ->updateDetectedStatus($event->throwable()->message(), Status::broken()); + ->completeOrphanHookFailure( + Status::broken(), + $throwable->message(), + $throwable->asString(), + ) + ->updateDetectedStatus($throwable->message(), Status::broken()); } } diff --git a/src/Event/TestFailedSubscriber.php b/src/Event/TestFailedSubscriber.php index 9865fde..95da6ce 100644 --- a/src/Event/TestFailedSubscriber.php +++ b/src/Event/TestFailedSubscriber.php @@ -25,9 +25,15 @@ public function notify(Failed $event): void return; } + $throwable = $event->throwable(); $this ->testLifecycle ->switchTo($test) - ->updateDetectedStatus($event->throwable()->message(), Status::failed(), Status::failed()); + ->completeOrphanHookFailure( + Status::failed(), + $throwable->message(), + $throwable->asString(), + ) + ->updateDetectedStatus($throwable->message(), Status::failed(), Status::failed()); } } diff --git a/src/Event/TestFinishedSubscriber.php b/src/Event/TestFinishedSubscriber.php index 3104f4a..eb45e0d 100644 --- a/src/Event/TestFinishedSubscriber.php +++ b/src/Event/TestFinishedSubscriber.php @@ -27,8 +27,10 @@ public function notify(Finished $event): void $this ->testLifecycle ->switchTo($test) + ->completeHookFixtureSuccess() ->stop() ->updateRunInfo() - ->write(); + ->write() + ->flushPendingFinalize(); } } diff --git a/src/Event/TestPreparationStartedSubscriber.php b/src/Event/TestPreparationStartedSubscriber.php index d1da65f..16f824d 100644 --- a/src/Event/TestPreparationStartedSubscriber.php +++ b/src/Event/TestPreparationStartedSubscriber.php @@ -26,6 +26,7 @@ public function notify(PreparationStarted $event): void $this ->testLifecycle + ->flushPendingFinalize() ->switchTo($test) ->reset() ->create(); diff --git a/src/Event/TestPreparedSubscriber.php b/src/Event/TestPreparedSubscriber.php index 14dd9ae..a3ccc48 100644 --- a/src/Event/TestPreparedSubscriber.php +++ b/src/Event/TestPreparedSubscriber.php @@ -27,6 +27,7 @@ public function notify(Prepared $event): void $this ->testLifecycle ->switchTo($test) + ->completeHookFixtureSuccess() ->updateInfo() ->start(); } diff --git a/src/Event/TestSuiteFinishedSubscriber.php b/src/Event/TestSuiteFinishedSubscriber.php new file mode 100644 index 0000000..5f49ffa --- /dev/null +++ b/src/Event/TestSuiteFinishedSubscriber.php @@ -0,0 +1,23 @@ +testLifecycle->flushPendingFinalize(); + } +} diff --git a/src/Internal/HookFailureMessage.php b/src/Internal/HookFailureMessage.php new file mode 100644 index 0000000..2b453f8 --- /dev/null +++ b/src/Internal/HookFailureMessage.php @@ -0,0 +1,22 @@ +lifecycle->startTest( $this->adapter->getTestId($this->getCurrentTest()), ); + $this->adapter->markTestStarted($this->getCurrentTest()); return $this; } @@ -75,11 +78,16 @@ public function start(): self #[\Override] public function stop(): self { + $info = $this->getCurrentTest(); + if ($this->adapter->wasTestWritten($info)) { + return $this; + } + $this->lifecycle->stopTest( - $this->adapter->getTestId($this->getCurrentTest()), + $this->adapter->getTestId($info), ); $this->lifecycle->stopContainer( - $this->adapter->getContainerId($this->getCurrentTest()), + $this->adapter->getContainerId($info), ); return $this; @@ -102,12 +110,18 @@ public function updateRunInfo(): self #[\Override] public function write(): self { + $info = $this->getCurrentTest(); + if ($this->adapter->wasTestWritten($info)) { + return $this; + } + $this->lifecycle->writeTest( - $this->adapter->getTestId($this->getCurrentTest()), + $this->adapter->getTestId($info), ); $this->lifecycle->writeContainer( - $this->adapter->getContainerId($this->getCurrentTest()), + $this->adapter->getContainerId($info), ); + $this->adapter->markTestWritten($info); return $this; } @@ -153,6 +167,7 @@ public function switchTo(TestMethod $test): self $thread = $this->threadDetector->getThread(); $this->lifecycle->switchThread($thread); + $this->currentClassName = null; $this->currentTest = $this->buildTestInfo( $test, $this->threadDetector->getHost(), @@ -170,6 +185,216 @@ public function reset(): self return $this; } + #[\Override] + public function ensureClassContainer(string $className): self + { + $this->currentClassName = $className; + $this->currentTest = null; + + if ($this->adapter->getClassContainerId($className) !== null) { + return $this; + } + + $containerResult = $this->resultFactory->createContainer(); + $this->lifecycle->startContainer($containerResult); + $this->adapter->registerClassContainer($className, $containerResult->getUuid()); + + return $this; + } + + #[\Override] + public function writeClassContainer(?string $className = null): self + { + $className ??= $this->currentClassName; + if ($className === null) { + return $this; + } + + $containerId = $this->adapter->getClassContainerId($className); + if ($containerId === null) { + return $this; + } + + $this->completeHookFixtureSuccess(); + $this->lifecycle->stopContainer($containerId); + $this->lifecycle->writeContainer($containerId); + $this->adapter->clearClassContainer($className); + + return $this; + } + + #[\Override] + public function startBeforeHookFixture(string $hookName): self + { + return $this->startHookFixture($hookName, before: true); + } + + #[\Override] + public function startAfterHookFixture(string $hookName): self + { + return $this->startHookFixture($hookName, before: false); + } + + #[\Override] + public function completeHookFixtureSuccess(): self + { + $uuid = $this->adapter->getActiveFixtureUuid(); + if ($uuid === null) { + return $this; + } + + $this->lifecycle->updateFixture( + static fn (FixtureResult $fixture) => $fixture->setStatus(Status::passed()), + $uuid, + ); + $this->lifecycle->stopFixture($uuid); + $this->adapter->clearActiveFixture(); + + return $this; + } + + #[\Override] + public function completeHookFixtureFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): self { + $uuid = $this->adapter->getActiveFixtureUuid(); + $hookName = $this->adapter->getActiveHookName() ?? 'hook'; + if ($uuid === null) { + return $this; + } + + $prefixed = HookFailureMessage::format($hookName, $message); + + $this->lifecycle->updateFixture( + static function (FixtureResult $fixture) use ($status, $prefixed, $trace): void { + $fixture + ->setStatus($status) + ->setStatusDetails( + (new StatusDetails()) + ->setMessage($prefixed) + ->setTrace($trace), + ); + }, + $uuid, + ); + $this->lifecycle->stopFixture($uuid); + + if (!$this->adapter->hasEmittedHookGlobalError($uuid)) { + Allure::globalError($prefixed, $trace); + $this->adapter->markHookGlobalErrorEmitted($uuid); + } + + $this->adapter->clearActiveFixture(); + + return $this; + } + + #[\Override] + public function finalizeWithoutFinished(): self + { + if ($this->currentTest === null) { + return $this; + } + + $info = $this->getCurrentTest(); + if ($this->adapter->wasTestWritten($info)) { + $this->adapter->clearPendingFinalize(); + + return $this; + } + + $this->completeHookFixtureSuccess(); + $this->lifecycle->stopTest($this->adapter->getTestId($info)); + $this->lifecycle->stopContainer($this->adapter->getContainerId($info)); + $this->updateRunInfo(); + $this->write(); + $this->adapter->clearPendingFinalize(); + + return $this; + } + + #[\Override] + public function markPendingFinalize(): self + { + if ($this->currentTest !== null) { + $this->adapter->markPendingFinalize($this->getCurrentTest()); + } + + return $this; + } + + #[\Override] + public function flushPendingFinalize(): self + { + $pending = $this->adapter->getPendingFinalizeTest(); + if ($pending === null) { + return $this; + } + + $previous = $this->currentTest; + $this->currentTest = $pending; + $this->finalizeWithoutFinished(); + $this->currentTest = $previous; + + return $this; + } + + #[\Override] + public function completeOrphanHookFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): self { + if ($this->currentTest === null) { + return $this; + } + + if ($this->adapter->getActiveFixtureUuid() === null) { + return $this; + } + + if ($this->adapter->wasTestStarted($this->getCurrentTest())) { + return $this; + } + + $this->completeHookFixtureFailure($status, $message, $trace); + $this->markPendingFinalize(); + + return $this; + } + + private function startHookFixture(string $hookName, bool $before): self + { + $this->completeHookFixtureSuccess(); + + $fixture = $this->resultFactory + ->createFixture() + ->setName($hookName); + + $containerId = $this->resolveContainerId(); + if ($before) { + $this->lifecycle->startBeforeFixture($fixture, $containerId); + } else { + $this->lifecycle->startAfterFixture($fixture, $containerId); + } + + $this->adapter->setActiveFixture($fixture->getUuid(), $hookName); + + return $this; + } + + private function resolveContainerId(): string + { + if ($this->currentClassName !== null) { + return $this->adapter->getClassContainerId($this->currentClassName) + ?? throw new RuntimeException("Class container is not set for {$this->currentClassName}"); + } + + return $this->adapter->getContainerId($this->getCurrentTest()); + } + private function getCurrentTest(): TestInfo { return $this->currentTest ?? throw new RuntimeException("Current test is not set"); diff --git a/src/Internal/TestLifecycleInterface.php b/src/Internal/TestLifecycleInterface.php index b5a2fa2..1d41147 100644 --- a/src/Internal/TestLifecycleInterface.php +++ b/src/Internal/TestLifecycleInterface.php @@ -32,4 +32,41 @@ public function updateDetectedStatus( public function switchTo(TestMethod $test): TestLifecycleInterface; public function reset(): TestLifecycleInterface; + + public function ensureClassContainer(string $className): TestLifecycleInterface; + + public function writeClassContainer(?string $className = null): TestLifecycleInterface; + + public function startBeforeHookFixture(string $hookName): TestLifecycleInterface; + + public function startAfterHookFixture(string $hookName): TestLifecycleInterface; + + public function completeHookFixtureSuccess(): TestLifecycleInterface; + + public function completeHookFixtureFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): TestLifecycleInterface; + + /** + * Writes the current test/container when PHPUnit skips TestFinished + * (e.g. setUp throws before Prepared). + */ + public function finalizeWithoutFinished(): TestLifecycleInterface; + + public function markPendingFinalize(): TestLifecycleInterface; + + public function flushPendingFinalize(): TestLifecycleInterface; + + /** + * Completes an active hook fixture when dedicated hook Failed/Errored events + * are missing (older PHPUnit) and the failure surfaces as TestFailed/TestErrored + * before the test body starts. + */ + public function completeOrphanHookFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): TestLifecycleInterface; } diff --git a/test/integration/Fixture/SetUpAssertionFailureTest.php b/test/integration/Fixture/SetUpAssertionFailureTest.php new file mode 100644 index 0000000..26d2c24 --- /dev/null +++ b/test/integration/Fixture/SetUpAssertionFailureTest.php @@ -0,0 +1,21 @@ +&1', + escapeshellarg(__DIR__), + escapeshellarg($outputDir), + $phpunit, + $filterArg, + ); + + exec($cmd, $output, $exitCode); + self::assertNotSame(0, $exitCode, implode("\n", $output)); + + $globals = $this->readGlobalMessages($outputDir); + $matching = array_values(array_filter( + $globals, + static fn (string $message): bool => str_starts_with($message, $messagePrefix), + )); + self::assertCount( + 1, + $matching, + sprintf( + "Expected one global starting with %s, got: %s\nPHPUnit output:\n%s", + $messagePrefix, + (string) json_encode($globals), + implode("\n", $output), + ), + ); + + $fixtures = $this->readFixturesNamed($outputDir, $hookName); + self::assertNotEmpty($fixtures, "Expected fixture named {$hookName}"); + $statuses = array_map( + static fn (array $fixture): ?string => $fixture['status'] ?? null, + $fixtures, + ); + self::assertContains($fixtureStatus, $statuses, (string) json_encode($fixtures)); + } + + /** + * @return iterable + */ + public static function providerHookFailures(): iterable + { + yield 'setUp throw' => [ + 'SetUpFailureTest', + 'setUp', + 'setUp failed', + 'broken', + ]; + yield 'tearDown throw' => [ + 'TearDownFailureTest', + 'tearDown', + 'tearDown failed', + 'broken', + ]; + yield 'setUpBeforeClass throw' => [ + 'SetUpBeforeClassFailureTest', + 'setUpBeforeClass', + 'setUpBeforeClass failed', + 'broken', + ]; + yield 'tearDownAfterClass throw' => [ + 'TearDownAfterClassFailureTest', + 'tearDownAfterClass', + 'tearDownAfterClass failed', + 'broken', + ]; + yield 'setUp assertion' => [ + 'SetUpAssertionFailureTest', + 'setUp', + 'setUp failed', + 'failed', + ]; + yield 'setUp empty message' => [ + 'SetUpEmptyMessageFailureTest', + 'setUp', + 'setUp failed', + 'broken', + ]; + } + + /** + * @return list + */ + private function readGlobalMessages(string $outputDir): array + { + $messages = []; + $files = glob($outputDir . '/*-globals.json'); + if (!is_array($files)) { + return []; + } + + foreach ($files as $file) { + /** @var array{errors?: list} $data */ + $data = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + foreach ($data['errors'] ?? [] as $error) { + if (isset($error['message'])) { + $messages[] = $error['message']; + } + } + } + + return $messages; + } + + /** + * @return list + */ + private function readFixturesNamed(string $outputDir, string $hookName): array + { + $fixtures = []; + $files = glob($outputDir . '/*-container.json'); + if (!is_array($files)) { + return []; + } + + foreach ($files as $file) { + /** + * @var array{ + * befores?: list, + * afters?: list + * } $data + */ + $data = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + foreach ([...($data['befores'] ?? []), ...($data['afters'] ?? [])] as $fixture) { + if (($fixture['name'] ?? null) === $hookName) { + $fixtures[] = $fixture; + } + } + } + + return $fixtures; + } +} diff --git a/test/integration/allure.config.php b/test/integration/allure.config.php new file mode 100644 index 0000000..0ee74d2 --- /dev/null +++ b/test/integration/allure.config.php @@ -0,0 +1,9 @@ + ($output = getenv('ALLURE_HOOK_OUTPUT')) !== false && $output !== '' + ? $output + : sys_get_temp_dir() . '/allure-phpunit-hook-int', +]; diff --git a/test/integration/phpunit.hooks.xml b/test/integration/phpunit.hooks.xml new file mode 100644 index 0000000..c0f08b9 --- /dev/null +++ b/test/integration/phpunit.hooks.xml @@ -0,0 +1,23 @@ + + + + + Fixture/ + + + + + + + + diff --git a/test/unit/Event/BeforeFirstTestMethodHookSubscriberTest.php b/test/unit/Event/BeforeFirstTestMethodHookSubscriberTest.php new file mode 100644 index 0000000..fdf73a6 --- /dev/null +++ b/test/unit/Event/BeforeFirstTestMethodHookSubscriberTest.php @@ -0,0 +1,72 @@ +createMock(TestLifecycleInterface::class); + $subscriber = new BeforeFirstTestMethodCalledSubscriber($testLifecycle); + $event = $this->createBeforeFirstTestMethodCalledEvent(self::class, 'setUpBeforeClass'); + + $testLifecycle + ->expects(self::once()) + ->method('ensureClassContainer') + ->with(self::identicalTo(self::class)) + ->willReturnSelf(); + $testLifecycle + ->expects(self::once()) + ->method('startBeforeHookFixture') + ->with(self::identicalTo('setUpBeforeClass')) + ->willReturnSelf(); + + $subscriber->notify($event); + } + + public function testErrored_WritesGlobalViaFailureAndClassContainer(): void + { + $testLifecycle = $this->createMock(TestLifecycleInterface::class); + $subscriber = new BeforeFirstTestMethodErroredSubscriber($testLifecycle); + $event = $this->createBeforeFirstTestMethodErroredEvent( + self::class, + 'class boom', + 'setUpBeforeClass', + ); + + $testLifecycle + ->expects(self::once()) + ->method('ensureClassContainer') + ->with(self::identicalTo(self::class)) + ->willReturnSelf(); + $testLifecycle + ->expects(self::once()) + ->method('completeHookFixtureFailure') + ->with( + self::identicalTo(Status::broken()), + self::identicalTo('class boom'), + self::anything(), + ) + ->willReturnSelf(); + $testLifecycle + ->expects(self::once()) + ->method('writeClassContainer') + ->with(self::identicalTo(self::class)) + ->willReturnSelf(); + + $subscriber->notify($event); + } +} diff --git a/test/unit/Event/BeforeTestMethodHookSubscriberTest.php b/test/unit/Event/BeforeTestMethodHookSubscriberTest.php new file mode 100644 index 0000000..2b5e7db --- /dev/null +++ b/test/unit/Event/BeforeTestMethodHookSubscriberTest.php @@ -0,0 +1,116 @@ +createMock(TestLifecycleInterface::class); + $subscriber = new BeforeTestMethodCalledSubscriber($testLifecycle); + $test = $this->createTestMethod(class: 'ExampleTest', methodName: 'testSomething'); + $event = $this->createBeforeTestMethodCalledEvent($test, 'setUp'); + + if (method_exists(BeforeTestMethodCalled::class, 'test')) { + $testLifecycle + ->expects(self::once()) + ->method('switchTo') + ->with(self::identicalTo($test)) + ->willReturnSelf(); + } else { + $testLifecycle + ->expects(self::never()) + ->method('switchTo'); + } + $testLifecycle + ->expects(self::once()) + ->method('startBeforeHookFixture') + ->with(self::identicalTo('setUp')) + ->willReturnSelf(); + + $subscriber->notify($event); + } + + public function testFailed_CompletesHookFixtureAsFailed(): void + { + if (!interface_exists(BeforeTestMethodFailedSubscriberInterface::class)) { + $this->markTestSkipped('BeforeTestMethodFailed is not available on this PHPUnit version'); + } + + $testLifecycle = $this->createMock(TestLifecycleInterface::class); + $subscriber = new BeforeTestMethodFailedSubscriber($testLifecycle); + $test = $this->createTestMethod(class: 'ExampleTest', methodName: 'testSomething'); + $event = $this->createBeforeTestMethodFailedEvent($test, 'assertion failed', 'setUp'); + + $testLifecycle + ->expects(self::once()) + ->method('switchTo') + ->with(self::identicalTo($test)) + ->willReturnSelf(); + $testLifecycle + ->expects(self::once()) + ->method('completeHookFixtureFailure') + ->with( + self::identicalTo(Status::failed()), + self::identicalTo('assertion failed'), + self::anything(), + ) + ->willReturnSelf(); + + $subscriber->notify($event); + } + + public function testErrored_CompletesHookFixtureAsBrokenAndMarksPendingFinalize(): void + { + $testLifecycle = $this->createMock(TestLifecycleInterface::class); + $subscriber = new BeforeTestMethodErroredSubscriber($testLifecycle); + $test = $this->createTestMethod(class: 'ExampleTest', methodName: 'testSomething'); + $event = $this->createBeforeTestMethodErroredEvent($test, 'boom', 'setUp'); + + if (method_exists(BeforeTestMethodCalled::class, 'test')) { + $testLifecycle + ->expects(self::once()) + ->method('switchTo') + ->willReturnSelf(); + } else { + $testLifecycle + ->expects(self::never()) + ->method('switchTo'); + } + $testLifecycle + ->expects(self::once()) + ->method('completeHookFixtureFailure') + ->with( + self::identicalTo(Status::broken()), + self::identicalTo('boom'), + self::anything(), + ) + ->willReturnSelf(); + $testLifecycle + ->expects(self::once()) + ->method('markPendingFinalize') + ->willReturnSelf(); + + $subscriber->notify($event); + } +} diff --git a/test/unit/Event/EventTestTrait.php b/test/unit/Event/EventTestTrait.php index 5557fd0..977a5db 100644 --- a/test/unit/Event/EventTestTrait.php +++ b/test/unit/Event/EventTestTrait.php @@ -9,12 +9,18 @@ use PHPUnit\Event\Code\TestDox; use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\Throwable; +use PHPUnit\Event\Code\ClassMethod; use PHPUnit\Event\Telemetry\Duration; use PHPUnit\Event\Telemetry\GarbageCollectorStatus; use PHPUnit\Event\Telemetry\HRTime; use PHPUnit\Event\Telemetry\Info; use PHPUnit\Event\Telemetry\MemoryUsage; use PHPUnit\Event\Telemetry\Snapshot; +use PHPUnit\Event\Test\BeforeFirstTestMethodCalled; +use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; +use PHPUnit\Event\Test\BeforeTestMethodCalled; +use PHPUnit\Event\Test\BeforeTestMethodErrored; +use PHPUnit\Event\Test\BeforeTestMethodFailed; use PHPUnit\Event\Test\ConsideredRisky; use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Failed; @@ -33,6 +39,8 @@ /** * Implementation-dependent helpers to test event subscribers. + * + * @psalm-suppress InternalMethod */ trait EventTestTrait { @@ -246,6 +254,151 @@ protected function createTestWarningTriggeredEvent( ); } + /** + * @psalm-suppress InternalMethod + * @psalm-suppress ArgumentTypeCoercion + */ + protected function createClassMethod( + ?string $className = null, + ?string $methodName = null, + ): ClassMethod { + /** + * @var class-string $class + * @var non-empty-string $method + */ + $class = $className ?? 'class'; + $method = $methodName ?? 'setUp'; + + return new ClassMethod($class, $method); + } + + /** + * PHPUnit < 12.5: (Info, class-string, ClassMethod) + * PHPUnit >= 12.5: (Info, TestMethod, ClassMethod) + * + * @psalm-suppress InternalMethod + * @psalm-suppress ArgumentTypeCoercion + * @psalm-suppress TooManyArguments + * @psalm-suppress InvalidArgument + */ + protected function createBeforeTestMethodCalledEvent( + TestMethod $test, + ?string $hookName = null, + ): BeforeTestMethodCalled { + $calledMethod = $this->createClassMethod($test->className(), $hookName ?? 'setUp'); + if (method_exists(BeforeTestMethodCalled::class, 'test')) { + return new BeforeTestMethodCalled( + $this->createTelemetryInfo(), + $test, + $calledMethod, + ); + } + + return new BeforeTestMethodCalled( + $this->createTelemetryInfo(), + $test->className(), + $calledMethod, + ); + } + + /** + * @psalm-suppress InternalMethod + * @psalm-suppress ArgumentTypeCoercion + * @psalm-suppress TooManyArguments + * @psalm-suppress InvalidArgument + * @psalm-suppress UndefinedClass + */ + protected function createBeforeTestMethodFailedEvent( + TestMethod $test, + ?string $message = null, + ?string $hookName = null, + ): BeforeTestMethodFailed { + $calledMethod = $this->createClassMethod($test->className(), $hookName ?? 'setUp'); + $throwable = $this->createThrowable(message: $message ?? 'message'); + if (method_exists(BeforeTestMethodFailed::class, 'test')) { + return new BeforeTestMethodFailed( + $this->createTelemetryInfo(), + $test, + $calledMethod, + $throwable, + ); + } + + return new BeforeTestMethodFailed( + $this->createTelemetryInfo(), + $test->className(), + $calledMethod, + $throwable, + ); + } + + /** + * PHPUnit < 12.5: (Info, class-string, ClassMethod, Throwable) + * PHPUnit >= 12.5: (Info, TestMethod, ClassMethod, Throwable) + * + * @psalm-suppress InternalMethod + * @psalm-suppress ArgumentTypeCoercion + * @psalm-suppress TooManyArguments + * @psalm-suppress InvalidArgument + */ + protected function createBeforeTestMethodErroredEvent( + TestMethod $test, + ?string $message = null, + ?string $hookName = null, + ): BeforeTestMethodErrored { + $calledMethod = $this->createClassMethod($test->className(), $hookName ?? 'setUp'); + $throwable = $this->createThrowable(message: $message ?? 'message'); + if (method_exists(BeforeTestMethodErrored::class, 'test')) { + return new BeforeTestMethodErrored( + $this->createTelemetryInfo(), + $test, + $calledMethod, + $throwable, + ); + } + + return new BeforeTestMethodErrored( + $this->createTelemetryInfo(), + $test->className(), + $calledMethod, + $throwable, + ); + } + + /** + * @param class-string $testClassName + * @psalm-suppress InternalMethod + * @psalm-suppress ArgumentTypeCoercion + */ + protected function createBeforeFirstTestMethodCalledEvent( + string $testClassName, + ?string $hookName = null, + ): BeforeFirstTestMethodCalled { + return new BeforeFirstTestMethodCalled( + $this->createTelemetryInfo(), + $testClassName, + $this->createClassMethod($testClassName, $hookName ?? 'setUpBeforeClass'), + ); + } + + /** + * @param class-string $testClassName + * @psalm-suppress InternalMethod + * @psalm-suppress ArgumentTypeCoercion + */ + protected function createBeforeFirstTestMethodErroredEvent( + string $testClassName, + ?string $message = null, + ?string $hookName = null, + ): BeforeFirstTestMethodErrored { + return new BeforeFirstTestMethodErrored( + $this->createTelemetryInfo(), + $testClassName, + $this->createClassMethod($testClassName, $hookName ?? 'setUpBeforeClass'), + $this->createThrowable(message: $message ?? 'message'), + ); + } + /** * GarbageCollectorStatus doesn't exist in PHPUnit 10.0 * @psalm-suppress UndefinedClass diff --git a/test/unit/Event/TestErroredSubscriberTest.php b/test/unit/Event/TestErroredSubscriberTest.php index 1699d5d..fa8e2e1 100644 --- a/test/unit/Event/TestErroredSubscriberTest.php +++ b/test/unit/Event/TestErroredSubscriberTest.php @@ -50,6 +50,21 @@ function () use (&$switched, $testLifecycle) { } ); $testLifecycle + ->expects(self::once()) + ->method('completeOrphanHookFailure') + ->with( + self::identicalTo(Status::broken()), + self::identicalTo('c'), + self::anything(), + ) + ->willReturnCallback( + function () use (&$switched, $testLifecycle) { + self::assertTrue($switched, "completeOrphanHookFailure() was called before switchTo()"); + + return $testLifecycle; + } + ); + $testLifecycle ->expects(self::once()) ->method('updateDetectedStatus') ->with( @@ -58,9 +73,7 @@ function () use (&$switched, $testLifecycle) { self::identicalTo(null), ) ->willReturnCallback( - function () use (&$switched, $testLifecycle) { - self::assertTrue($switched, "updateDetectedStatus() was called before switchTo()"); - + function () use ($testLifecycle) { return $testLifecycle; } ); diff --git a/test/unit/Event/TestFailedSubscriberTest.php b/test/unit/Event/TestFailedSubscriberTest.php index 17bd71d..c4ff113 100644 --- a/test/unit/Event/TestFailedSubscriberTest.php +++ b/test/unit/Event/TestFailedSubscriberTest.php @@ -49,6 +49,10 @@ function () use (&$switched, $testLifecycle) { return $testLifecycle; } ); + $testLifecycle + ->expects(self::once()) + ->method('completeOrphanHookFailure') + ->willReturn($testLifecycle); $testLifecycle ->expects(self::once()) ->method('updateDetectedStatus') diff --git a/test/unit/Event/TestFinishedSubscriberTest.php b/test/unit/Event/TestFinishedSubscriberTest.php index 76c0ab5..0f6cf9f 100644 --- a/test/unit/Event/TestFinishedSubscriberTest.php +++ b/test/unit/Event/TestFinishedSubscriberTest.php @@ -49,11 +49,27 @@ function () use (&$lastMethod, $testLifecycle) { } ); $testLifecycle + ->expects(self::once()) + ->method('completeHookFixtureSuccess') + ->willReturnCallback( + function () use (&$lastMethod, $testLifecycle) { + self::assertEquals("switchTo", $lastMethod); + + $lastMethod = "completeHookFixtureSuccess"; + + return $testLifecycle; + } + ); + $testLifecycle ->expects(self::once()) ->method('stop') ->willReturnCallback( function () use (&$lastMethod, $testLifecycle) { - self::assertEquals("switchTo", $lastMethod, "stop() was called before switchTo()"); + self::assertEquals( + "completeHookFixtureSuccess", + $lastMethod, + "stop() was called before completeHookFixtureSuccess()", + ); $lastMethod = "stop"; @@ -79,6 +95,18 @@ function () use (&$lastMethod, $testLifecycle) { function () use (&$lastMethod, $testLifecycle) { self::assertEquals("updateRunInfo", $lastMethod, "write() was called before updateRunInfo()"); + $lastMethod = "write"; + + return $testLifecycle; + } + ); + $testLifecycle + ->expects(self::once()) + ->method('flushPendingFinalize') + ->willReturnCallback( + function () use (&$lastMethod, $testLifecycle) { + self::assertEquals("write", $lastMethod); + return $testLifecycle; } ); diff --git a/test/unit/Event/TestPreparationStartedSubscriberTest.php b/test/unit/Event/TestPreparationStartedSubscriberTest.php index 263c986..cd338bf 100644 --- a/test/unit/Event/TestPreparationStartedSubscriberTest.php +++ b/test/unit/Event/TestPreparationStartedSubscriberTest.php @@ -37,12 +37,24 @@ public function testNotify_ValidTestMethod_CreatesTestAfterResettingSwitchedCont $event = $this->createTestPreparationStartedEvent($test); $lastMethod = null; + $testLifecycle + ->expects(self::once()) + ->method('flushPendingFinalize') + ->willReturnCallback( + function () use (&$lastMethod, $testLifecycle) { + $lastMethod = "flushPendingFinalize"; + + return $testLifecycle; + } + ); $testLifecycle ->expects(self::once()) ->method('switchTo') ->with(self::identicalTo($test)) ->willReturnCallback( function () use (&$lastMethod, $testLifecycle) { + self::assertEquals("flushPendingFinalize", $lastMethod); + $lastMethod = "switchTo"; return $testLifecycle; @@ -55,7 +67,7 @@ function () use (&$lastMethod, $testLifecycle) { function () use (&$lastMethod, $testLifecycle) { self::assertEquals("switchTo", $lastMethod, "reset() was called before switchTo()"); - $lastMethod = true; + $lastMethod = "reset"; return $testLifecycle; } diff --git a/test/unit/Event/TestPreparedSubscriberTest.php b/test/unit/Event/TestPreparedSubscriberTest.php index 010afcc..17f3e0b 100644 --- a/test/unit/Event/TestPreparedSubscriberTest.php +++ b/test/unit/Event/TestPreparedSubscriberTest.php @@ -49,13 +49,29 @@ function () use (&$lastMethod, $testLifecycle) { } ); $testLifecycle + ->expects(self::once()) + ->method('completeHookFixtureSuccess') + ->willReturnCallback( + function () use (&$lastMethod, $testLifecycle) { + self::assertEquals("switchTo", $lastMethod); + + $lastMethod = "completeHookFixtureSuccess"; + + return $testLifecycle; + } + ); + $testLifecycle ->expects(self::once()) ->method('updateInfo') ->willReturnCallback( function () use (&$lastMethod, $testLifecycle) { - self::assertEquals("switchTo", $lastMethod, "updateInfo() was called before switchTo()"); + self::assertEquals( + "completeHookFixtureSuccess", + $lastMethod, + "updateInfo() was called before completeHookFixtureSuccess()", + ); - $lastMethod = true; + $lastMethod = "updateInfo"; return $testLifecycle; } diff --git a/test/unit/Internal/HookFailureMessageTest.php b/test/unit/Internal/HookFailureMessageTest.php new file mode 100644 index 0000000..3c35fad --- /dev/null +++ b/test/unit/Internal/HookFailureMessageTest.php @@ -0,0 +1,39 @@ + + */ + public static function providerFormat(): iterable + { + yield 'with details' => ['setUp', 'boom', 'setUp failed: boom']; + yield 'empty string' => ['setUp', '', 'setUp failed']; + yield 'whitespace only' => ['tearDown', " \t ", 'tearDown failed']; + yield 'null' => ['setUpBeforeClass', null, 'setUpBeforeClass failed']; + yield 'assertion message' => [ + 'assertPreConditions', + 'Failed asserting that false is true.', + 'assertPreConditions failed: Failed asserting that false is true.', + ]; + } +} diff --git a/test/unit/Internal/TestLifecycleHookFixtureTest.php b/test/unit/Internal/TestLifecycleHookFixtureTest.php new file mode 100644 index 0000000..5374d94 --- /dev/null +++ b/test/unit/Internal/TestLifecycleHookFixtureTest.php @@ -0,0 +1,136 @@ +outputDirectory = sys_get_temp_dir() . '/allure-phpunit-hook-' . uniqid('', true); + mkdir($this->outputDirectory); + Allure::getLifecycleConfigurator()->setOutputDirectory($this->outputDirectory); + } + + #[\Override] + protected function tearDown(): void + { + Allure::reset(); + AllureAdapter::reset(); + } + + public function testCompleteHookFixtureFailure_EmitsPrefixedGlobalAndStopsFixture(): void + { + $lifecycle = $this->createLifecycle(); + $test = $this->createTestMethod( + class: self::class, + methodName: 'testCompleteHookFixtureFailure_EmitsPrefixedGlobalAndStopsFixture', + ); + $lifecycle->switchTo($test)->create()->startBeforeHookFixture('setUp'); + $lifecycle->completeHookFixtureFailure(Status::broken(), 'boom', 'trace-line'); + + $globals = $this->readGlobalErrors(); + self::assertCount(1, $globals); + self::assertSame('setUp failed: boom', $globals[0]['message'] ?? null); + self::assertSame('trace-line', $globals[0]['trace'] ?? null); + } + + public function testCompleteHookFixtureFailure_BlankMessage_UsesFallback(): void + { + $lifecycle = $this->createLifecycle(); + $test = $this->createTestMethod( + class: self::class, + methodName: 'testCompleteHookFixtureFailure_BlankMessage_UsesFallback', + ); + $lifecycle->switchTo($test)->create()->startBeforeHookFixture('setUp'); + $lifecycle->completeHookFixtureFailure(Status::failed(), ' ', null); + + $globals = $this->readGlobalErrors(); + self::assertCount(1, $globals); + self::assertSame('setUp failed', $globals[0]['message'] ?? null); + } + + public function testCompleteHookFixtureFailure_CalledTwice_DoesNotDuplicateGlobal(): void + { + $lifecycle = $this->createLifecycle(); + $test = $this->createTestMethod( + class: self::class, + methodName: 'testCompleteHookFixtureFailure_CalledTwice_DoesNotDuplicateGlobal', + ); + $lifecycle->switchTo($test)->create()->startBeforeHookFixture('setUp'); + + $adapter = AllureAdapter::getInstance(); + $uuid = $adapter->getActiveFixtureUuid(); + self::assertNotNull($uuid); + + $lifecycle->completeHookFixtureFailure(Status::broken(), 'once', 't'); + // Simulate a second attempt against the same fixture UUID after clear. + $adapter->setActiveFixture($uuid, 'setUp'); + $lifecycle->completeHookFixtureFailure(Status::broken(), 'twice', 't'); + + $globals = $this->readGlobalErrors(); + self::assertCount(1, $globals); + self::assertSame('setUp failed: once', $globals[0]['message'] ?? null); + } + + private function createLifecycle(): TestLifecycle + { + return new TestLifecycle( + Allure::getLifecycle(), + Allure::getConfig()->getResultFactory(), + Allure::getConfig()->getStatusDetector(), + new DefaultThreadDetector(), + AllureAdapter::getInstance(), + new TestUpdater(Allure::getConfig()->getLinkTemplates()), + ); + } + + /** + * @return list + */ + private function readGlobalErrors(): array + { + $files = glob($this->outputDirectory . '/*-globals.json'); + if (!is_array($files)) { + return []; + } + + $errors = []; + foreach ($files as $file) { + /** @var array{errors?: list} $data */ + $data = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + foreach ($data['errors'] ?? [] as $error) { + $errors[] = $error; + } + } + + return $errors; + } +} diff --git a/test/unit/TestTestLifecycle.php b/test/unit/TestTestLifecycle.php index 33d7545..c7d31ca 100644 --- a/test/unit/TestTestLifecycle.php +++ b/test/unit/TestTestLifecycle.php @@ -72,4 +72,70 @@ public function write(): TestLifecycleInterface { return $this; } + + #[\Override] + public function ensureClassContainer(string $className): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function writeClassContainer(?string $className = null): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function startBeforeHookFixture(string $hookName): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function startAfterHookFixture(string $hookName): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function completeHookFixtureSuccess(): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function completeHookFixtureFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): TestLifecycleInterface { + return $this; + } + + #[\Override] + public function finalizeWithoutFinished(): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function markPendingFinalize(): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function flushPendingFinalize(): TestLifecycleInterface + { + return $this; + } + + #[\Override] + public function completeOrphanHookFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): TestLifecycleInterface { + return $this; + } }