diff --git a/README.md b/README.md index 0b46ba2..f97d725 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ declare(strict_types=1); namespace Tests; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\ExpectException; +use Testo\Attribute\Test; use Testo\Retry\RetryPolicy; final class CalculatorTest diff --git a/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php b/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php index a13df82..bf8dba1 100644 --- a/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php +++ b/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php @@ -4,7 +4,7 @@ namespace Testo\Application\Middleware\Locator; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; use Testo\Common\Reflection; use Testo\Core\Definition\CaseDefinitions; use Testo\Core\Value\TestType; diff --git a/src/Assert.php b/src/Assert.php index e44d2a7..c0952f1 100644 --- a/src/Assert.php +++ b/src/Assert.php @@ -24,6 +24,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionException; use Testo\Assert\State\Test\Fail; +use Testo\Attribute\AssertMethod; /** * Assertion utilities. @@ -38,6 +39,7 @@ final class Assert * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function same(mixed $expected, mixed $actual, string $message = ''): void { $actual === $expected @@ -53,6 +55,7 @@ public static function same(mixed $expected, mixed $actual, string $message = '' * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function notSame(mixed $expected, mixed $actual, string $message = ''): void { $actual !== $expected @@ -74,6 +77,7 @@ public static function notSame(mixed $expected, mixed $actual, string $message = * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function equals(mixed $expected, mixed $actual, string $message = ''): void { $actual == $expected @@ -94,6 +98,7 @@ public static function equals(mixed $expected, mixed $actual, string $message = * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function notEquals(mixed $expected, mixed $actual, string $message = ''): void { $actual != $expected @@ -114,6 +119,7 @@ public static function notEquals(mixed $expected, mixed $actual, string $message * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function true(mixed $actual, string $message = ''): void { $actual === true @@ -133,6 +139,7 @@ public static function true(mixed $actual, string $message = ''): void * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function false(mixed $actual, string $message = ''): void { $actual === false @@ -194,6 +201,7 @@ public static function contains(mixed $needle, iterable $haystack, string $messa * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function null( mixed $actual, string $message = '', @@ -215,6 +223,7 @@ public static function null( * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function blank( mixed $actual, string $message = '', @@ -238,6 +247,7 @@ public static function blank( * * @throws AssertException when the assertion fails. */ + #[AssertMethod] public static function string(mixed $actual): StringType { return AssertString::validateAndCreate($actual); @@ -335,6 +345,7 @@ public static function object(mixed $actual): ObjectType * @param string $message The reason for the failure. * @throws AssertException */ + #[AssertMethod] public static function fail(string $message = ''): never { $exception = new Fail($message); diff --git a/src/Assert/Internal/Assertion/AssertArray.php b/src/Assert/Internal/Assertion/AssertArray.php index a6f8197..2197d5d 100644 --- a/src/Assert/Internal/Assertion/AssertArray.php +++ b/src/Assert/Internal/Assertion/AssertArray.php @@ -9,6 +9,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\AssertMethod; /** * Assertion utilities for arrays. @@ -39,6 +40,7 @@ public static function validateAndCreate(mixed $value): self return new self($value, $parent); } + #[AssertMethod] #[\Override] public function hasKeys(int|string ...$keys): static { @@ -70,6 +72,7 @@ public function hasKeys(int|string ...$keys): static ); } + #[AssertMethod] #[\Override] public function doesNotHaveKeys(string|int ...$keys): static { @@ -101,6 +104,7 @@ public function doesNotHaveKeys(string|int ...$keys): static ); } + #[AssertMethod] #[\Override] public function isList(string $message = ''): static { diff --git a/src/Assert/Internal/Assertion/AssertJson.php b/src/Assert/Internal/Assertion/AssertJson.php index 5c47e3b..73fb598 100644 --- a/src/Assert/Internal/Assertion/AssertJson.php +++ b/src/Assert/Internal/Assertion/AssertJson.php @@ -10,6 +10,7 @@ use Testo\Assert\Api\Json\JsonObject; use Testo\Assert\Api\Json\JsonStructure; use Testo\Assert\State\Assertion\AssertionComposite; +use Testo\Attribute\AssertMethod; /** * Implementation of JSON assertions. @@ -30,6 +31,7 @@ public function __construct( * * @deprecated To be implemented */ + #[AssertMethod] #[\Override] public function maxDepth(int $expected): static { @@ -41,6 +43,7 @@ public function maxDepth(int $expected): static * * @deprecated To be implemented */ + #[AssertMethod] #[\Override] public function empty(): JsonCommon { @@ -56,6 +59,7 @@ public function empty(): JsonCommon * * @deprecated To be implemented */ + #[AssertMethod] #[\Override] public function assertPath(string $path, callable $callback): static { @@ -69,6 +73,7 @@ public function assertPath(string $path, callable $callback): static * * @deprecated To be implemented */ + #[AssertMethod] #[\Override] public function count(int $count, string $message = ''): static { @@ -78,6 +83,7 @@ public function count(int $count, string $message = ''): static /** * Asserts that the JSON string represents a valid JSON structure (object or array). */ + #[AssertMethod] #[\Override] public function isStructure(): JsonStructure { @@ -87,6 +93,7 @@ public function isStructure(): JsonStructure /** * Assert that the JSON string represents a valid JSON object. */ + #[AssertMethod] #[\Override] public function isObject(): JsonObject { @@ -96,6 +103,7 @@ public function isObject(): JsonObject /** * Assert that the JSON string represents a valid JSON array. */ + #[AssertMethod] #[\Override] public function isArray(): JsonArray { @@ -105,6 +113,7 @@ public function isArray(): JsonArray /** * Assert that the JSON string represents a primitive value (string, number, boolean, null). */ + #[AssertMethod] #[\Override] public function isPrimitive(): JsonCommon { @@ -119,6 +128,7 @@ public function isPrimitive(): JsonCommon * * @param non-empty-string $type The Psalm type to validate against */ + #[AssertMethod] #[\Override] public function matchesType(string $type): static { @@ -132,6 +142,7 @@ public function matchesType(string $type): static * * @deprecated To be implemented */ + #[AssertMethod] #[\Override] public function matchesSchema(string $schema): static { @@ -143,6 +154,7 @@ public function matchesSchema(string $schema): static * * @param array|string $keys The keys to check for existence. */ + #[AssertMethod] #[\Override] public function hasKeys(array|string $keys, string $message = ''): JsonObject { @@ -156,6 +168,7 @@ public function hasKeys(array|string $keys, string $message = ''): JsonObject * * @deprecated To be implemented */ + #[AssertMethod] #[\Override] public function decode(): mixed { diff --git a/src/Assert/Internal/Assertion/AssertObject.php b/src/Assert/Internal/Assertion/AssertObject.php index 62deee0..012c276 100644 --- a/src/Assert/Internal/Assertion/AssertObject.php +++ b/src/Assert/Internal/Assertion/AssertObject.php @@ -8,6 +8,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\AssertMethod; /** * Assertion utilities for objects. @@ -38,6 +39,7 @@ public static function validateAndCreate(mixed $value): self return new self($value, $parent); } + #[AssertMethod] #[\Override] public function instanceOf(string $expected, string $message = ''): static { @@ -48,6 +50,7 @@ public function instanceOf(string $expected, string $message = ''): static return $this; } + #[AssertMethod] #[\Override] public function hasProperty(string $propertyName, string $message = ''): static { diff --git a/src/Assert/Internal/Assertion/AssertString.php b/src/Assert/Internal/Assertion/AssertString.php index 6fb4304..f741e6c 100644 --- a/src/Assert/Internal/Assertion/AssertString.php +++ b/src/Assert/Internal/Assertion/AssertString.php @@ -9,6 +9,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\AssertMethod; /** * Assertion utilities for string data type. @@ -41,48 +42,36 @@ public static function validateAndCreate(mixed $value): self /** * Asserts that the string contains the given substring. * - * @param string $needle Substring to search for. + * @param non-empty-string $needle Substring to search for. * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[AssertMethod] #[\Override] public function contains(string $needle, string $message = ''): static { - if (\str_contains($this->value, $needle)) { - StaticState::success($this->value, ' contains "' . $needle . '"', $message); - return $this; - } - - StaticState::fail(AssertException::compare( - $needle, - $this->value, - $message, - pattern: 'Failed asserting that string `%2$s` contains `%1$s`.', - showDiff: false, - )); + $str = 'contains "' . $needle . '"'; + \str_contains($this->value, $needle) + ? $this->parent->success($str, $message) + : throw $this->parent->fail($str, 'the substring is not found', $message); + return $this; } /** * Asserts that the string does not contain the given substring. * - * @param string $needle Substring to search for. + * @param non-empty-string $needle Substring to search for. * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[AssertMethod] #[\Override] public function notContains(string $needle, string $message = ''): static { - if (!\str_contains($this->value, $needle)) { - StaticState::success($this->value, 'does not contain "' . $needle . '"', $message); - return $this; - } - - StaticState::fail(AssertException::compare( - $needle, - $this->value, - $message, - pattern: 'Failed asserting that string `%2$s` does not contain `%1$s`.', - showDiff: false, - )); + $str = 'does not contain "' . $needle . '"'; + !\str_contains($this->value, $needle) + ? $this->parent->success($str, $message) + : throw $this->parent->fail($str, 'the substring is found', $message); + return $this; } } diff --git a/src/Assert/Internal/Assertion/Traits/IterableTrait.php b/src/Assert/Internal/Assertion/Traits/IterableTrait.php index 0a41afa..55ff96b 100644 --- a/src/Assert/Internal/Assertion/Traits/IterableTrait.php +++ b/src/Assert/Internal/Assertion/Traits/IterableTrait.php @@ -6,6 +6,7 @@ use Testo\Assert\Internal\Support; use Testo\Assert\State\Assertion\AssertionComposite; +use Testo\Attribute\AssertMethod; /** * Contains assertion methods for iterable values. @@ -15,6 +16,7 @@ */ trait IterableTrait { + #[AssertMethod] #[\Override] public function notEmpty(string $message = ''): static { @@ -31,6 +33,7 @@ public function notEmpty(string $message = ''): static ); } + #[AssertMethod] #[\Override] public function contains(mixed $needle, string $message = ''): static { @@ -68,6 +71,7 @@ public function every(callable $callback, string $message = ''): static return $this; } + #[AssertMethod] #[\Override] public function sameSizeAs(iterable $expected, string $message = ''): static { @@ -86,6 +90,7 @@ public function sameSizeAs(iterable $expected, string $message = ''): static ); } + #[AssertMethod] #[\Override] public function allOf(string $type, string $message = ''): static { @@ -111,6 +116,7 @@ public function allOf(string $type, string $message = ''): static return $this; } + #[AssertMethod] #[\Override] public function hasCount(int $expected): static { diff --git a/src/Assert/Internal/Assertion/Traits/NumericTrait.php b/src/Assert/Internal/Assertion/Traits/NumericTrait.php index 1ab7fc8..f7eaed2 100644 --- a/src/Assert/Internal/Assertion/Traits/NumericTrait.php +++ b/src/Assert/Internal/Assertion/Traits/NumericTrait.php @@ -6,6 +6,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; +use Testo\Attribute\AssertMethod; /** * Contains methods for comparing numeric values @@ -21,6 +22,7 @@ trait NumericTrait * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[AssertMethod] #[\Override] public function greaterThan(int|float $min, string $message = ''): static { @@ -44,6 +46,7 @@ public function greaterThan(int|float $min, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[AssertMethod] #[\Override] public function greaterThanOrEqual(int|float $min, string $message = ''): static { @@ -67,6 +70,7 @@ public function greaterThanOrEqual(int|float $min, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[AssertMethod] #[\Override] public function lessThan(int|float $max, string $message = ''): static { @@ -90,6 +94,7 @@ public function lessThan(int|float $max, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[AssertMethod] #[\Override] public function lessThanOrEqual(int|float $max, string $message = ''): static { diff --git a/src/Attribute/AssertMethod.php b/src/Attribute/AssertMethod.php new file mode 100644 index 0000000..ecca71a --- /dev/null +++ b/src/Attribute/AssertMethod.php @@ -0,0 +1,60 @@ +has($id), "Service '{$id}' not found in container"); + * } + * } + * ``` + * + * Also useful for domain-specific assertions in traits: + * + * ``` + * trait ApiAssertions + * { + * #[AssertMethod] + * protected function assertJsonResponse(Response $response, int $status = 200): void + * { + * Assert::same($status, $response->getStatusCode()); + * Assert::string($response->getHeaderLine('Content-Type'))->contains('application/json'); + * } + * } + * ``` + * + * Without `#[AssertMethod]`: + * + * ``` + * #0 src/Assert/Internal/StaticState.php(42): Assert::same() + * #1 src/Assert.php(35): StaticState::fail() + * #2 app/Testing/ApiAssertions.php(12): Assert::same() + * #3 tests/Api/OrderTest.php(28): OrderTest->assertJsonResponse() + * ``` + * + * With `#[AssertMethod]`: + * + * ``` + * #0 app/Testing/ApiAssertions.php(12): Assert::same() + * #1 tests/Api/OrderTest.php(28): OrderTest->assertJsonResponse() + * ``` + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class AssertMethod implements Interceptable, LifecycleAttribute {} diff --git a/src/Application/Attribute/Test.php b/src/Attribute/Test.php similarity index 89% rename from src/Application/Attribute/Test.php rename to src/Attribute/Test.php index d8cc898..6b97542 100644 --- a/src/Application/Attribute/Test.php +++ b/src/Attribute/Test.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Application\Attribute; +namespace Testo\Attribute; /** * Marks a class (public methods), or a method or a function as a test. diff --git a/src/Bridge/Symfony/Command/Run.php b/src/Bridge/Symfony/Command/Run.php index 000698b..112df74 100644 --- a/src/Bridge/Symfony/Command/Run.php +++ b/src/Bridge/Symfony/Command/Run.php @@ -9,8 +9,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Testo\Bridge\Symfony\TerminalRenderer; -use Testo\Teamcity\TeamcityRenderer; +use Testo\Output\Teamcity\TeamcityRenderer; +use Testo\Output\Terminal\TerminalRenderer; /** * Executes test suites with optional filtering and custom output formatting. diff --git a/src/Common/Environment.php b/src/Common/Environment.php index 9c875cf..a85d02a 100644 --- a/src/Common/Environment.php +++ b/src/Common/Environment.php @@ -52,7 +52,7 @@ public static function getXDebugMode(): array { self::init(); return self::$xDebugExists && \function_exists('xdebug_info') - ? xdebug_info('mode') + ? \xdebug_info('mode') : []; } diff --git a/src/Core/Definition/TestDefinition.php b/src/Core/Definition/TestDefinition.php index 8c5ecf6..736a277 100644 --- a/src/Core/Definition/TestDefinition.php +++ b/src/Core/Definition/TestDefinition.php @@ -4,7 +4,7 @@ namespace Testo\Core\Definition; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; final class TestDefinition { @@ -19,7 +19,7 @@ public function getDescription(): ?string return null; } - /** @var Test $testAttribute */ + /** @var \Testo\Attribute\Test $testAttribute */ $testAttribute = $attributes[0]->newInstance(); return $testAttribute->description !== '' ? $testAttribute->description : null; } diff --git a/src/Output/Rendering/StackTrace.php b/src/Output/Rendering/StackTrace.php new file mode 100644 index 0000000..92d4bbb --- /dev/null +++ b/src/Output/Rendering/StackTrace.php @@ -0,0 +1,95 @@ + Cached {@see AssertMethod} attribute lookup results */ + private static array $cutTraceCache = []; + + /** + * @param list> $trace + * @param \ReflectionFunctionAbstract|null $boundary Test function — stops AssertMethod search at this frame. + * @param bool $trimAtBoundary If true, also removes all frames after the boundary. + * @return list> + */ + public static function cutStackTrace( + array $trace, + ?\ReflectionFunctionAbstract $boundary = null, + bool $trimAtBoundary = true, + ): array { + $cutIndex = null; + /** @var list $uncached Keys of frames checked but not yet cached */ + $uncached = []; + $boundaryName = $boundary?->getName(); + $boundaryClass = $boundary instanceof \ReflectionMethod + ? $boundary->getDeclaringClass()->getName() + : null; + $limit = $boundary !== null + ? \count($trace) + : \min(\count($trace), self::SEARCH_DEPTH); + + for ($index = 0; $index < $limit; $index++) { + $frame = $trace[$index]; + $class = $frame['class'] ?? null; + $function = $frame['function'] ?? null; + + // Boundary reached — stop searching for AssertMethod + if ($boundaryName !== null and $function === $boundaryName + and ($boundaryClass === null ? $class === null : $class === $boundaryClass) + ) { + $trimAtBoundary and $trace = \array_slice($trace, 0, $index + 1); + break; + } + + if ($class === null or $function === null) { + continue; + } + + $key = $class . '::' . $function; + + if (self::$cutTraceCache[$key] ?? self::resolveHasAssertMethod($key, $class, $function)) { + $cutIndex = $index; + $boundary !== null or $limit = \min(\count($trace), $index + 1 + self::SEARCH_DEPTH); + // Cache preceding uncached frames as false + foreach ($uncached as $k) { + self::$cutTraceCache[$k] = false; + } + $uncached = []; + } elseif (!isset(self::$cutTraceCache[$key])) { + $uncached[] = $key; + } + } + + return $cutIndex !== null + ? \array_slice($trace, $cutIndex) + : $trace; + } + + private static function resolveHasAssertMethod(string $key, string $class, string $function): bool + { + try { + $method = new \ReflectionMethod($class, $function); + } catch (\ReflectionException) { + return false; + } + + if ($method->getAttributes(AssertMethod::class) === []) { + return false; + } + + self::$cutTraceCache[$key] = true; + + return true; + } +} diff --git a/src/Teamcity/Teamcity/Formatter.php b/src/Output/Teamcity/Teamcity/Formatter.php similarity index 99% rename from src/Teamcity/Teamcity/Formatter.php rename to src/Output/Teamcity/Teamcity/Formatter.php index 4ddf7b3..3eac8b0 100644 --- a/src/Teamcity/Teamcity/Formatter.php +++ b/src/Output/Teamcity/Teamcity/Formatter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Teamcity\Teamcity; +namespace Testo\Output\Teamcity\Teamcity; /** * Formats TeamCity service messages. diff --git a/src/Teamcity/Teamcity/TeamcityLogger.php b/src/Output/Teamcity/Teamcity/TeamcityLogger.php similarity index 88% rename from src/Teamcity/Teamcity/TeamcityLogger.php rename to src/Output/Teamcity/Teamcity/TeamcityLogger.php index 9aa5e02..e88e0f5 100644 --- a/src/Teamcity/Teamcity/TeamcityLogger.php +++ b/src/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Teamcity\Teamcity; +namespace Testo\Output\Teamcity\Teamcity; use Testo\Assert\State\CompositeRecord; use Testo\Assert\State\Record; @@ -16,6 +16,7 @@ use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; +use Testo\Output\Rendering\StackTrace; /** * TeamCity logger for test reporting using DTO objects. @@ -31,14 +32,23 @@ final class TeamcityLogger /** * Formats a throwable into a detailed string with class, message, file, line, and stack trace. */ - public static function formatThrowable(\Throwable $throwable): string - { - $class = $throwable::class; - $file = $throwable->getFile(); - $line = $throwable->getLine(); - $trace = $throwable->getTraceAsString(); - - return "{$class}\nFile: {$file}:{$line}\n\nStack trace:\n{$trace}"; + public static function formatThrowable( + \Throwable $throwable, + ?\ReflectionFunctionAbstract $boundary = null, + ): string { + $parts = []; + $current = $throwable; + + do { + $class = $current::class; + $file = $current->getFile(); + $line = $current->getLine(); + $trace = self::formatTrace(StackTrace::cutStackTrace($current->getTrace(), $boundary, false)); + + $parts[] = "{$class}\nFile: {$file}:{$line}\n\nStack trace:\n{$trace}"; + } while ($current = $current->getPrevious()); + + return \implode("\n\nCaused by:\n", $parts); } /** @@ -196,7 +206,9 @@ public function testFailedFromResult(TestResult $result): void $message = $failure?->getMessage() ?? 'Test failed'; $assertionHistory = $this->formatAssertionHistory($result); - $details = $failure !== null ? self::formatThrowable($failure) : ''; + $details = $failure !== null + ? self::formatThrowable($failure, $result->info->testDefinition->reflection) + : ''; if ($assertionHistory !== '') { $details = $assertionHistory . $details; @@ -241,6 +253,26 @@ public function handleSingleTestResult(TestResult $result, ?int $duration = null }; } + /** + * @param list> $trace + */ + private static function formatTrace(array $trace): string + { + $lines = []; + + foreach ($trace as $i => $frame) { + $location = isset($frame['file']) + ? "{$frame['file']}({$frame['line']})" + : '[internal function]'; + $call = isset($frame['class']) + ? "{$frame['class']}{$frame['type']}{$frame['function']}()" + : "{$frame['function']}()"; + $lines[] = "#{$i} {$location}: {$call}"; + } + + return \implode("\n", $lines); + } + private static function key(string $name): string { return "\033[36;1m{$name}:\033[0m "; @@ -304,7 +336,9 @@ private function handleFailedTest(TestResult $result, ?int $duration, ?string $o $message = $failure?->getMessage() ?? 'Test failed'; $assertionHistory = $this->formatAssertionHistory($result); - $details = $failure !== null ? self::formatThrowable($failure) : ''; + $details = $failure !== null + ? self::formatThrowable($failure, $result->info->testDefinition->reflection) + : ''; if ($assertionHistory !== '') { $details = $assertionHistory . $details; @@ -330,7 +364,9 @@ private function handleAbortedTest(TestResult $result, ?int $duration, ?string $ $name = $overrideName ?? $result->info->name; $assertionHistory = $this->formatAssertionHistory($result); - $details = $result->failure !== null ? self::formatThrowable($result->failure) : ''; + $details = $result->failure !== null + ? self::formatThrowable($result->failure, $result->info->testDefinition->reflection) + : ''; if ($assertionHistory !== '') { $details = $assertionHistory . $details; diff --git a/src/Teamcity/TeamcityRenderer.php b/src/Output/Teamcity/TeamcityRenderer.php similarity index 98% rename from src/Teamcity/TeamcityRenderer.php rename to src/Output/Teamcity/TeamcityRenderer.php index ba7aa83..911202a 100644 --- a/src/Teamcity/TeamcityRenderer.php +++ b/src/Output/Teamcity/TeamcityRenderer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Teamcity; +namespace Testo\Output\Teamcity; use Testo\Application\Config\EventListenerCollector; use Testo\Application\Config\PluginConfigurator; @@ -18,7 +18,7 @@ use Testo\Event\TestCase\TestCaseStarting; use Testo\Event\TestSuite\TestSuiteFinished; use Testo\Event\TestSuite\TestSuiteStarting; -use Testo\Teamcity\Teamcity\TeamcityLogger; +use Testo\Output\Teamcity\Teamcity\TeamcityLogger; final class TeamcityRenderer implements PluginConfigurator { diff --git a/src/Bridge/Symfony/Renderer/Color.php b/src/Output/Terminal/Renderer/Color.php similarity index 91% rename from src/Bridge/Symfony/Renderer/Color.php rename to src/Output/Terminal/Renderer/Color.php index 8693fd3..06fd7f1 100644 --- a/src/Bridge/Symfony/Renderer/Color.php +++ b/src/Output/Terminal/Renderer/Color.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * ANSI color codes for terminal styling. diff --git a/src/Bridge/Symfony/Renderer/ColorMode.php b/src/Output/Terminal/Renderer/ColorMode.php similarity index 98% rename from src/Bridge/Symfony/Renderer/ColorMode.php rename to src/Output/Terminal/Renderer/ColorMode.php index 8126fc5..77b267a 100644 --- a/src/Bridge/Symfony/Renderer/ColorMode.php +++ b/src/Output/Terminal/Renderer/ColorMode.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Color mode for terminal output. diff --git a/src/Bridge/Symfony/Renderer/DotSymbol.php b/src/Output/Terminal/Renderer/DotSymbol.php similarity index 85% rename from src/Bridge/Symfony/Renderer/DotSymbol.php rename to src/Output/Terminal/Renderer/DotSymbol.php index a754278..c3afff1 100644 --- a/src/Bridge/Symfony/Renderer/DotSymbol.php +++ b/src/Output/Terminal/Renderer/DotSymbol.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Symbols for test status representation in dots mode. diff --git a/src/Bridge/Symfony/Renderer/FormattedItem.php b/src/Output/Terminal/Renderer/FormattedItem.php similarity index 95% rename from src/Bridge/Symfony/Renderer/FormattedItem.php rename to src/Output/Terminal/Renderer/FormattedItem.php index 3f7c60a..3cff252 100644 --- a/src/Bridge/Symfony/Renderer/FormattedItem.php +++ b/src/Output/Terminal/Renderer/FormattedItem.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; use Testo\Core\Value\Status; diff --git a/src/Bridge/Symfony/Renderer/Formatter.php b/src/Output/Terminal/Renderer/Formatter.php similarity index 98% rename from src/Bridge/Symfony/Renderer/Formatter.php rename to src/Output/Terminal/Renderer/Formatter.php index 128f717..2753936 100644 --- a/src/Bridge/Symfony/Renderer/Formatter.php +++ b/src/Output/Terminal/Renderer/Formatter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; use Testo\Assert\State\CompositeRecord; use Testo\Assert\State\Record; @@ -200,16 +200,18 @@ public static function failureDetail( string $message, string $details, ?int $duration, + ?string $location = null, ): string { $durationStr = $duration !== null ? Style::dim(" ({$duration}ms)") : ''; $header = "\n " . Style::bold("{$index}) {$testName}") . $durationStr . "\n"; + $locationBlock = $location !== null ? " " . Style::dim($location) . "\n" : ''; $messageBlock = "\n {$message}\n"; $detailsBlock = $details !== '' ? "\n" . self::indentText($details, ' ') . "\n" : ''; - return $header . $messageBlock . $detailsBlock; + return $header . $locationBlock . $messageBlock . $detailsBlock; } /** diff --git a/src/Bridge/Symfony/Renderer/Helper.php b/src/Output/Terminal/Renderer/Helper.php similarity index 61% rename from src/Bridge/Symfony/Renderer/Helper.php rename to src/Output/Terminal/Renderer/Helper.php index 61ddd31..3f48aae 100644 --- a/src/Bridge/Symfony/Renderer/Helper.php +++ b/src/Output/Terminal/Renderer/Helper.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; + +use Testo\Output\Rendering\StackTrace; /** * Helper utilities for text-based rendering (CLI, TeamCity, logs, etc.). @@ -58,9 +60,8 @@ private static function formatSingleThrowable( $result .= "\nFile: {$file}:{$line}"; - // Get stack trace and cut it at the test method - $trace = $throwable->getTrace(); - $cutTrace = self::cutTraceAtFunction($trace, $function); + // Get stack trace: cut internal frames (AssertMethod) and trim at test boundary + $cutTrace = StackTrace::cutStackTrace($throwable->getTrace(), $function); if ($cutTrace !== []) { $result .= "\n\nStack trace:\n" . self::formatTraceArray($cutTrace); @@ -69,52 +70,6 @@ private static function formatSingleThrowable( return $result; } - /** - * Cuts the stack trace array at the specified function call. - * - * @param array}> $trace - * @return array}> - */ - private static function cutTraceAtFunction( - array $trace, - \ReflectionFunctionAbstract $function, - ): array { - $targetClass = null; - $targetFunction = $function->getName(); - - if ($function instanceof \ReflectionMethod) { - $targetClass = $function->getDeclaringClass()->getName(); - } - - $cutIndex = null; - foreach ($trace as $index => $frame) { - $frameClass = $frame['class'] ?? null; - $frameFunction = $frame['function'] ?? null; - - // Match function/method call - if ($targetClass !== null) { - // Method call - if ($frameClass === $targetClass && $frameFunction === $targetFunction) { - $cutIndex = $index; - break; - } - } else { - // Function call - if ($frameFunction === $targetFunction && $frameClass === null) { - $cutIndex = $index; - break; - } - } - } - - // Return trace up to (and including) the matched frame - if ($cutIndex !== null) { - return \array_slice($trace, 0, $cutIndex); - } - - return $trace; - } - /** * Formats trace array into a readable string. * diff --git a/src/Bridge/Symfony/Renderer/OutputFormat.php b/src/Output/Terminal/Renderer/OutputFormat.php similarity index 94% rename from src/Bridge/Symfony/Renderer/OutputFormat.php rename to src/Output/Terminal/Renderer/OutputFormat.php index daa23bf..bcdb200 100644 --- a/src/Bridge/Symfony/Renderer/OutputFormat.php +++ b/src/Output/Terminal/Renderer/OutputFormat.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Output format for terminal renderer. diff --git a/src/Bridge/Symfony/Renderer/Style.php b/src/Output/Terminal/Renderer/Style.php similarity index 98% rename from src/Bridge/Symfony/Renderer/Style.php rename to src/Output/Terminal/Renderer/Style.php index 1d653a5..c3a5942 100644 --- a/src/Bridge/Symfony/Renderer/Style.php +++ b/src/Output/Terminal/Renderer/Style.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Terminal styling utilities with configurable color support. diff --git a/src/Bridge/Symfony/Renderer/Symbol.php b/src/Output/Terminal/Renderer/Symbol.php similarity index 88% rename from src/Bridge/Symfony/Renderer/Symbol.php rename to src/Output/Terminal/Renderer/Symbol.php index 90bf362..892bbf8 100644 --- a/src/Bridge/Symfony/Renderer/Symbol.php +++ b/src/Output/Terminal/Renderer/Symbol.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Symbols for test status representation in compact/verbose modes. diff --git a/src/Bridge/Symfony/Renderer/TerminalLogger.php b/src/Output/Terminal/Renderer/TerminalLogger.php similarity index 86% rename from src/Bridge/Symfony/Renderer/TerminalLogger.php rename to src/Output/Terminal/Renderer/TerminalLogger.php index 62420e3..fead405 100644 --- a/src/Bridge/Symfony/Renderer/TerminalLogger.php +++ b/src/Output/Terminal/Renderer/TerminalLogger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; use Testo\Assert\TestState; use Testo\Common\Environment; @@ -37,7 +37,7 @@ final class TerminalLogger /** @var int<0, max> */ private int $riskyTests = 0; - /** @var list|null}> */ + /** @var list|null, suiteName: string|null, datasetName: string|null}> */ private array $failures = []; /** @@ -52,6 +52,11 @@ final class TerminalLogger */ private ?string $currentTestName = null; + /** + * Current suite name for failure context. + */ + private ?string $currentSuiteName = null; + public function __construct( private readonly OutputFormat $format = OutputFormat::Compact, ) {} @@ -61,6 +66,7 @@ public function __construct( */ public function suiteStartedFromInfo(SuiteInfo $info): void { + $this->currentSuiteName = $info->name; echo Formatter::suiteHeader($info->name, $this->format); } @@ -211,7 +217,12 @@ private function handlePassedTest(TestResult $result, ?int $duration): void private function handleFailedTest(TestResult $result, ?int $duration): void { $this->failedTests++; - $this->failures[] = ['result' => $result, 'duration' => $duration]; + $this->failures[] = [ + 'result' => $result, + 'duration' => $duration, + 'suiteName' => $this->currentSuiteName, + 'datasetName' => $this->currentTestName, + ]; $item = new FormattedItem( name: $this->currentTestName ?? $result->info->name, @@ -341,18 +352,56 @@ function: $result->info->testDefinition->reflection, ) : ''; + $testName = self::buildFullTestName( + $result->info, + $failure['suiteName'], + $failure['datasetName'], + ); + + $reflection = $result->info->testDefinition->reflection; + $file = $reflection->getFileName(); + $line = $reflection->getStartLine(); + $location = $file !== false && $line !== false + ? "{$file}:{$line}" + : null; + echo Formatter::failureDetail( $index, - $result->info->name, + $testName, $message, $details, $duration, + $location, ); $index++; } } + /** + * Builds a fully qualified test name with suite, case, method, and dataset. + * + * Format: Suite / CaseName :: methodName > DatasetName + * + * @return non-empty-string + */ + private static function buildFullTestName( + TestInfo $info, + ?string $suiteName, + ?string $datasetName, + ): string { + $parts = []; + + $suiteName !== null and $parts[] = $suiteName; + $parts[] = $info->caseInfo->name; + + $name = \implode(' / ', $parts) . ' :: ' . $info->name; + + $datasetName !== null and $name .= ' > ' . $datasetName; + + return $name; + } + /** * Prints final statistics. */ diff --git a/src/Bridge/Symfony/TerminalRenderer.php b/src/Output/Terminal/TerminalRenderer.php similarity index 97% rename from src/Bridge/Symfony/TerminalRenderer.php rename to src/Output/Terminal/TerminalRenderer.php index 6c8fa9f..13c77e1 100644 --- a/src/Bridge/Symfony/TerminalRenderer.php +++ b/src/Output/Terminal/TerminalRenderer.php @@ -2,13 +2,10 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony; +namespace Testo\Output\Terminal; use Testo\Application\Config\EventListenerCollector; use Testo\Application\Config\PluginConfigurator; -use Testo\Bridge\Symfony\Renderer\ColorMode; -use Testo\Bridge\Symfony\Renderer\Style; -use Testo\Bridge\Symfony\Renderer\TerminalLogger; use Testo\Common\Container; use Testo\Core\Context\TestInfo; use Testo\Event\Framework\SessionFinished; @@ -22,6 +19,9 @@ use Testo\Event\TestCase\TestCaseStarting; use Testo\Event\TestSuite\TestSuiteFinished; use Testo\Event\TestSuite\TestSuiteStarting; +use Testo\Output\Terminal\Renderer\ColorMode; +use Testo\Output\Terminal\Renderer\Style; +use Testo\Output\Terminal\Renderer\TerminalLogger; /** * Terminal interceptor for rendering test results with configurable output. diff --git a/testo.php b/testo.php index 4067cd7..e517ca3 100644 --- a/testo.php +++ b/testo.php @@ -21,5 +21,6 @@ require 'tests/Lifecycle/suites.php', require 'tests/Data/suites.php', require 'tests/Bench/suites.php', + require 'tests/Output/suites.php', ), ); diff --git a/tests/Assert/Feature/CommonTest.php b/tests/Assert/Feature/CommonTest.php index 6a44e4e..d159ca7 100644 --- a/tests/Assert/Feature/CommonTest.php +++ b/tests/Assert/Feature/CommonTest.php @@ -4,8 +4,8 @@ namespace Tests\Assert\Feature; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Core\Value\Status; use Testo\Testing\Attribute\TestingSuite; use Testo\Testing\Traits\TestRunner; diff --git a/tests/Assert/Self/AssertArray.php b/tests/Assert/Self/AssertArray.php index 5bf839d..82611a7 100644 --- a/tests/Assert/Self/AssertArray.php +++ b/tests/Assert/Self/AssertArray.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Self/AssertBlank.php b/tests/Assert/Self/AssertBlank.php index a1c2ac7..d58567a 100644 --- a/tests/Assert/Self/AssertBlank.php +++ b/tests/Assert/Self/AssertBlank.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\State\AssertException; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Self/AssertEquals.php b/tests/Assert/Self/AssertEquals.php index 2a29014..2d87ba5 100644 --- a/tests/Assert/Self/AssertEquals.php +++ b/tests/Assert/Self/AssertEquals.php @@ -4,8 +4,8 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; /** * Assertion examples. diff --git a/tests/Assert/Self/AssertIterable.php b/tests/Assert/Self/AssertIterable.php index 0713b9f..ade3288 100644 --- a/tests/Assert/Self/AssertIterable.php +++ b/tests/Assert/Self/AssertIterable.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Self/AssertJson.php b/tests/Assert/Self/AssertJson.php index 3a92b02..577473a 100644 --- a/tests/Assert/Self/AssertJson.php +++ b/tests/Assert/Self/AssertJson.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\Api\Json\JsonAbstract; +use Testo\Attribute\Test; use Testo\Data\DataProvider; /** diff --git a/tests/Assert/Self/AssertNotEquals.php b/tests/Assert/Self/AssertNotEquals.php index 745b11d..e774fc1 100644 --- a/tests/Assert/Self/AssertNotEquals.php +++ b/tests/Assert/Self/AssertNotEquals.php @@ -4,8 +4,8 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; /** * Assertion examples. diff --git a/tests/Assert/Self/AssertNumeric.php b/tests/Assert/Self/AssertNumeric.php index 03c2e3a..42ffb14 100644 --- a/tests/Assert/Self/AssertNumeric.php +++ b/tests/Assert/Self/AssertNumeric.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Self/AssertObject.php b/tests/Assert/Self/AssertObject.php index 80f0a43..f906b5d 100644 --- a/tests/Assert/Self/AssertObject.php +++ b/tests/Assert/Self/AssertObject.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Self/AssertString.php b/tests/Assert/Self/AssertString.php index 7ef2b73..176ae27 100644 --- a/tests/Assert/Self/AssertString.php +++ b/tests/Assert/Self/AssertString.php @@ -4,9 +4,10 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\State\AssertException; +use Testo\Assert\State\Assertion\AssertionException; +use Testo\Attribute\Test; use Testo\Expect; /** @@ -41,14 +42,14 @@ public function chainLogShowcase(): void #[Test] public function checkWrongDataType(): void { - Expect::exception(Assert\State\Assertion\AssertionException::class); + Expect::exception(AssertionException::class); Assert::string([666]); } #[Test] public function checkStringDoesNotContain(): void { - Expect::exception(AssertException::class); + Expect::exception(AssertionException::class); Assert::string("What makes PHP the best programming language?")->contains("PHP is dying"); } } diff --git a/tests/Assert/Self/ExpectExceptionTest.php b/tests/Assert/Self/ExpectExceptionTest.php index 2115520..5bf6527 100644 --- a/tests/Assert/Self/ExpectExceptionTest.php +++ b/tests/Assert/Self/ExpectExceptionTest.php @@ -4,8 +4,8 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert\Api\ExpectedException; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Self/ExpectLeaks.php b/tests/Assert/Self/ExpectLeaks.php index 38a835b..fa554ca 100644 --- a/tests/Assert/Self/ExpectLeaks.php +++ b/tests/Assert/Self/ExpectLeaks.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert\ExpectException; use Testo\Assert\State\Expectation\ExpectLeaksFailure; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Self/ExpectNotLeaks.php b/tests/Assert/Self/ExpectNotLeaks.php index 4f93d4e..58f6563 100644 --- a/tests/Assert/Self/ExpectNotLeaks.php +++ b/tests/Assert/Self/ExpectNotLeaks.php @@ -4,9 +4,9 @@ namespace Tests\Assert\Self; -use Testo\Application\Attribute\Test; use Testo\Assert\ExpectException; use Testo\Assert\State\Expectation\ExpectNotLeaksFailure; +use Testo\Attribute\Test; use Testo\Expect; /** diff --git a/tests/Assert/Stub/Common.php b/tests/Assert/Stub/Common.php index b845cba..c4dc040 100644 --- a/tests/Assert/Stub/Common.php +++ b/tests/Assert/Stub/Common.php @@ -4,7 +4,7 @@ namespace Tests\Assert\Stub; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; final class Common { diff --git a/tests/Data/Self/DataCross.php b/tests/Data/Self/DataCross.php index a04efc7..77b21f5 100644 --- a/tests/Data/Self/DataCross.php +++ b/tests/Data/Self/DataCross.php @@ -4,8 +4,8 @@ namespace Tests\Data\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Data\DataProvider; use Testo\Data\DataSet; diff --git a/tests/Data/Self/DataProviders.php b/tests/Data/Self/DataProviders.php index a15629d..2ce2509 100644 --- a/tests/Data/Self/DataProviders.php +++ b/tests/Data/Self/DataProviders.php @@ -4,8 +4,8 @@ namespace Tests\Data\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Data\DataProvider; use Testo\Data\DataSet; diff --git a/tests/Data/Self/DataUnion.php b/tests/Data/Self/DataUnion.php index 595c522..e53a86b 100644 --- a/tests/Data/Self/DataUnion.php +++ b/tests/Data/Self/DataUnion.php @@ -4,8 +4,8 @@ namespace Tests\Data\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Data\DataProvider; use Testo\Data\DataSet; diff --git a/tests/Data/Self/DataZip.php b/tests/Data/Self/DataZip.php index c11abb2..4719d19 100644 --- a/tests/Data/Self/DataZip.php +++ b/tests/Data/Self/DataZip.php @@ -4,8 +4,8 @@ namespace Tests\Data\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Data\DataProvider; use Testo\Data\DataSet; diff --git a/tests/Fixture/Interceptor/TestClassWithClassLevelAttribute.php b/tests/Fixture/Interceptor/TestClassWithClassLevelAttribute.php index 245ec24..12094b0 100644 --- a/tests/Fixture/Interceptor/TestClassWithClassLevelAttribute.php +++ b/tests/Fixture/Interceptor/TestClassWithClassLevelAttribute.php @@ -4,7 +4,7 @@ namespace Tests\Fixture\Interceptor; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; #[Test] final class TestClassWithClassLevelAttribute diff --git a/tests/Fixture/Interceptor/TestClassWithMethodLevelAttributes.php b/tests/Fixture/Interceptor/TestClassWithMethodLevelAttributes.php index f8c3694..e8d4e4c 100644 --- a/tests/Fixture/Interceptor/TestClassWithMethodLevelAttributes.php +++ b/tests/Fixture/Interceptor/TestClassWithMethodLevelAttributes.php @@ -4,7 +4,7 @@ namespace Tests\Fixture\Interceptor; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; final class TestClassWithMethodLevelAttributes { diff --git a/tests/Fixture/TestAttributes.php b/tests/Fixture/TestAttributes.php index ae4dae6..700506e 100644 --- a/tests/Fixture/TestAttributes.php +++ b/tests/Fixture/TestAttributes.php @@ -4,7 +4,7 @@ namespace Tests\Fixture; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; final class TestAttributes { diff --git a/tests/Fixture/TestInterceptors.php b/tests/Fixture/TestInterceptors.php index 949c064..22a87c0 100644 --- a/tests/Fixture/TestInterceptors.php +++ b/tests/Fixture/TestInterceptors.php @@ -4,7 +4,7 @@ namespace Tests\Fixture; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; use Testo\Retry\RetryPolicy; final class TestInterceptors diff --git a/tests/Fixture/functions.php b/tests/Fixture/functions.php index 01310ed..9db0673 100644 --- a/tests/Fixture/functions.php +++ b/tests/Fixture/functions.php @@ -4,7 +4,7 @@ namespace Tests\Fixture; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; use Testo\Retry\RetryPolicy; #[Test] diff --git a/tests/Lifecycle/Self/BeforeAfterAllTest.php b/tests/Lifecycle/Self/BeforeAfterAllTest.php index 7b062bf..a02963c 100644 --- a/tests/Lifecycle/Self/BeforeAfterAllTest.php +++ b/tests/Lifecycle/Self/BeforeAfterAllTest.php @@ -4,8 +4,8 @@ namespace Tests\Lifecycle\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Lifecycle\AfterAll; use Testo\Lifecycle\BeforeAll; diff --git a/tests/Lifecycle/Self/BeforeAfterTest.php b/tests/Lifecycle/Self/BeforeAfterTest.php index 6d8b769..8bc87cb 100644 --- a/tests/Lifecycle/Self/BeforeAfterTest.php +++ b/tests/Lifecycle/Self/BeforeAfterTest.php @@ -4,8 +4,8 @@ namespace Tests\Lifecycle\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Lifecycle\AfterEach; use Testo\Lifecycle\BeforeEach; diff --git a/tests/Lifecycle/Self/MultipleAttributesTest.php b/tests/Lifecycle/Self/MultipleAttributesTest.php index bd6a8e8..4c2d767 100644 --- a/tests/Lifecycle/Self/MultipleAttributesTest.php +++ b/tests/Lifecycle/Self/MultipleAttributesTest.php @@ -4,8 +4,8 @@ namespace Tests\Lifecycle\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Lifecycle\AfterEach; use Testo\Lifecycle\BeforeEach; diff --git a/tests/Lifecycle/Self/PriorityTest.php b/tests/Lifecycle/Self/PriorityTest.php index 1d89fe3..1e583b9 100644 --- a/tests/Lifecycle/Self/PriorityTest.php +++ b/tests/Lifecycle/Self/PriorityTest.php @@ -4,8 +4,8 @@ namespace Tests\Lifecycle\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Lifecycle\BeforeEach; /** diff --git a/tests/Lifecycle/Self/StaticMethodTest.php b/tests/Lifecycle/Self/StaticMethodTest.php index fdf47a8..be7534c 100644 --- a/tests/Lifecycle/Self/StaticMethodTest.php +++ b/tests/Lifecycle/Self/StaticMethodTest.php @@ -4,8 +4,8 @@ namespace Tests\Lifecycle\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Lifecycle\BeforeEach; /** diff --git a/tests/Output/Stub/AssertMethodStub.php b/tests/Output/Stub/AssertMethodStub.php new file mode 100644 index 0000000..bde287c --- /dev/null +++ b/tests/Output/Stub/AssertMethodStub.php @@ -0,0 +1,16 @@ +> + */ + public static function captureTrace(): array + { + return \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); + } +} diff --git a/tests/Output/Unit/Rendering/StackTraceTest.php b/tests/Output/Unit/Rendering/StackTraceTest.php new file mode 100644 index 0000000..85fc25a --- /dev/null +++ b/tests/Output/Unit/Rendering/StackTraceTest.php @@ -0,0 +1,194 @@ +getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: AssertMethodStub::run is the first frame, ThrowingStub::fail is removed + Assert::true(\count($result) < \count($trace)); + Assert::same(AssertMethodStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + $hasThrowingFrames = \array_filter( + $result, + static fn(array $f): bool => ($f['class'] ?? null) === ThrowingStub::class, + ); + Assert::same([], $hasThrowingFrames); + } + + public function testCutsFramesBelowAssertMethodFromDebugBacktrace(): void + { + // Arrange + $trace = AssertMethodStub::run(ThrowingStub::captureTrace(...)); + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: AssertMethodStub::run is the first frame, ThrowingStub::captureTrace is removed + Assert::true(\count($result) < \count($trace)); + Assert::same(AssertMethodStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + $hasThrowingFrames = \array_filter( + $result, + static fn(array $f): bool => ($f['class'] ?? null) === ThrowingStub::class, + ); + Assert::same([], $hasThrowingFrames); + } + + public function testDoesNotAssertMethodWithoutAttribute(): void + { + // Arrange + try { + MiddlewareStub::run(ThrowingStub::fail(...)); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert + Assert::same($trace, $result); + } + + public function testCutsAtOutermostAssertMethodWithMultipleAttributes(): void + { + // Arrange: outer AssertMethod -> closure -> inner AssertMethod -> fail + try { + AssertMethodStub::run(static fn() => AssertMethodStub::run(ThrowingStub::fail(...))); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: outer AssertMethod is the first frame, inner AssertMethod and deeper are removed + Assert::true(\count($result) < \count($trace)); + Assert::same(AssertMethodStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + $cutFrames = \array_filter( + $result, + static fn(array $f): bool => ($f['class'] ?? null) === AssertMethodStub::class, + ); + Assert::same(1, \count($cutFrames)); + } + + public function testDoesNotCutBeyondDepthLimit(): void + { + // Arrange: AssertMethod is beyond SEARCH_DEPTH from the start + try { + AssertMethodStub::run(static fn() => MiddlewareStub::runDeep(ThrowingStub::fail(...))); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: AssertMethod too far from the error, nothing is cut + Assert::same($trace, $result); + } + + public function testBoundaryStopsAssertMethodSearch(): void + { + // Arrange: AssertMethod -> middleware -> boundary (no AssertMethod between error and boundary) + try { + AssertMethodStub::run(static fn() => MiddlewareStub::run(ThrowingStub::fail(...))); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act: boundary stops search before AssertMethod is reached + $result = StackTrace::cutStackTrace($trace, $boundary, false); + + // Assert: trace unchanged — AssertMethod is after boundary, not found + Assert::same($trace, $result); + } + + public function testBoundaryWithAssertMethodBeforeBoundary(): void + { + // Arrange: error -> AssertMethod -> middleware -> boundary + try { + MiddlewareStub::run( + static fn() => AssertMethodStub::run(ThrowingStub::fail(...)), + ); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act + $result = StackTrace::cutStackTrace($trace, $boundary); + + // Assert: AssertMethod found before boundary, internal frames cut + Assert::same(AssertMethodStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + } + + public function testBoundaryBypassesDepthLimit(): void + { + // Arrange: AssertMethod is beyond SEARCH_DEPTH but before boundary + try { + MiddlewareStub::run(static fn() => AssertMethodStub::run( + static fn() => MiddlewareStub::runDeep(ThrowingStub::fail(...)), + )); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act + $result = StackTrace::cutStackTrace($trace, $boundary); + + // Assert: AssertMethod found despite being beyond SEARCH_DEPTH + Assert::same(AssertMethodStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + } + + public function testTrimAtBoundaryCutsFramesAfterBoundary(): void + { + // Arrange + try { + MiddlewareStub::run(ThrowingStub::fail(...)); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act + $result = StackTrace::cutStackTrace($trace, $boundary, trimAtBoundary: true); + + // Assert: trace ends at the boundary method + Assert::true(\count($result) < \count($trace)); + $lastFrame = $result[\count($result) - 1]; + Assert::same(MiddlewareStub::class, $lastFrame['class']); + Assert::same('run', $lastFrame['function']); + } +} diff --git a/tests/Output/suites.php b/tests/Output/suites.php new file mode 100644 index 0000000..5bfbd9f --- /dev/null +++ b/tests/Output/suites.php @@ -0,0 +1,24 @@ +getFileName()), + ], + ), + ), + new SuiteConfig( + name: 'Output/Unit', + location: new FinderConfig( + include: [__DIR__ . '/Unit'], + ), + ), +]; diff --git a/tests/Testo/Interceptor/TestoAttributesLocatorInterceptorTest.php b/tests/Testo/Interceptor/TestoAttributesLocatorInterceptorTest.php index 7187791..2f0222d 100644 --- a/tests/Testo/Interceptor/TestoAttributesLocatorInterceptorTest.php +++ b/tests/Testo/Interceptor/TestoAttributesLocatorInterceptorTest.php @@ -4,9 +4,9 @@ namespace Tests\Testo\Interceptor; -use Testo\Application\Attribute\Test; use Testo\Application\Middleware\Locator\TestoAttributesLocatorInterceptor; use Testo\Assert; +use Testo\Attribute\Test; use Testo\Tokenizer\Reflection\FileDefinitions; use Testo\Tokenizer\Reflection\TokenizedFile; use Tests\Fixture\Interceptor\TestClassWithClassLevelAttribute; diff --git a/tests/Testo/Self/AsserTest.php b/tests/Testo/Self/AsserTest.php index 0a8c06f..7712db0 100644 --- a/tests/Testo/Self/AsserTest.php +++ b/tests/Testo/Self/AsserTest.php @@ -4,10 +4,10 @@ namespace Tests\Testo\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\ExpectException; use Testo\Assert\State\AssertException; +use Testo\Attribute\Test; use Testo\Data\DataProvider; use Testo\Data\DataSet; use Testo\Expect; diff --git a/tests/Testo/Self/TestCase/CaseInstantiatorCache.php b/tests/Testo/Self/TestCase/CaseInstantiatorCache.php index 5b30967..efefdf2 100644 --- a/tests/Testo/Self/TestCase/CaseInstantiatorCache.php +++ b/tests/Testo/Self/TestCase/CaseInstantiatorCache.php @@ -4,8 +4,8 @@ namespace Tests\Testo\Self\TestCase; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; /** * By default, the same test case instance is used for the each non-static test method. diff --git a/tests/Testo/Self/TestCase/CaseInstantiatorStatic.php b/tests/Testo/Self/TestCase/CaseInstantiatorStatic.php index c227b79..fb98510 100644 --- a/tests/Testo/Self/TestCase/CaseInstantiatorStatic.php +++ b/tests/Testo/Self/TestCase/CaseInstantiatorStatic.php @@ -4,8 +4,8 @@ namespace Tests\Testo\Self\TestCase; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; /** * If there are static methods only in the test case, Testo must not try to instantiate the class. diff --git a/tests/Testo/Self/functions.php b/tests/Testo/Self/functions.php index c1f21ff..f24c876 100644 --- a/tests/Testo/Self/functions.php +++ b/tests/Testo/Self/functions.php @@ -4,8 +4,8 @@ namespace Tests\Testo\Self; -use Testo\Application\Attribute\Test; use Testo\Assert; +use Testo\Attribute\Test; #[Test] function simpleFunctionAssertions(): void