From e5dc6f0ae2dfe468960c3a1913b7ac17f990a4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20N=C3=BCrnberger?= Date: Sun, 30 Jul 2023 08:35:00 +0200 Subject: [PATCH] junit support --- src/Bootstrappers/BootSubscribers.php | 1 + src/Logging/JUnit/Converter.php | 228 ++++++++++ src/Logging/JUnit/JUnitLogger.php | 398 ++++++++++++++++++ src/Logging/JUnit/Subscriber/Subscriber.php | 28 ++ .../Subscriber/TestErroredSubscriber.php | 19 + .../JUnit/Subscriber/TestFailedSubscriber.php | 19 + .../Subscriber/TestFinishedSubscriber.php | 19 + .../TestMarkedIncompleteSubscriber.php | 19 + .../Subscriber/TestPreparedSubscriber.php | 19 + .../TestRunnerExecutionFinishedSubscriber.php | 19 + .../Subscriber/TestSkippedSubscriber.php | 19 + .../TestSuiteFinishedSubscriber.php | 19 + .../Subscriber/TestSuiteStartedSubscriber.php | 19 + src/Subscribers/EnsureJunitEnabled.php | 48 +++ 14 files changed, 874 insertions(+) create mode 100644 src/Logging/JUnit/Converter.php create mode 100644 src/Logging/JUnit/JUnitLogger.php create mode 100644 src/Logging/JUnit/Subscriber/Subscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestErroredSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestFailedSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestFinishedSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestMarkedIncompleteSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestPreparedSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestRunnerExecutionFinishedSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestSkippedSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestSuiteFinishedSubscriber.php create mode 100644 src/Logging/JUnit/Subscriber/TestSuiteStartedSubscriber.php create mode 100644 src/Subscribers/EnsureJunitEnabled.php diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 248b9dde..ad4ab7da 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -25,6 +25,7 @@ final class BootSubscribers implements Bootstrapper Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureTeamCityEnabled::class, + Subscribers\EnsureJunitEnabled::class, ]; /** diff --git a/src/Logging/JUnit/Converter.php b/src/Logging/JUnit/Converter.php new file mode 100644 index 00000000..f417a59e --- /dev/null +++ b/src/Logging/JUnit/Converter.php @@ -0,0 +1,228 @@ +stateGenerator = new StateGenerator(); + } + + /** + * Gets the test case method name. + */ + public function getTestCaseMethodName(Test $test): string + { + if (! $test instanceof TestMethod) { + throw ShouldNotHappen::fromMessage('Not an instance of TestMethod'); + } + + return $test->testDox()->prettifiedMethodName(); + } + + /** + * Gets the test case location. + */ + public function getTestCaseLocation(Test $test, bool $withDescription = false): string + { + if (! $test instanceof TestMethod) { + throw ShouldNotHappen::fromMessage('Not an instance of TestMethod'); + } + + $path = $test->testDox()->prettifiedClassName(); + $relativePath = $this->toRelativePath($path); + + // TODO: Get the description without the dataset. + $description = $test->testDox()->prettifiedMethodName(); + + if (! $withDescription) { + return $relativePath; + } + + return "$relativePath::$description"; + } + + /** + * Gets the exception message. + */ + public function getExceptionMessage(Throwable $throwable): string + { + if (is_a($throwable->className(), FrameworkException::class, true)) { + return $throwable->message(); + } + + $buffer = $throwable->className(); + $throwableMessage = $throwable->message(); + + if ($throwableMessage !== '') { + $buffer .= ": $throwableMessage"; + } + + return $buffer; + } + + /** + * Gets the exception details. + */ + public function getExceptionDetails(Throwable $throwable): string + { + $buffer = $this->getStackTrace($throwable); + + while ($throwable->hasPrevious()) { + $throwable = $throwable->previous(); + + $buffer .= sprintf( + "\nCaused by\n%s\n%s", + $throwable->description(), + $this->getStackTrace($throwable) + ); + } + + return $buffer; + } + + /** + * Gets the stack trace. + */ + public function getStackTrace(Throwable $throwable): string + { + $stackTrace = $throwable->stackTrace(); + + // Split stacktrace per frame. + $frames = explode("\n", $stackTrace); + + // Remove empty lines + $frames = array_filter($frames); + + // clean the paths of each frame. + $frames = array_map( + fn (string $frame): string => $this->toRelativePath($frame), + $frames + ); + + // Format stacktrace as `at ` + $frames = array_map( + fn (string $frame) => "at $frame", + $frames + ); + + return implode("\n", $frames); + } + + /** + * Gets the test suite name. + */ + public function getTestSuiteName(TestSuite $testSuite): string + { + $name = $testSuite->name(); + + if (str_starts_with($name, self::PREFIX)) { + return Str::after($name, self::PREFIX); + } + + return Str::after($name, $this->rootPath); + } + + /** + * Gets the test suite location. + */ + public function getTestSuiteLocation(TestSuite $testSuite): ?string + { + $tests = $testSuite->tests()->asArray(); + + // TODO: figure out how to get the file path without a test being there. + if ($tests === []) { + return null; + } + + $firstTest = $tests[0]; + if (! $firstTest instanceof TestMethod) { + throw ShouldNotHappen::fromMessage('Not an instance of TestMethod'); + } + + $path = $firstTest->testDox()->prettifiedClassName(); + + return $this->toRelativePath($path); + } + + /** + * Gets the test suite size. + */ + public function getTestSuiteSize(TestSuite $testSuite): int + { + return $testSuite->count(); + } + + /** + * Transforms the given path in relative path. + */ + private function toRelativePath(string $path): string + { + // Remove cwd from the path. + return str_replace("$this->rootPath".DIRECTORY_SEPARATOR, '', $path); + } + + /** + * Get the test result. + */ + public function getStateFromResult(PhpUnitTestResult $result): State + { + $events = [ + ...$result->testErroredEvents(), + ...$result->testFailedEvents(), + ...$result->testSkippedEvents(), + ...array_merge(...array_values($result->testConsideredRiskyEvents())), + ...$result->testMarkedIncompleteEvents(), + ]; + + $numberOfNotPassedTests = count( + array_unique( + array_map( + function (BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string { + if ($event instanceof BeforeFirstTestMethodErrored) { + return $event->testClassName(); + } + + return $this->getTestCaseLocation($event->test()); + }, + $events + ) + ) + ); + + $numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests; + + return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result); + } +} diff --git a/src/Logging/JUnit/JUnitLogger.php b/src/Logging/JUnit/JUnitLogger.php new file mode 100644 index 00000000..538d28cb --- /dev/null +++ b/src/Logging/JUnit/JUnitLogger.php @@ -0,0 +1,398 @@ + + */ + private array $testSuiteTests = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteAssertions = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteErrors = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteFailures = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteSkipped = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteTimes = [0]; + + private int $testSuiteLevel = 0; + + private ?DOMElement $currentTestCase = null; + + private ?HRTime $time = null; + + private bool $prepared = false; + + /**✅ + * @throws EventFacadeIsSealedException + * @throws UnknownSubscriberTypeException + */ + public function __construct( + private readonly Printer $printer, + private readonly OutputInterface $output, + private readonly Converter $converter, + ) { + $this->registerSubscribers(); + $this->createDocument(); + } + + public function flush(): void + { + $this->printer->print($this->document->saveXML()); + + $this->printer->flush(); + + $this->output->writeln('Junit finished'); + } + + public function testSuiteStarted(Started $event): void + { + $testSuite = $this->document->createElement('testsuite'); + $testSuite->setAttribute('name', $this->converter->getTestSuiteName($event->testSuite())); + + if ($event->testSuite()->isForTestClass()) { + $testSuite->setAttribute('file', $this->converter->getTestSuiteLocation($event->testSuite()) ?? ''); + } + + if ($this->testSuiteLevel > 0) { + $this->testSuites[$this->testSuiteLevel]->appendChild($testSuite); + } else { + $this->root->appendChild($testSuite); + } + + $this->testSuiteLevel++; + $this->testSuites[$this->testSuiteLevel] = $testSuite; + $this->testSuiteTests[$this->testSuiteLevel] = 0; + $this->testSuiteAssertions[$this->testSuiteLevel] = 0; + $this->testSuiteErrors[$this->testSuiteLevel] = 0; + $this->testSuiteFailures[$this->testSuiteLevel] = 0; + $this->testSuiteSkipped[$this->testSuiteLevel] = 0; + $this->testSuiteTimes[$this->testSuiteLevel] = 0; + } + + public function testSuiteFinished(): void + { + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'tests', + (string) $this->testSuiteTests[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'assertions', + (string) $this->testSuiteAssertions[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'errors', + (string) $this->testSuiteErrors[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'failures', + (string) $this->testSuiteFailures[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'skipped', + (string) $this->testSuiteSkipped[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'time', + sprintf('%F', $this->testSuiteTimes[$this->testSuiteLevel]), + ); + + if ($this->testSuiteLevel > 1) { + $this->testSuiteTests[$this->testSuiteLevel - 1] += $this->testSuiteTests[$this->testSuiteLevel]; + $this->testSuiteAssertions[$this->testSuiteLevel - 1] += $this->testSuiteAssertions[$this->testSuiteLevel]; + $this->testSuiteErrors[$this->testSuiteLevel - 1] += $this->testSuiteErrors[$this->testSuiteLevel]; + $this->testSuiteFailures[$this->testSuiteLevel - 1] += $this->testSuiteFailures[$this->testSuiteLevel]; + $this->testSuiteSkipped[$this->testSuiteLevel - 1] += $this->testSuiteSkipped[$this->testSuiteLevel]; + $this->testSuiteTimes[$this->testSuiteLevel - 1] += $this->testSuiteTimes[$this->testSuiteLevel]; + } + + $this->testSuiteLevel--; + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testPrepared(Prepared $event): void + { + $this->createTestCase($event); + $this->prepared = true; + } + + /** + * @throws InvalidArgumentException + */ + public function testFinished(Finished $event): void + { + $this->handleFinish($event->telemetryInfo(), $event->numberOfAssertionsPerformed()); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testMarkedIncomplete(MarkedIncomplete $event): void + { + $this->handleIncompleteOrSkipped($event); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testSkipped(Skipped $event): void + { + $this->handleIncompleteOrSkipped($event); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testErrored(Errored $event): void + { + $this->handleFault($event, 'error'); + + $this->testSuiteErrors[$this->testSuiteLevel]++; + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testFailed(Failed $event): void + { + $this->handleFault($event, 'failure'); + + $this->testSuiteFailures[$this->testSuiteLevel]++; + } + + /** + * @throws InvalidArgumentException + */ + private function handleFinish(Info $telemetryInfo, int $numberOfAssertionsPerformed): void + { + assert($this->currentTestCase !== null); + assert($this->time !== null); + + $time = $telemetryInfo->time()->duration($this->time)->asFloat(); + + $this->testSuiteAssertions[$this->testSuiteLevel] += $numberOfAssertionsPerformed; + + $this->currentTestCase->setAttribute( + 'assertions', + (string) $numberOfAssertionsPerformed, + ); + + $this->currentTestCase->setAttribute( + 'time', + sprintf('%F', $time), + ); + + $this->testSuites[$this->testSuiteLevel]->appendChild( + $this->currentTestCase, + ); + + $this->testSuiteTests[$this->testSuiteLevel]++; + $this->testSuiteTimes[$this->testSuiteLevel] += $time; + + $this->currentTestCase = null; + $this->time = null; + $this->prepared = false; + } + + /**✅ + * @throws EventFacadeIsSealedException + * @throws UnknownSubscriberTypeException + */ + private function registerSubscribers(): void + { + $subscribers = [ + new TestSuiteStartedSubscriber($this), + new TestSuiteFinishedSubscriber($this), + new TestPreparedSubscriber($this), + new TestFinishedSubscriber($this), + new TestErroredSubscriber($this), + new TestFailedSubscriber($this), + new TestMarkedIncompleteSubscriber($this), + new TestSkippedSubscriber($this), + new TestRunnerExecutionFinishedSubscriber($this), + ]; + + Facade::instance()->registerSubscribers(...$subscribers); + } + + private function createDocument(): void + { + $this->output->writeln('Start Junit'); + + $this->document = new DOMDocument('1.0', 'UTF-8'); + $this->document->formatOutput = true; + + $this->root = $this->document->createElement('testsuites'); + $this->document->appendChild($this->root); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + private function handleFault(Errored|Failed $event, string $type): void + { + if (! $this->prepared) { + $this->createTestCase($event); + } + + assert($this->currentTestCase !== null); + + $testName = $this->converter->getTestCaseMethodName($event->test()); + // $message = $this->converter->getExceptionMessage($event->throwable()); + // $details = $this->converter->getExceptionDetails($event->throwable()); + + $buffer = $testName; + + $throwable = $event->throwable(); + $buffer .= trim( + $throwable->description().PHP_EOL. + $throwable->stackTrace(), + ); + + $fault = $this->document->createElement( + $type, + Xml::prepareString($buffer), + ); + + $fault->setAttribute('type', $throwable->className()); + + $this->currentTestCase->appendChild($fault); + + if (! $this->prepared) { + $this->handleFinish($event->telemetryInfo(), 0); + } + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + private function handleIncompleteOrSkipped(MarkedIncomplete|Skipped $event): void + { + if (! $this->prepared) { + $this->createTestCase($event); + } + + assert($this->currentTestCase !== null); + + $skipped = $this->document->createElement('skipped'); + + $this->currentTestCase->appendChild($skipped); + + $this->testSuiteSkipped[$this->testSuiteLevel]++; + + if (! $this->prepared) { + $this->handleFinish($event->telemetryInfo(), 0); + } + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + * + * @psalm-assert !null $this->currentTestCase + */ + private function createTestCase(Errored|Failed|MarkedIncomplete|Prepared|Skipped $event): void + { + $testCase = $this->document->createElement('testcase'); + + $file = $this->converter->getTestCaseLocation($event->test()); + + $testCase->setAttribute('name', $this->converter->getTestCaseMethodName($event->test())); +// $testCase->setAttribute('name', $event->test()->name()); + $testCase->setAttribute('file', $file); +// $testCase->setAttribute('file', $event->test()->file()); + + if ($event->test()->isTestMethod()) { + assert($event->test() instanceof TestMethod); + + //dd(TestSuite::getInstance()->tests->get($file)); + // add classname, and line to this + + $testCase->setAttribute('line', (string) $event->test()->line()); //@todo figure out how to get line number in original pest file + $testCase->setAttribute('class', $event->test()->name()); + $testCase->setAttribute('classname', str_replace('\\', '.', $event->test()->name())); + } + + $this->currentTestCase = $testCase; + $this->time = $event->telemetryInfo()->time(); + } +} diff --git a/src/Logging/JUnit/Subscriber/Subscriber.php b/src/Logging/JUnit/Subscriber/Subscriber.php new file mode 100644 index 00000000..7ffa1f34 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/Subscriber.php @@ -0,0 +1,28 @@ +logger; + } +} diff --git a/src/Logging/JUnit/Subscriber/TestErroredSubscriber.php b/src/Logging/JUnit/Subscriber/TestErroredSubscriber.php new file mode 100644 index 00000000..87172774 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestErroredSubscriber.php @@ -0,0 +1,19 @@ +logger()->testErrored($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestFailedSubscriber.php b/src/Logging/JUnit/Subscriber/TestFailedSubscriber.php new file mode 100644 index 00000000..d0f6c1ea --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestFailedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testFailed($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestFinishedSubscriber.php b/src/Logging/JUnit/Subscriber/TestFinishedSubscriber.php new file mode 100644 index 00000000..4c1e937d --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testFinished($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestMarkedIncompleteSubscriber.php b/src/Logging/JUnit/Subscriber/TestMarkedIncompleteSubscriber.php new file mode 100644 index 00000000..4ed5d693 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestMarkedIncompleteSubscriber.php @@ -0,0 +1,19 @@ +logger()->testMarkedIncomplete($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestPreparedSubscriber.php b/src/Logging/JUnit/Subscriber/TestPreparedSubscriber.php new file mode 100644 index 00000000..f9841361 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestPreparedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testPrepared($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestRunnerExecutionFinishedSubscriber.php b/src/Logging/JUnit/Subscriber/TestRunnerExecutionFinishedSubscriber.php new file mode 100644 index 00000000..8dcf762f --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestRunnerExecutionFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->flush(); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestSkippedSubscriber.php b/src/Logging/JUnit/Subscriber/TestSkippedSubscriber.php new file mode 100644 index 00000000..afa764ad --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestSkippedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSkipped($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestSuiteFinishedSubscriber.php b/src/Logging/JUnit/Subscriber/TestSuiteFinishedSubscriber.php new file mode 100644 index 00000000..9ed15c15 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestSuiteFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSuiteFinished(); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestSuiteStartedSubscriber.php b/src/Logging/JUnit/Subscriber/TestSuiteStartedSubscriber.php new file mode 100644 index 00000000..26f80239 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestSuiteStartedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSuiteStarted($event); + } +} diff --git a/src/Subscribers/EnsureJunitEnabled.php b/src/Subscribers/EnsureJunitEnabled.php new file mode 100644 index 00000000..79c78eee --- /dev/null +++ b/src/Subscribers/EnsureJunitEnabled.php @@ -0,0 +1,48 @@ +input->hasParameterOption('--log-junit')) { + return; + } + + new JUnitLogger( + DefaultPrinter::from(Container::getInstance()->get(Configuration::class)->logfileJunit()), + $this->output, + new Converter($this->testSuite->rootPath), + ); + } +}