Skip to content
Draft
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
1 change: 1 addition & 0 deletions bridge/rector/FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto
| **Skip at runtime** (`throw SkipTest` ↔ `markTestSkipped`) | ✅ | ✅ *`MarkTestSkippedToTestoRector` takes what the attribute form below leaves: a guarded call, a call deeper in the body, a non-literal message* | ➖ *every Pest `->skip()` is unconditional — see the row below* |
| **Skip declaratively** (`#[Skip]`) | ✅ *`SkipAttributeToPhpUnitRector`: PHPUnit has no skip attribute, so `#[\Testo\Skip('reason')]` becomes a leading `$this->markTestSkipped('reason')` and the attribute is dropped; a class-level attribute is fanned out onto each test method (a method's own reason wins). **Residual:** the skip turns into a runtime one — PHPUnit runs `setUp()` and the data provider before aborting, where Testo keeps the test out of the pipeline entirely* | ✅ *`MarkTestSkippedToSkipAttributeRector`: an unconditional `markTestSkipped('literal')` opening a test method → `#[\Testo\Skip('literal')]`, dropping the call. A guarded/non-literal one stays a throw (row above); a call opening `setUp()` stays a throw too, rather than becoming a class-level `#[Skip]`* | ✅ *`->skip()` / `->skip('reason')` → `#[\Testo\Skip('reason')]`; a conditional `->skip(fn () => …)` / `->skip($bool, 'reason')` leaves the whole statement untouched* |
| **Incomplete** (`markTestIncomplete`) | ➖ | 🟡 *`MarkTestIncompleteRector`: Testo has no Incomplete status, so it maps to the nearest one — a `throw new \Testo\Core\Exception\SkipTest(...)` (Skipped). Lossy: the Incomplete-vs-Skipped nuance survives only as an `Incomplete: ` prefix on the reason (literal folds to `'Incomplete: <msg>'`, non-literal to `'Incomplete: ' . $expr`, bare call to `'Incomplete'`)* | ➖ |
| **Custom assertion records** (`Assert::success()` / `Assert::failure()`) | ⛔ *no faithful PHPUnit/Pest equivalent; unregistered `CustomAssertionRector` stub* | ➖ | ➖ |
| **Cancel** (`CancelTest`) | ⛔ *no PHPUnit equivalent* | ➖ | ➖ |
| **Coverage attribute** (`#[Covers]` ↔ `#[CoversClass]`) | ✅ *`CoversToCoversClassRector` maps by target kind: a class/enum → `#[CoversClass]`, a trait → `#[CoversTrait]`, an interface is dropped (PHPUnit rejects a non-class coverage target); kind resolved by autoload, an unresolvable name defaults to `#[CoversClass]`* | ✅ | ✅ *`->covers(X::class)` → `#[\Testo\Codecov\Covers(X::class)]`* |
| **Lifecycle hooks** | ✅ *attribute → attribute; plus `ConstructorDestructorToLifecycleRector` turns a parameterless `__construct()`/`__destruct()` into a renamed `#[Before]`/`#[After]` method (coexists with any existing `setUp`)* | ✅ *(`setUp/tearDown` → attributes)* | 🟡 *`beforeEach/afterEach/beforeAll/afterAll` → functions carrying `Testo\Lifecycle\{BeforeTest,AfterTest,BeforeClass,AfterClass}`; relies on Testo's function-level lifecycle, and `$this`-shared state is left manual* |
Expand Down
37 changes: 37 additions & 0 deletions bridge/rector/src/TestoToPhpunit/CustomAssertionRector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\Rector\TestoToPhpunit;

use PhpParser\Node;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

/**
* STUB — not implemented, not registered in the set.
*
* Testo custom assertion records have no faithful PHPUnit/Pest equivalent.
*/
final class CustomAssertionRector extends AbstractRector
{
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'STUB: Testo custom assertions have no faithful PHPUnit equivalent (not implemented)',
[],
);
}

#[\Override]
public function getNodeTypes(): array
{
return [];
}

#[\Override]
public function refactor(Node $node): ?Node
{
return null;
}
}
3 changes: 3 additions & 0 deletions bridge/rector/src/TestoToPhpunit/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ directory but are **not** registered in `config/testo-to-phpunit.php`.
memory-leak assertion or post-test object-liveness hook.
- **`CancelTestRector`** — `CancelTest` is an external interruption signal with no
PHPUnit equivalent (not the same as skip/incomplete).
- **`CustomAssertionRector`** — `Assert::success()`/`Assert::failure()` record Testo-specific
assertion history; PHPUnit/Pest have no faithful custom-record conversion. The stub is
intentionally unregistered rather than emitting a lossy rewrite.

## Implemented since the first cut

Expand Down
28 changes: 28 additions & 0 deletions plugin/assert/Assert.php
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,34 @@ public static function callable(mixed $actual): CallableType
return AssertCallable::validateAndCreate($actual);
}

/**
* Records a successful custom assertion.
*
* @param mixed $actual The actual value that was asserted.
* @param non-empty-string $assertion The assertion description.
* @param string $context Optional context describing the assertion.
* @throws \InvalidArgumentException when the assertion description is empty.
*/
public static function success(mixed $actual, string $assertion, string $context = ''): void
{
$assertion === '' and throw new \InvalidArgumentException('Assertion description must not be empty.');
StaticState::success($actual, $assertion, $context);
}

/**
* Records and throws a canonical custom assertion failure.
*
* @param AssertionException $failure The supported assertion failure to record and throw.
* @throws AssertionException
* @throws \InvalidArgumentException when the assertion description is empty.
*/
#[AssertMethod]
public static function failure(AssertionException $failure): never
{
$failure->getAssertion() === '' and throw new \InvalidArgumentException('Assertion description must not be empty.');
StaticState::fail($failure);
}

/**
* Fails the test.
*
Expand Down
20 changes: 20 additions & 0 deletions plugin/assert/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@

Provides Testo's assertion machinery: a fluent assertion facade, expectation lifecycle, and exception-matching helpers. Tests can describe what they expect to happen and the plugin reports the comparison result through Testo's regular pipeline.

### Custom assertions

Domain-specific checks can use the public `Testo\Assert` facade without importing internal state:

```php
use Testo\Assert;
use Testo\Assert\State\Assertion\AssertionException;

Assert::success($actual, 'satisfies the domain rule', 'optional context');
Assert::failure(new AssertionException(
value: (string) $actual,
assertion: 'satisfies the domain rule',
context: '',
reason: 'the rule was violated',
details: '',
));
```

`success()` requires a non-empty description. `failure()` accepts only the public `AssertionException` contract (including `ComparisonFailure`), records it, and throws without returning; arbitrary throwables are unsupported. Empty descriptions are rejected before history changes. A caught failure remains in history and may leave the test passed, while matching `Expect::exception()` uses normal expectation handling. `Assert::fail()` retains its existing semantics.

## Install

```bash
Expand Down
5 changes: 4 additions & 1 deletion plugin/assert/src/State/Assertion/AssertionException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
use Testo\Assert\State\Assertion;

/**
* Assertion exception.
* Canonical assertion failure record for custom assertions.
*
* @api
*/
class AssertionException extends \LogicException implements Assertion
{
Expand All @@ -25,6 +27,7 @@ public function __construct(
private readonly string $reason,
private readonly string $details,
) {
$assertion === '' and throw new \InvalidArgumentException('Assertion description must not be empty.');
$message = "Failed assertion that `{$value}` {$assertion}: {$reason}.";
$context === '' or $message .= "\nMeaning: {$context}";
$details === '' or $message .= "\nDetails:\n{$details}";
Expand Down
2 changes: 2 additions & 0 deletions plugin/assert/src/State/Assertion/ComparisonFailure.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
/**
* Assertion exception for value comparisons.
*
* @api
*
* Carries the raw `expected` and `actual` values alongside the textual
* assertion record fields so output adapters can render them as needed
* (e.g. terminal diff, TeamCity `comparisonFailure` message).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ public function fiberRoundRobinTestsKeepSeparateHistories(): void
);
}

public function fiberCustomAssertionsKeepSeparateHistories(): void
{
self::assertHistoryCount(
TestRunner::runTest([RoundRobinAssertScenarios::class, 'recordsCustomAssertionsAcrossSuspends']),
3,
);
}

public function fiberRandomScheduleKeepsSeparateHistories(): void
{
self::assertHistoryCount(
Expand Down
159 changes: 159 additions & 0 deletions plugin/assert/tests/Feature/CustomAssertionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

declare(strict_types=1);

namespace Tests\Assert\Feature;

use Testo\Assert;
use Testo\Assert\State\Assertion\AssertionException;
use Testo\Assert\State\Assertion\ComparisonFailure;
use Testo\Assert\State\Expectation;
use Testo\Assert\State\Assertion\AssertionSuccess;
use Testo\Assert\TestState;
use Testo\Core\Value\Status;
use Testo\Test;
use Testo\Testing\Attribute\TestingSuite;
use Testo\Testing\Helper\TestRunner;
use Tests\Assert\Stub\CustomAssertions;

#[Test]
#[TestingSuite(path: __DIR__ . '/../Stub')]
final class CustomAssertionsTest
{
public function successRecordsThePublicCustomAssertion(): void
{
$result = TestRunner::runTest([CustomAssertions::class, 'success']);
Assert::same($result->status, Status::Passed);
$state = $result->getAttribute(TestState::class);
Assert::instanceOf($state, TestState::class);
Assert::count($state->history, 1);
Assert::instanceOf($state->history[0], AssertionSuccess::class);
Assert::same($state->history[0]->getValue(), 'array(1)');
Assert::same($state->history[0]->getAssertion(), 'is valid');
Assert::same($state->history[0]->getContext(), 'domain check');
}

public function mixedBuiltInAndCustomHistoryPreservesOrder(): void
{
$result = TestRunner::runTest([CustomAssertions::class, 'mixedHistory']);
Assert::same($result->status, Status::Passed);
$state = $result->getAttribute(TestState::class);
Assert::instanceOf($state, TestState::class);
Assert::count($state->history, 3);
Assert::same($state->history[0]->getAssertion(), 'is the same');
Assert::same($state->history[1]->getAssertion(), 'is custom');
Assert::same($state->history[2]->getAssertion(), 'is exactly `true`');
}

public function uncaughtFailureUsesNormalFailurePath(): void
{
$result = TestRunner::runTest([CustomAssertions::class, 'uncaughtFailure']);
Assert::same($result->status, Status::Failed);
$state = $result->getAttribute(TestState::class);
Assert::instanceOf($state, TestState::class);
Assert::count($state->history, 1);
Assert::same($state->history[0], $result->failure);
Assert::same($state->history[0]->getAssertion(), 'is valid');
Assert::same($state->history[0]->getContext(), 'domain check');
Assert::same($state->history[0]->getFailReason(), 'invalid');
Assert::same($state->history[0]->getFailDetails(), 'details');
Assert::string($result->failure?->getMessage())
->contains('Failed assertion that `value` is valid: invalid.')
->contains('Meaning: domain check')
->contains("Details:\ndetails");
Assert::true($result->failure?->getTrace() !== []);
}

public function caughtFailureIsRetainedAndTestPasses(): void
{
$result = TestRunner::runTest([CustomAssertions::class, 'caughtFailure']);
Assert::same($result->status, Status::Passed);
$state = $result->getAttribute(TestState::class);
Assert::instanceOf($state, TestState::class);
Assert::count($state->history, 1);
Assert::same($state->history[0], CustomAssertions::$caughtFailure);
Assert::instanceOf($state->history[0], AssertionException::class);
}

public function expectedComparisonFailureUsesNormalExpectationHandling(): void
{
$result = TestRunner::runTest([CustomAssertions::class, 'expectedComparisonFailure']);
Assert::same($result->status, Status::Passed);
$state = $result->getAttribute(TestState::class);
Assert::instanceOf($state, TestState::class);
Assert::count($state->history, 2);

$comparisonFailure = null;
$expectation = null;
foreach ($state->history as $record) {
$record instanceof ComparisonFailure and $comparisonFailure = $record;
$record instanceof Expectation and $expectation = $record;
}

Assert::instanceOf($comparisonFailure, ComparisonFailure::class);
Assert::same($comparisonFailure->expected, 'expected');
Assert::same($comparisonFailure->actual, 'actual');
Assert::instanceOf($expectation, Expectation::class);
}

public function emptySuccessDescriptionIsRejected(): void
{
$this->assertRejectedInput('emptySuccessDescriptionIsRejected');
}

public function emptyAssertionExceptionDescriptionIsRejected(): void
{
$this->assertRejectedInput('emptyAssertionExceptionDescriptionIsRejected');
}

public function unsupportedThrowableIsRejected(): void
{
$this->assertRejectedInput('unsupportedThrowableIsRejected');
}

public function emptyCustomAssertionDescriptionIsRejected(): void
{
$this->assertRejectedInput('emptyCustomAssertionDescriptionIsRejected');
}

public function validSuccessAfterValidationIsRecorded(): void
{
$result = TestRunner::runTest([CustomAssertions::class, 'validSuccessAfterValidation']);
Assert::same($result->status, Status::Passed);
$state = $result->getAttribute(TestState::class);
Assert::instanceOf($state, TestState::class);
Assert::count($state->history, 1);
Assert::instanceOf($state->history[0], AssertionSuccess::class);
Assert::same($state->history[0]->getAssertion(), 'after validation');
}

private function assertRejectedInput(string $fixture): void
{
$result = TestRunner::runTest([CustomAssertions::class, $fixture]);
Assert::same($result->status, Status::Passed);
$state = $result->getAttribute(TestState::class);
Assert::instanceOf($state, TestState::class);
Assert::count($state->history, 1);
Assert::instanceOf($state->history[0], Expectation::class);
}

public static bool $outsideStateIsSafe = false;

public function outsideActiveStateKeepsExplicitBoundary(): void
{
Assert::true(self::$outsideStateIsSafe);
}

}

CustomAssertionsTest::$outsideStateIsSafe = (static function (): bool {
Assert::success('value', 'outside');
$failure = new AssertionException('value', 'fails', '', 'reason', '');
try {
Assert::failure($failure);
} catch (AssertionException $actual) {
return $actual === $failure;
}

return false;
})();
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public function recordsFiveAssertions(): void
self::assertAcrossSuspends(5);
}

public function recordsCustomAssertionsAcrossSuspends(): void
{
for ($i = 1; $i <= 3; $i++) {
Assert::success($i, 'is custom assertion');
\Fiber::suspend();
}
}

/**
* Record exactly `$count` passing assertions, suspending after each so the other tests interleave.
*/
Expand Down
Loading
Loading