diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5f1d2b7..bfdc5fa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,16 +3,14 @@ on: push: branches: - master - pull_request: null - schedule: - - cron: "0 0 * * MON" + pull_request: ~ jobs: tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php-version: ["8.2", "8.3"] + php-version: ["8.3"] composer-flags: [""] name: [""] include: @@ -34,4 +32,13 @@ jobs: - name: Run docker run: docker run -d --rm -p 8081:80 httpbin - name: tests - run: composer test + run: bin/asynit tests --report report/asynit.xml + + - name: Test Report + uses: dorny/test-reporter@v1 + if: success() || failure() # run this step even if previous step failed + with: + name: Asynit Tests ${{ matrix.php-version }} + path: report/asynit.xml + reporter: java-junit + diff --git a/.gitignore b/.gitignore index 12cf771..ab6f405 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ composer.lock .php_cs.cache .php-cs-fixer.cache /vendor/ +report # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file diff --git a/src/Command/AsynitCommand.php b/src/Command/AsynitCommand.php index b5e1e23..36af44e 100644 --- a/src/Command/AsynitCommand.php +++ b/src/Command/AsynitCommand.php @@ -6,6 +6,7 @@ use Asynit\Output\OutputFactory; use Asynit\Parser\TestPoolBuilder; use Asynit\Parser\TestsFinder; +use Asynit\Report\JUnitReport; use Asynit\Runner\PoolRunner; use Asynit\TestWorkflow; use Symfony\Component\Console\Command\Command; @@ -35,6 +36,7 @@ protected function configure(): void ->addOption('retry', null, InputOption::VALUE_REQUIRED, 'Default retry number for http request', 0) ->addOption('bootstrap', null, InputOption::VALUE_REQUIRED, 'A PHP file to include before anything else', $this->defaultBootstrapFilename) ->addOption('order', null, InputOption::VALUE_NONE, 'Output tests execution order') + ->addOption('report', null, InputOption::VALUE_REQUIRED, 'JUnit report directory') ; } @@ -55,8 +57,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $testsSuites = $testsFinder->findTests($target); $testsCount = array_reduce($testsSuites, fn (int $carry, $suite) => $carry + \count($suite->tests), 0); - /** @phpstan-ignore-next-line */ - $useOrder = (boolean) $input->getOption('order'); + $useOrder = (bool) $input->getOption('order'); list($chainOutput, $countOutput) = (new OutputFactory($useOrder))->buildOutput($testsCount); @@ -82,7 +83,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Build a list of tests from the directory $pool = $builder->build($testsSuites); + $start = microtime(true); $runner->loop($pool); + $end = microtime(true); + + /** @var string|null $reportDir */ + $reportDir = $input->getOption('report'); + + if (null !== $reportDir) { + $report = new JUnitReport($reportDir); + $time = $end - $start; + $report->generate($time, $testsSuites); + } // Return the number of failed tests return $countOutput->getFailed(); diff --git a/src/Parser/TestPoolBuilder.php b/src/Parser/TestPoolBuilder.php index c9a41eb..079fb0a 100644 --- a/src/Parser/TestPoolBuilder.php +++ b/src/Parser/TestPoolBuilder.php @@ -72,7 +72,7 @@ private function processTestAnnotations(\ArrayObject $tests, Test $test): void throw new \RuntimeException(sprintf('Failed to build test pool "%s" dependency is not resolvable for "%s::%s".', $dependency->dependency, $test->getMethod()->getDeclaringClass()->getName(), $test->getMethod()->getName())); } - $dependentTest = new Test(new \ReflectionMethod($class, $method), null, false); + $dependentTest = new Test(null, new \ReflectionMethod($class, $method), null, false); if (isset($tests[$dependentTest->getIdentifier()])) { $dependentTest = $tests[$dependentTest->getIdentifier()]; diff --git a/src/Parser/TestsFinder.php b/src/Parser/TestsFinder.php index b46b09b..35c240a 100644 --- a/src/Parser/TestsFinder.php +++ b/src/Parser/TestsFinder.php @@ -66,9 +66,9 @@ private function doFindTests(iterable $files): array $test = null; if (count($tests) > 0) { - $test = new Test($reflectionMethod); + $test = new Test($testSuite, $reflectionMethod); } elseif (preg_match('/^test(.+)$/', $reflectionMethod->getName())) { - $test = new Test($reflectionMethod); + $test = new Test($testSuite, $reflectionMethod); } if (null !== $test) { diff --git a/src/Report/JUnitReport.php b/src/Report/JUnitReport.php new file mode 100644 index 0000000..abf2fd0 --- /dev/null +++ b/src/Report/JUnitReport.php @@ -0,0 +1,136 @@ +[] $testSuites + */ + public function generate(float $time, array $testSuites): void + { + $xml = new \DOMDocument('1.0', 'UTF-8'); + $xml->formatOutput = true; + + $root = $xml->createElement('testsuites'); + $root->setAttribute('name', 'asynit'); + + $totalTests = 0; + $totalFailures = 0; + $totalErrors = 0; + $totalSuccess = 0; + $totalSKipped = 0; + $totalAssertions = 0; + + /** @var TestSuite $testSuite */ + foreach ($testSuites as $testSuite) { + $testsCount = count($testSuite->tests); + + if (0 === $testsCount) { + continue; + } + + $failures = $testSuite->getFailure(); + $errors = $testSuite->getErrors(); + $success = $testSuite->getSuccess(); + $skipped = $testSuite->getSkipped(); + $assertions = $testSuite->getAssertions(); + + $totalTests += $testsCount; + $totalFailures += $failures; + $totalErrors += $errors; + $totalSuccess += $success; + $totalSKipped += $skipped; + $totalAssertions += $assertions; + + $testsuites = $xml->createElement('testsuite'); + $testsuites->setAttribute('name', $testSuite->reflectionClass->getName()); + $testsuites->setAttribute('tests', (string) $testsCount); + $testsuites->setAttribute('failures', (string) $failures); + $testsuites->setAttribute('errors', (string) $errors); + $testsuites->setAttribute('skipped', (string) $skipped); + $testsuites->setAttribute('assertions', (string) $assertions); + $testsuites->setAttribute('time', (string) $testSuite->getTime()); + // timestamp in ISO 8601 format + $date = \DateTime::createFromFormat('U.u', (string) $testSuite->startTime); + + if ($date) { + $testsuites->setAttribute('timestamp', $date->format(\DateTimeInterface::ISO8601_EXPANDED)); + } + + $testsuites->setAttribute('file', (string) $testSuite->reflectionClass->getFileName()); + $root->appendChild($testsuites); + + /** @var Test $test */ + foreach ($testSuite->tests as $test) { + $testcase = $xml->createElement('testcase'); + $testcase->setAttribute('name', $test->getDisplayName()); + $testcase->setAttribute('classname', $testSuite->reflectionClass->getName()); + $testcase->setAttribute('assertions', (string) $test->getAssertionsCount()); + $testcase->setAttribute('time', (string) $test->getTime()); + $testcase->setAttribute('file', (string) $testSuite->reflectionClass->getFileName()); + $testcase->setAttribute('line', (string) $test->method->getStartLine()); + $date = \DateTime::createFromFormat('U.u', (string) $test->startTime); + + if ($date) { + $testcase->setAttribute('timestamp', $date->format(\DateTimeInterface::ISO8601_EXPANDED)); + } + + $testsuites->appendChild($testcase); + + if ('' !== $test->output) { + $systemOut = $xml->createElement('system-out'); + $systemOut->appendChild($xml->createCDATASection($test->output)); + $testcase->appendChild($systemOut); + } + + if (Test::STATE_FAILURE === $test->state) { + if ($test->failure instanceof AssertionFailure) { + $failure = $xml->createElement('failure'); + $failure->setAttribute('message', $test->failure->getMessage()); + $failure->setAttribute('type', get_class($test->failure)); + + $testcase->appendChild($failure); + } else { + $failure = $xml->createElement('error'); + $failure->setAttribute('message', $test->failure->getMessage()); + $failure->setAttribute('type', get_class($test->failure)); + + $testcase->appendChild($failure); + } + } + + if (Test::STATE_SKIPPED === $test->state) { + $skipped = $xml->createElement('skipped'); + $testcase->appendChild($skipped); + } + } + } + + $directory = dirname($this->filename); + + if (!is_dir($directory)) { + @mkdir($directory, 0755, true); + } + + $root->setAttribute('tests', (string) $totalTests); + $root->setAttribute('failures', (string) $totalFailures); + $root->setAttribute('errors', (string) $totalErrors); + $root->setAttribute('skipped', (string) $totalSKipped); + $root->setAttribute('assertions', (string) $totalAssertions); + $root->setAttribute('time', (string) $time); + + $xml->appendChild($root); + + $xml->save($this->filename); + } +} diff --git a/src/Test.php b/src/Test.php index 8475406..59e2290 100644 --- a/src/Test.php +++ b/src/Test.php @@ -27,12 +27,24 @@ final class Test private string $identifier; - private string $state; - private string $displayName; + public string $state; + + public float $startTime; + + public float $endTime; + + public string $output; + + public \Throwable $failure; + + /** + * @param TestSuite|null $suite + */ public function __construct( - private readonly \ReflectionMethod $method, + public readonly ?TestSuite $suite, + public readonly \ReflectionMethod $method, ?string $identifier = null, public readonly bool $isRealTest = true, ) { @@ -75,14 +87,37 @@ public function canBeRun(): bool return true; } - public function getState(): string + public function start(): void + { + $this->suite?->start(); + $this->startTime = microtime(true); + $this->state = self::STATE_RUNNING; + } + + public function success(string $output): void { - return $this->state; + $this->endTime = microtime(true); + $this->output = $output; + $this->state = self::STATE_SUCCESS; + $this->suite?->tryEnd(); } - public function setState(string $state): void + public function failure(string $output, \Throwable $error): void { - $this->state = $state; + $this->endTime = microtime(true); + $this->output = $output; + $this->state = self::STATE_FAILURE; + $this->failure = $error; + $this->suite?->tryEnd(); + } + + public function skipped(): void + { + $this->startTime = microtime(true); + $this->endTime = microtime(true); + $this->output = ''; + $this->state = self::STATE_SKIPPED; + $this->suite?->tryEnd(); } public function getIdentifier(): string @@ -124,6 +159,11 @@ public function getAssertions(): array return $this->assertions; } + public function getAssertionsCount(): int + { + return \count($this->assertions); + } + /** * @return Test[] */ @@ -171,4 +211,9 @@ public function setDisplayName(string $displayName): void { $this->displayName = $displayName; } + + public function getTime(): float + { + return $this->endTime - $this->startTime; + } } diff --git a/src/TestSuite.php b/src/TestSuite.php index c24449e..6983afc 100644 --- a/src/TestSuite.php +++ b/src/TestSuite.php @@ -2,7 +2,7 @@ namespace Asynit; -/** +use bovigo\assert\AssertionFailure; /** * @internal * * @template T of object @@ -12,6 +12,10 @@ final class TestSuite /** @var array */ public array $tests = []; + public float $startTime; + + public float $endTime; + /** * @param \ReflectionClass $reflectionClass */ @@ -19,4 +23,91 @@ public function __construct( public readonly \ReflectionClass $reflectionClass ) { } + + public function start(): void + { + if (isset($this->startTime)) { + return; + } + + $this->startTime = microtime(true); + } + + public function tryEnd(): void + { + foreach ($this->tests as $test) { + if (!$test->isCompleted()) { + return; + } + } + + $this->endTime = microtime(true); + } + + public function getFailure(): int + { + $failure = 0; + foreach ($this->tests as $test) { + if (Test::STATE_FAILURE === $test->state && $test->failure instanceof AssertionFailure) { + ++$failure; + } + } + + return $failure; + } + + public function getErrors(): int + { + $errors = 0; + foreach ($this->tests as $test) { + if (Test::STATE_FAILURE === $test->state && !$test->failure instanceof AssertionFailure) { + ++$errors; + } + } + + return $errors; + } + + public function getSuccess(): int + { + $success = 0; + foreach ($this->tests as $test) { + if (Test::STATE_SUCCESS === $test->state) { + ++$success; + } + } + + return $success; + } + + public function getSkipped(): int + { + $skipped = 0; + foreach ($this->tests as $test) { + if (Test::STATE_SKIPPED === $test->state) { + ++$skipped; + } + } + + return $skipped; + } + + public function getAssertions(): int + { + $assertions = 0; + foreach ($this->tests as $test) { + $assertions += $test->getAssertionsCount(); + } + + return $assertions; + } + + public function getTime(): float + { + if (!isset($this->endTime)) { + return 0.0; + } + + return $this->endTime - $this->startTime; + } } diff --git a/src/TestWorkflow.php b/src/TestWorkflow.php index 01f2790..cd67e68 100644 --- a/src/TestWorkflow.php +++ b/src/TestWorkflow.php @@ -19,11 +19,10 @@ public function markTestAsRunning(Test $test): void return; } - $test->setState(Test::STATE_RUNNING); - $debugOutput = ob_get_contents(); ob_clean(); + $test->start(); $this->output->outputStep($test, false === $debugOutput ? '' : $debugOutput); } @@ -33,10 +32,10 @@ public function markTestAsSuccess(Test $test): void return; } - $test->setState(Test::STATE_SUCCESS); - $debugOutput = ob_get_contents(); ob_clean(); + + $test->success(false === $debugOutput ? '' : $debugOutput); $this->output->outputSuccess($test, false === $debugOutput ? '' : $debugOutput); } @@ -46,14 +45,11 @@ public function markTestAsFailed(Test $test, \Throwable $error): void return; } - $test->setState(Test::STATE_FAILURE); - $debugOutput = ob_get_contents(); ob_clean(); - if (is_string($debugOutput)) { - $this->output->outputFailure($test, $debugOutput, $error); - } + $test->failure(false === $debugOutput ? '' : $debugOutput, $error); + $this->output->outputFailure($test, false === $debugOutput ? '' : $debugOutput, $error); foreach ($test->getChildren(true) as $child) { $this->markTestAsSkipped($child); @@ -66,7 +62,7 @@ public function markTestAsSkipped(Test $test): void return; } - $test->setState(Test::STATE_SKIPPED); + $test->skipped(); foreach ($test->getChildren() as $child) { $this->markTestAsSkipped($child);