Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/Assert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Testo;

use Testo\Assert\Interceptor\ExpectExceptionInterceptor;
use Testo\Assert\State\AssertException;
use Testo\Assert\StaticState;
use Testo\Assert\Support;

/**
* Assertion utilities.
*/
final class Assert
{
/**
* Asserts that two values are the same (identical).
*
* @param mixed $expected The expected value.
* @param mixed $actual The actual value to compare against the expected value.
* @param string $message Short description about what exactly is being asserted.
* @throws AssertException when the assertion fails.
*/
public static function same(mixed $expected, mixed $actual, string $message = ''): void
{
$actual === $expected
? StaticState::log('Assert same: `' . Support::stringify($expected) . '`', $message)
: StaticState::fail(AssertException::same($expected, $actual, $message));
}

/**
* Asserts that the given value is null.
*
* @param mixed $actual The actual value to check for null.
* @param string $message Short description about what exactly is being asserted.
* @throws AssertException when the assertion fails.
*/
public static function null(
mixed $actual,
string $message = '',
): void {
$actual === null
? StaticState::log('Assert null', $message)
: StaticState::fail(AssertException::same(null, $actual, $message));
}

/**
* Expects that the test will throw an exception of the given class.
*
* @param class-string $class The expected exception class or interface.
*
* @note Requires {@see ExpectExceptionInterceptor} to be registered.
*/
public static function exception(
string $class,
): void {
StaticState::expectException($class);
}
}
42 changes: 0 additions & 42 deletions src/Assert/Assert.php

This file was deleted.

16 changes: 0 additions & 16 deletions src/Assert/AssertCollector.php

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

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;

/**
* 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.
Expand All @@ -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
Expand All @@ -38,20 +40,20 @@ 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);
}

/** @var TestResult $result */
$result = $fiber->getReturn();
}

return $result->withAttribute(AssertCollector::class, $collector);
return $result->withAttribute(TestState::class, $state);
} finally {
StaticState::swap($previous);
}
Expand Down
34 changes: 34 additions & 0 deletions src/Assert/Interceptor/ExpectExceptionConfigurator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Testo\Assert\Interceptor;

use Testo\Assert\State\ExpectedException;
use Testo\Assert\StaticState;
use Testo\Attribute\ExpectException;
use Testo\Interceptor\TestCallInterceptor;
use Testo\Test\Dto\TestInfo;
use Testo\Test\Dto\TestResult;

final class ExpectExceptionConfigurator implements TestCallInterceptor
{
public function __construct(
private readonly ExpectException $options,
) {}

#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
$context = StaticState::current() ?? throw new \RuntimeException(\sprintf(
'Interceptor %s must be defined in the pipeline',
AssertCollectorInterceptor::class,
));

$context->expectException = new ExpectedException(
class: $this->options->class,
);

return $next($info);
}
}
68 changes: 68 additions & 0 deletions src/Assert/Interceptor/ExpectExceptionInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Testo\Assert\Interceptor;

use Testo\Assert\State;
use Testo\Assert\State\AssertException;
use Testo\Assert\State\Record;
use Testo\Assert\State\Success;
use Testo\Assert\StaticState;
use Testo\Interceptor\TestCallInterceptor;
use Testo\Test\Dto\Status;
use Testo\Test\Dto\TestInfo;
use Testo\Test\Dto\TestResult;

/**
* Interceptor to handle expected exceptions.
*
* @note Must be placed right before the test execution.
*/
final class ExpectExceptionInterceptor implements TestCallInterceptor
{
/**
* @throws AssertException When the expected exception is not thrown.
*/
#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
/** @var TestResult $result */
$result = $next($info);
$context = StaticState::current();
$expectation = $context?->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 . '`).',
);
}
}
17 changes: 0 additions & 17 deletions src/Assert/Record.php

This file was deleted.

80 changes: 80 additions & 0 deletions src/Assert/State/AssertException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Testo\Assert\State;

use Testo\Assert\Support;

/**
* Assertion exception.
*/
final class AssertException extends \Exception implements Record
{
/**
* @param non-empty-string $assertion The assertion result (e.g., "Expected exactly 42, got 43").
* @param string $context Optional user-provided context describing what is being asserted.
* @param string $details The detailed assertion failure information (diff).
*/
final protected function __construct(
public readonly string $assertion,
public readonly string $context,
public readonly string $details,
) {
parent::__construct();
}

/**
* Failed `same` assertion factory.
* @param mixed $expected The expected value.
* @param mixed $actual The actual value to compare against the expected value.
* @param non-empty-string $message Short description about what exactly is being asserted.
* @param non-empty-string $pattern The message pattern.
*/
public static function same(
mixed $expected,
mixed $actual,
string $message,
string $pattern = 'Expected `%1$s`, got `%2$s.`',
): self {
# todo
$diff = '';

$msg = \sprintf(
$pattern,
Support::stringify($actual),
Support::stringify($expected),
);
return new self(
assertion: $msg,
context: $message,
details: $diff,
);
}

/**
* Failed `expect exception` assertion factory.
*
* @param class-string<\Throwable> $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;
}
}
18 changes: 18 additions & 0 deletions src/Assert/State/ExpectedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Testo\Assert\State;

/**
* Expected exception declaration.
*/
final class ExpectedException
{
/**
* @param class-string $class Expected exception class.
*/
public function __construct(
public readonly string $class,
) {}
}
Loading