diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index ee441d48..c6f0c785 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -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: '`, 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* | diff --git a/bridge/rector/src/TestoToPhpunit/CustomAssertionRector.php b/bridge/rector/src/TestoToPhpunit/CustomAssertionRector.php new file mode 100644 index 00000000..275f371e --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/CustomAssertionRector.php @@ -0,0 +1,37 @@ +getAssertion() === '' and throw new \InvalidArgumentException('Assertion description must not be empty.'); + StaticState::fail($failure); + } + /** * Fails the test. * diff --git a/plugin/assert/README.md b/plugin/assert/README.md index d5039633..1955abe5 100644 --- a/plugin/assert/README.md +++ b/plugin/assert/README.md @@ -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 diff --git a/plugin/assert/src/State/Assertion/AssertionException.php b/plugin/assert/src/State/Assertion/AssertionException.php index 97afe79b..1fac5c93 100644 --- a/plugin/assert/src/State/Assertion/AssertionException.php +++ b/plugin/assert/src/State/Assertion/AssertionException.php @@ -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 { @@ -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}"; diff --git a/plugin/assert/src/State/Assertion/ComparisonFailure.php b/plugin/assert/src/State/Assertion/ComparisonFailure.php index da333726..4ee4fa4a 100644 --- a/plugin/assert/src/State/Assertion/ComparisonFailure.php +++ b/plugin/assert/src/State/Assertion/ComparisonFailure.php @@ -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). diff --git a/plugin/assert/tests/Feature/ConcurrentAssertIsolationTest.php b/plugin/assert/tests/Feature/ConcurrentAssertIsolationTest.php index d5add2f1..41674585 100644 --- a/plugin/assert/tests/Feature/ConcurrentAssertIsolationTest.php +++ b/plugin/assert/tests/Feature/ConcurrentAssertIsolationTest.php @@ -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( diff --git a/plugin/assert/tests/Feature/CustomAssertionsTest.php b/plugin/assert/tests/Feature/CustomAssertionsTest.php new file mode 100644 index 00000000..5dbaa8f3 --- /dev/null +++ b/plugin/assert/tests/Feature/CustomAssertionsTest.php @@ -0,0 +1,159 @@ +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; +})(); diff --git a/plugin/assert/tests/Stub/Concurrency/RoundRobinAssertScenarios.php b/plugin/assert/tests/Stub/Concurrency/RoundRobinAssertScenarios.php index 34fd0325..b1ae6060 100644 --- a/plugin/assert/tests/Stub/Concurrency/RoundRobinAssertScenarios.php +++ b/plugin/assert/tests/Stub/Concurrency/RoundRobinAssertScenarios.php @@ -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. */ diff --git a/plugin/assert/tests/Stub/CustomAssertions.php b/plugin/assert/tests/Stub/CustomAssertions.php new file mode 100644 index 00000000..aa77183b --- /dev/null +++ b/plugin/assert/tests/Stub/CustomAssertions.php @@ -0,0 +1,109 @@ + true], 'is valid', 'domain check'); + } + + #[Test] + public function mixedHistory(): void + { + Assert::same(1, 1); + Assert::success('value', 'is custom'); + Assert::true(true); + } + + #[Test] + public function uncaughtFailure(): never + { + Assert::failure(new AssertionException('value', 'is valid', 'domain check', 'invalid', 'details')); + } + + #[Test] + public function caughtFailure(): void + { + $failure = new AssertionException('value', 'is valid', 'domain check', 'invalid', 'details'); + try { + Assert::failure($failure); + } catch (AssertionException $actual) { + self::$caughtFailure = $actual; + if ($actual !== $failure) { + throw new \LogicException('Failure identity was not preserved'); + } + } + } + + #[Test] + public function expectedComparisonFailure(): never + { + Expect::exception(AssertionException::class); + Assert::failure(new ComparisonFailure( + expected: 'expected', + actual: 'actual', + value: 'actual', + assertion: 'equals expected', + context: 'comparison', + reason: 'different', + details: 'diff', + )); + } + + #[Test] + public function emptySuccessDescriptionIsRejected(): never + { + Expect::exception(\InvalidArgumentException::class); + Assert::success(1, ''); + } + + #[Test] + public function emptyAssertionExceptionDescriptionIsRejected(): never + { + Expect::exception(\InvalidArgumentException::class); + new AssertionException('value', '', '', 'reason', ''); + } + + #[Test] + public function unsupportedThrowableIsRejected(): never + { + /** @var mixed $failure */ + $failure = new \RuntimeException('unsupported'); + Expect::exception(\TypeError::class); + Assert::failure($failure); + } + + #[Test] + public function emptyCustomAssertionDescriptionIsRejected(): never + { + Expect::exception(\InvalidArgumentException::class); + Assert::failure(new EmptyAssertionDescription('value', 'valid', '', 'reason', '')); + } + + #[Test] + public function validSuccessAfterValidation(): void + { + Assert::success(1, 'after validation'); + } +} diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index e50170d3..9d6a329b 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -59,6 +59,9 @@ Hard rules: Use the `Testo\Assert` facade for in-test checks. Order is **actual, expected** for `same`/`equals`. ```php +use Testo\Assert; +use Testo\Assert\State\Assertion\AssertionException; + Assert::same($user->id, 42); Assert::notSame($a, $b); Assert::equals($result, '1'); // loose == @@ -71,9 +74,13 @@ Assert::notBlank($value); // inverse of blank(); false/0/'0' count a Assert::contains($collection, $needle); Assert::count($collection, 3); Assert::instanceOf($object, MyClass::class); +Assert::success($actual, 'satisfies the domain rule', 'optional context'); +Assert::failure(new AssertionException($value, 'satisfies the domain rule', '', 'rule violated', '')); Assert::fail('explicit failure'); ``` +Custom assertions use only the public `AssertionException` contract (including `ComparisonFailure`), never arbitrary `Throwable`; both are documented API records. `Assert::failure()` records and throws (`never`), so it may be caught and leave a failed history record while the test passes; matching `Expect::exception()` follows ordinary expectation handling. Descriptions must be non-empty, and no internal state class is needed. Outside an active Testo test, custom success is a no-op and custom failure still throws. + Typed chains (use when you want a fluent series of checks on one value): ```php