junit support

This commit is contained in:
Adrian Nürnberger
2023-07-30 08:35:00 +02:00
parent 8f738f5d49
commit e5dc6f0ae2
14 changed files with 874 additions and 0 deletions

View File

@ -25,6 +25,7 @@ final class BootSubscribers implements Bootstrapper
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureJunitEnabled::class,
];
/**

View File

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit;
use NunoMaduro\Collision\Adapters\Phpunit\State;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Support\StateGenerator;
use Pest\Support\Str;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Framework\Exception as FrameworkException;
use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult;
/**
* @internal
*/
final class Converter
{
private const PREFIX = 'P\\';
private readonly StateGenerator $stateGenerator;
/**
* Creates a new instance of the Converter.
*/
public function __construct(
private readonly string $rootPath,
) {
$this->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 <path>`
$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);
}
}

View File

@ -0,0 +1,398 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit;
use DOMDocument;
use DOMElement;
use Pest\Logging\JUnit\Subscriber\TestErroredSubscriber;
use Pest\Logging\JUnit\Subscriber\TestFailedSubscriber;
use Pest\Logging\JUnit\Subscriber\TestFinishedSubscriber;
use Pest\Logging\JUnit\Subscriber\TestMarkedIncompleteSubscriber;
use Pest\Logging\JUnit\Subscriber\TestPreparedSubscriber;
use Pest\Logging\JUnit\Subscriber\TestRunnerExecutionFinishedSubscriber;
use Pest\Logging\JUnit\Subscriber\TestSkippedSubscriber;
use Pest\Logging\JUnit\Subscriber\TestSuiteFinishedSubscriber;
use Pest\Logging\JUnit\Subscriber\TestSuiteStartedSubscriber;
use Pest\TestSuite;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\EventFacadeIsSealedException;
use PHPUnit\Event\Facade;
use PHPUnit\Event\InvalidArgumentException;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\Telemetry\Info;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestData\NoDataSetFromDataProviderException;
use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\UnknownSubscriberTypeException;
use PHPUnit\TextUI\Output\Printer;
use PHPUnit\Util\Xml;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class JUnitLogger
{
private DOMDocument $document;
private DOMElement $root;
/**
* @var DOMElement[]
*/
private array $testSuites = [];
/**
* @psalm-var array<int,int>
*/
private array $testSuiteTests = [0];
/**
* @psalm-var array<int,int>
*/
private array $testSuiteAssertions = [0];
/**
* @psalm-var array<int,int>
*/
private array $testSuiteErrors = [0];
/**
* @psalm-var array<int,int>
*/
private array $testSuiteFailures = [0];
/**
* @psalm-var array<int,int>
*/
private array $testSuiteSkipped = [0];
/**
* @psalm-var array<int,int>
*/
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();
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use Pest\Logging\JUnit\JUnitLogger;
/**
* @internal
*/
abstract class Subscriber
{
/**
* Creates a new Subscriber instance.
*/
public function __construct(private readonly JUnitLogger $logger)
{
}
/**
* Creates a new JunitLogger instance.
*/
final protected function logger(): JUnitLogger
{
return $this->logger;
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final class TestErroredSubscriber extends Subscriber implements ErroredSubscriber
{
public function notify(Errored $event): void
{
$this->logger()->testErrored($event);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final class TestFailedSubscriber extends Subscriber implements FailedSubscriber
{
public function notify(Failed $event): void
{
$this->logger()->testFailed($event);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final class TestFinishedSubscriber extends Subscriber implements FinishedSubscriber
{
public function notify(Finished $event): void
{
$this->logger()->testFinished($event);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
/**
* @internal
*/
final class TestMarkedIncompleteSubscriber extends Subscriber implements MarkedIncompleteSubscriber
{
public function notify(MarkedIncomplete $event): void
{
$this->logger()->testMarkedIncomplete($event);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
/**
* @internal
*/
final class TestPreparedSubscriber extends Subscriber implements PreparedSubscriber
{
public function notify(Prepared $event): void
{
$this->logger()->testPrepared($event);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\TestRunner\ExecutionFinished;
use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber;
/**
* @internal
*/
final class TestRunnerExecutionFinishedSubscriber extends Subscriber implements ExecutionFinishedSubscriber
{
public function notify(ExecutionFinished $event): void
{
$this->logger()->flush();
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
/**
* @internal
*/
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
{
public function notify(Skipped $event): void
{
$this->logger()->testSkipped($event);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\FinishedSubscriber;
/**
* @internal
*/
final class TestSuiteFinishedSubscriber extends Subscriber implements FinishedSubscriber
{
public function notify(Finished $event): void
{
$this->logger()->testSuiteFinished();
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\JUnit\Subscriber;
use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\TestSuite\StartedSubscriber;
/**
* @internal
*/
final class TestSuiteStartedSubscriber extends Subscriber implements StartedSubscriber
{
public function notify(Started $event): void
{
$this->logger()->testSuiteStarted($event);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Logging\JUnit\JUnitLogger;
use Pest\Logging\JUnit\Converter;
use Pest\Support\Container;
use Pest\TestSuite;
use PHPUnit\Event\TestRunner\Configured;
use PHPUnit\Event\TestRunner\ConfiguredSubscriber;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\Output\DefaultPrinter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class EnsureJunitEnabled implements ConfiguredSubscriber
{
/**
* Creates a new Configured Subscriber instance.
*/
public function __construct(
private readonly InputInterface $input,
private readonly OutputInterface $output,
private readonly TestSuite $testSuite,
) {
}
/**
* Runs the subscriber.
*/
public function notify(Configured $event): void
{
if (! $this->input->hasParameterOption('--log-junit')) {
return;
}
new JUnitLogger(
DefaultPrinter::from(Container::getInstance()->get(Configuration::class)->logfileJunit()),
$this->output,
new Converter($this->testSuite->rootPath),
);
}
}