diff --git a/src/Assert.php b/src/Assert.php new file mode 100644 index 0000000..c23b091 --- /dev/null +++ b/src/Assert.php @@ -0,0 +1,60 @@ + 'null', - $value === true => 'true', - $value === false => 'false', - \is_string($value) => '"' . \str_replace('"', '\\"', $value) . '"', - \is_array($value) => 'array(' . \count($value) . ')', - \is_resource($value) => 'resource', - \is_object($value) => $value::class, - default => (string) $value, - }; - } -} diff --git a/src/Assert/AssertCollector.php b/src/Assert/AssertCollector.php deleted file mode 100644 index 704784b..0000000 --- a/src/Assert/AssertCollector.php +++ /dev/null @@ -1,16 +0,0 @@ - The history of assertions. - */ - public array $history = []; -} diff --git a/src/Assert/AssertCollectorInterceptor.php b/src/Assert/Interceptor/AssertCollectorInterceptor.php similarity index 75% rename from src/Assert/AssertCollectorInterceptor.php rename to src/Assert/Interceptor/AssertCollectorInterceptor.php index 63e6553..e806006 100644 --- a/src/Assert/AssertCollectorInterceptor.php +++ b/src/Assert/Interceptor/AssertCollectorInterceptor.php @@ -2,8 +2,10 @@ declare(strict_types=1); -namespace Testo\Assert; +namespace Testo\Assert\Interceptor; +use Testo\Assert\StaticState; +use Testo\Assert\TestState; use Testo\Interceptor\TestCallInterceptor; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; @@ -11,7 +13,7 @@ /** * Collects assertions. * - * Creates a new {@see AssertCollector} instance for each test and assigns it to the {@see StaticState}. + * Creates a new {@see TestState} instance for each test and assigns it to the {@see StaticState}. * After the test is executed, the collector is attached to the {@see TestResult} attributes. * * Supports both synchronous and asynchronous (Fiber-based) environments. @@ -21,9 +23,9 @@ final class AssertCollectorInterceptor implements TestCallInterceptor #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { - $collector = new AssertCollector(); + $state = new TestState(); try { - $previous = StaticState::swap($collector); + $previous = StaticState::swap($state); if (\Fiber::getCurrent() === null) { # No Fiber, run the test directly @@ -38,12 +40,12 @@ public function runTest(TestInfo $info, callable $next): TestResult try { $resume = \Fiber::suspend($value); } catch (\Throwable $e) { - $previous = StaticState::swap($collector); + $previous = StaticState::swap($state); $value = $fiber->throw($e); continue; } - $previous = StaticState::swap($collector); + $previous = StaticState::swap($state); $value = $fiber->resume($resume); } @@ -51,7 +53,7 @@ public function runTest(TestInfo $info, callable $next): TestResult $result = $fiber->getReturn(); } - return $result->withAttribute(AssertCollector::class, $collector); + return $result->withAttribute(TestState::class, $state); } finally { StaticState::swap($previous); } diff --git a/src/Assert/Interceptor/ExpectExceptionConfigurator.php b/src/Assert/Interceptor/ExpectExceptionConfigurator.php new file mode 100644 index 0000000..814645c --- /dev/null +++ b/src/Assert/Interceptor/ExpectExceptionConfigurator.php @@ -0,0 +1,34 @@ +expectException = new ExpectedException( + class: $this->options->class, + ); + + return $next($info); + } +} diff --git a/src/Assert/Interceptor/ExpectExceptionInterceptor.php b/src/Assert/Interceptor/ExpectExceptionInterceptor.php new file mode 100644 index 0000000..793386f --- /dev/null +++ b/src/Assert/Interceptor/ExpectExceptionInterceptor.php @@ -0,0 +1,68 @@ +expectException; + + # No state or expectation defined + if ($expectation === null) { + # Test failed due to an assertion failure + if ($result->status->isFailure() && $result->failure instanceof AssertException) { + $result = $result->with(status: Status::Failed); + } + + return $result; + } + + # An expectation was defined + # Check if the expectation was met + $record = self::isPassed($expectation, $result->failure); + $context->history[] = $record; + $context->expectException = null; + + return $record->isSuccess() + ? $result->with(status: Status::Passed) + : $result->with(status: Status::Failed)->withFailure($record); + } + + private static function isPassed(State\ExpectedException $expected, ?\Throwable $actual): Record|AssertException + { + if (!$actual instanceof $expected->class) { + return AssertException::exceptionClass($expected->class, $actual); + } + + return new Success( + assertion: $expected->class === $actual::class + ? 'Throw exception: `' . $expected->class . '`.' + : 'Throw exception: `' . $actual::class . '` (got `' . $expected->class . '`).', + ); + } +} diff --git a/src/Assert/Record.php b/src/Assert/Record.php deleted file mode 100644 index 2de5fa9..0000000 --- a/src/Assert/Record.php +++ /dev/null @@ -1,17 +0,0 @@ - $expected The expected exception class. + * @param \Throwable|null $actual The actual exception thrown, or null if none was thrown. + */ + public static function exceptionClass( + string $expected, + ?\Throwable $actual, + ): self { + $msg = $actual === null + ? "Expected exception of type `$expected`, none thrown." + : "Expected exception of type `$expected`, got `" . $actual::class . '`.'; + + return new self( + assertion: $msg, + context: '', + details: '', + ); + } + + public function isSuccess(): bool + { + return false; + } +} diff --git a/src/Assert/State/ExpectedException.php b/src/Assert/State/ExpectedException.php new file mode 100644 index 0000000..81c8151 --- /dev/null +++ b/src/Assert/State/ExpectedException.php @@ -0,0 +1,18 @@ +success; + } +} diff --git a/src/Assert/StaticState.php b/src/Assert/StaticState.php index f9a377f..90f2654 100644 --- a/src/Assert/StaticState.php +++ b/src/Assert/StaticState.php @@ -4,6 +4,10 @@ namespace Testo\Assert; +use Testo\Assert\State\AssertException; +use Testo\Assert\State\ExpectedException; +use Testo\Assert\State\Success; + /** * Holds the current assertion collector. * @@ -12,32 +16,66 @@ */ final class StaticState { - public static ?AssertCollector $collector = null; + public static ?TestState $state = null; /** - * Swaps the current collector with the given one. + * Swap the current collector with the given one. * - * @return AssertCollector|null The previous collector. + * @return TestState|null The previous collector. */ - public static function swap(?AssertCollector $collector): ?AssertCollector + public static function swap(?TestState $collector): ?TestState { - [self::$collector, $collector] = [$collector, self::$collector]; + [self::$state, $collector] = [$collector, self::$state]; return $collector; } - public static function pass(string $name): void + /** + * Get the current collector. + */ + public static function current(): ?TestState { - self::$collector === null or self::$collector->history[] = new Record( - passed: true, - method: $name, + return self::$state; + } + + /** + * @param non-empty-string $assertion The assertion result (e.g., "Same: 42", "Assert `true`"). + * @param non-empty-string $context Optional user-provided context describing what is being asserted. + */ + public static function log(string $assertion, string $context): void + { + self::$state === null or self::$state->history[] = new Success( + assertion: $assertion, + context: $context, ); } - public static function fail(string $name): void + /** + * Log a failed assertion and throw the given exception. + * + * @template T of AssertException + * @param T $failure The assertion failure. + * @throws T + */ + public static function fail(AssertException $failure): never { - self::$collector === null or self::$collector->history[] = new Record( - passed: false, - method: $name, + self::$state === null or self::$state->history[] = $failure; + throw $failure; + } + + /** + * Set the expected exception for the current test. + * + * @param class-string<\Throwable> $class The expected exception class or interface. + * + * @throws \RuntimeException when there is no current {@see TestState}. + */ + public static function expectException( + string $class, + ): void { + # todo make the exception friendlier + self::$state === null and throw new \RuntimeException( + 'No current AssertState to set expected exception on.', ); + self::$state->expectException = new ExpectedException($class); } } diff --git a/src/Assert/Support.php b/src/Assert/Support.php new file mode 100644 index 0000000..0a19a17 --- /dev/null +++ b/src/Assert/Support.php @@ -0,0 +1,28 @@ + 'null', + $value === true => 'true', + $value === false => 'false', + \is_string($value) => \strlen($value) > 64 + ? 'string(' . \strlen($value) . ')' + : '"' . \str_replace('"', '\\"', $value) . '"', + \is_array($value) => 'array(' . \count($value) . ')', + \is_resource($value) => 'resource', + \is_object($value) => $value::class, + default => (string) $value, + }; + } +} diff --git a/src/Assert/TestState.php b/src/Assert/TestState.php new file mode 100644 index 0000000..1d74b94 --- /dev/null +++ b/src/Assert/TestState.php @@ -0,0 +1,24 @@ + The history of assertions. + */ + public array $history = []; + + /** + * @var ExpectedException|null Expected exception configuration. + */ + public ?ExpectedException $expectException = null; +} diff --git a/src/Attribute/ExpectException.php b/src/Attribute/ExpectException.php new file mode 100644 index 0000000..5bb1e10 --- /dev/null +++ b/src/Attribute/ExpectException.php @@ -0,0 +1,23 @@ + $class Expected exception class. + */ + public function __construct( + public readonly string $class, + ) {} +} diff --git a/src/Interceptor/Exception/PipelineException.php b/src/Interceptor/Exception/PipelineException.php new file mode 100644 index 0000000..2c5079b --- /dev/null +++ b/src/Interceptor/Exception/PipelineException.php @@ -0,0 +1,10 @@ +map[$class] ??= $attrs === [] ? null : tr($attrs[0]->newInstance()->class); + return $this->map[$class] ??= $attrs === [] ? null : $attrs[0]->newInstance()->class; } } diff --git a/src/Suite/SuiteCollector.php b/src/Suite/SuiteCollector.php index 1a0a4bf..f49c354 100644 --- a/src/Suite/SuiteCollector.php +++ b/src/Suite/SuiteCollector.php @@ -77,7 +77,7 @@ private function getFilesIterator(SuiteConfig $config): iterable $interceptors = $this->interceptorProvider->fromClasses(FileLocatorInterceptor::class); # todo remove: - // $interceptors[] = new FilePostfixTestLocatorInterceptor(); + $interceptors[] = new FilePostfixTestLocatorInterceptor(); $interceptors[] = new TestoAttributesLocatorInterceptor(); /** @@ -110,6 +110,7 @@ private function getCaseDefinitions(SuiteConfig $config, iterable $files): array // todo remove: $interceptors[] = new FilePostfixTestLocatorInterceptor(); + $interceptors[] = new TestoAttributesLocatorInterceptor(); /** * @see CaseLocatorInterceptor::locateTestCases() diff --git a/src/Test/CaseRunner.php b/src/Test/CaseRunner.php index b430016..64a7837 100644 --- a/src/Test/CaseRunner.php +++ b/src/Test/CaseRunner.php @@ -16,7 +16,6 @@ final class CaseRunner { public function __construct( private readonly TestRunner $testRunner, - private readonly TestsProvider $testsProvider, private readonly InterceptorProvider $interceptorProvider, ) {} diff --git a/src/Test/Dto/Status.php b/src/Test/Dto/Status.php index 635863e..474f05d 100644 --- a/src/Test/Dto/Status.php +++ b/src/Test/Dto/Status.php @@ -4,40 +4,88 @@ namespace Testo\Test\Dto; +use Testo\Attribute\RetryPolicy; +use Testo\Assert; + +/** + * Possible statuses of a test execution. + */ enum Status { /** - * Test executed successfully with all assertions passing. + * Test executed successfully and all ASSERTIONS PASSED without other issues. + * + * @see Assert */ case Passed; /** - * Test failed due to assertion failure. + * Test failed due to ASSERTION or an EXPECTATION failure. + * + * @see Assert */ case Failed; /** - * Test was skipped and not executed. + * Test was SKIPPED and not executed. */ case Skipped; /** - * Test encountered an error or exception during execution. + * Test encountered an EXCEPTION during execution. */ case Error; /** - * Test ran but has potential issues (no assertions, output, or global state changes). + * Test COMPLETED successfully but has potential ISSUES (no assertions, output, or global state changes). */ case Risky; /** - * Completed but failed at least once. + * COMPLETED but after RETRIES due to intermittent failures. + * + * @see RetryPolicy */ case Flaky; /** - * Test was cancelled before completion. + * Test was CANCELLED before completion. */ case Cancelled; + + /** + * Successfully or not, the test has reached a terminal state. + */ + public function isCompleted(): bool + { + return match ($this) { + self::Cancelled, + self::Skipped => false, + default => true, + }; + } + + /** + * Indicates whether the test execution is considered successful. + */ + public function isSuccessful(): bool + { + return match ($this) { + self::Passed, + self::Flaky => true, + default => false, + }; + } + + /** + * Indicates whether the test execution is considered a failure. + */ + public function isFailure(): bool + { + return match ($this) { + self::Failed, + self::Error => true, + default => false, + }; + } } diff --git a/src/Test/Dto/TestResult.php b/src/Test/Dto/TestResult.php index fade4e8..c462b72 100644 --- a/src/Test/Dto/TestResult.php +++ b/src/Test/Dto/TestResult.php @@ -12,8 +12,9 @@ final class TestResult public function __construct( public readonly TestInfo $info, - public readonly mixed $result, public readonly Status $status, + public readonly mixed $result = null, + public readonly ?\Throwable $failure = null, public readonly array $attributes = [], ) {} @@ -22,17 +23,20 @@ public function with( ): self { return new self( info: $this->info, - result: $this->result, status: $status ?? $this->status, + result: $this->result, + failure: $this->failure, + attributes: $this->attributes, ); } public function withResult(mixed $result): self { - return new self( - info: $this->info, - result: $result, - status: $this->status, - ); + return $this->cloneWith('result', $result); + } + + public function withFailure(?\Throwable $failure): self + { + return $this->cloneWith('failure', $failure); } } diff --git a/src/Test/TestRunner.php b/src/Test/TestRunner.php index 7b98fa0..a16f110 100644 --- a/src/Test/TestRunner.php +++ b/src/Test/TestRunner.php @@ -4,8 +4,10 @@ namespace Testo\Test; -use Testo\Assert\AssertCollectorInterceptor; +use Testo\Assert\Interceptor\AssertCollectorInterceptor; +use Testo\Assert\Interceptor\ExpectExceptionInterceptor; use Testo\Attribute\Interceptable; +use Testo\Interceptor\Exception\PipelineException; use Testo\Interceptor\InterceptorProvider; use Testo\Interceptor\Internal\Pipeline; use Testo\Interceptor\TestCallInterceptor; @@ -21,38 +23,47 @@ public function __construct( public function runTest(TestInfo $info): TestResult { - # Build interceptors pipeline - $interceptors = $this->prepareInterceptors($info); + try { + # Build interceptors pipeline + $interceptors = [ + new AssertCollectorInterceptor(), // todo remove + new ExpectExceptionInterceptor(), // todo remove + ...$this->prepareInterceptors($info), + ]; - // todo remove - $interceptors[] = new AssertCollectorInterceptor(); + return Pipeline::prepare(...$interceptors)->with( + static function (TestInfo $info): TestResult { + # TODO resolve arguments + # TODO don't instantiate if the method is static + $instance = $info->caseInfo->instance; + try { + $result = $instance === null + ? $info->testDefinition->reflection->invoke() + : $info->testDefinition->reflection->invoke($instance); - return Pipeline::prepare(...$interceptors)->with( - static function (TestInfo $info): TestResult { - # TODO resolve arguments - # TODO don't instantiate if the method is static - $instance = $info->caseInfo->instance; - try { - $result = $instance === null - ? $info->testDefinition->reflection->invoke() - : $info->testDefinition->reflection->invoke($instance); - - return new TestResult( - $info, - $result, - Status::Passed, - ); - } catch (\Throwable $throwable) { - return new TestResult( - $info, - $throwable, - Status::Failed, - ); - } - }, - /** @see TestCallInterceptor::runTest() */ - 'runTest', - )($info); + return new TestResult( + info: $info, + status: Status::Passed, + result: $result, + ); + } catch (\Throwable $throwable) { + return new TestResult( + info: $info, + status: Status::Error, + failure: $throwable, + ); + } + }, + /** @see TestCallInterceptor::runTest() */ + 'runTest', + )($info); + } catch (\Throwable $e) { + return new TestResult( + info: $info, + status: Status::Skipped, + failure: new PipelineException('Error during test execution pipeline.', previous: $e), + ); + } } /** diff --git a/tests/Testo/Test.php b/tests/Testo/Test.php deleted file mode 100644 index a9fccc7..0000000 --- a/tests/Testo/Test.php +++ /dev/null @@ -1,18 +0,0 @@ -