diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index ee441d48..53e97217 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -17,7 +17,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **actual/expected argument swap** | βœ… | βœ… | βœ… | | **Fluent / typed chains** (`Assert::string()->…`, Pest `->not->`, `toBeGreaterThan`) | 🟑 *`TypedAssertChainRector` decomposes into separate `assert*` lines (incl. 1β†’N `hasKeys`, `between`, `isList`β†’`assertIsList`, `sameElementsAs`β†’`assertEqualsCanonicalizing`, the `iterable` headβ†’`assertIsIterable`, a bare head such as `Assert::callable($x)`β†’`assertIsCallable`, `allInstanceOf`β†’`assertContainsOnlyInstancesOf`, `allOf('int')`β†’`assertContainsOnlyInt` for the exact native types, `sameSizeAs`β†’`assertSameSize` on countable sides, `matchesRegex`/`notMatchesRegex`β†’`assertMatchesRegularExpression`/`assertDoesNotMatchRegularExpression`, `ignoringCase()->contains()`/`notContains()`β†’`assertStringContainsStringIgnoringCase`/`assertStringNotContainsStringIgnoringCase`, `ignoringLineEndings()->contains()`β†’`assertStringContainsStringIgnoringLineEndings`, `same`/`notSame`β†’`assertSame`/`assertNotSame`, `notStartsWith`/`notEndsWith`β†’`assertStringStartsNotWith`/`assertStringEndsNotWith`, `ignoringCase()->same()`/`notSame()`β†’`assertEqualsIgnoringCase`/`assertNotEqualsIgnoringCase`, `ignoringLineEndings()->same()`β†’`assertStringEqualsStringIgnoringLineEndings`, `ignoringWhitespace(true)->same()`/`notSame()`β†’`assertStringEqualsStringIgnoringWhitespace`/`NotEquals`); a non-variable subject is hoisted into a `$value` local so it is evaluated once; only inside a class (the emitted `$this->assert*` needs a method scope); JSON path/structure, `every`, the `callable` matchers (`isStatic`/`notStatic`, `hasReturnType`), custom matchers, and the string modifier combinations PHPUnit lacks (`ignoringCase()->startsWith()`, several modes together, `ignoringWhitespace()` without line breaks, `ignoringBlankLines()`, `ignoringAnsi()`) are left untouched + TODO* | 🟑 *`TypedAssertCallToTestoRector` converts the assertions whose faithful Testo form is a typed head + matcher: comparisons (`assertGreaterThan`β†’`Assert::numeric()->greaterThan()`, …), array keys (`assertArrayHasKey`β†’`Assert::array()->hasKeys()`), `assertEqualsCanonicalizing`β†’`sameElementsAs`, array-subject `assertEmpty`/`assertNotEmpty`β†’`blank`/`notBlank` (a non-object subject β†’ `Assert::true(empty($x))`), `assertStringStartsWith`/`EndsWith`β†’`Assert::string()->startsWith()`/`endsWith()`, `assertStringStartsNotWith`/`EndsNotWith`β†’`notStartsWith()`/`notEndsWith()`, `assertStringContainsString`/`NotContainsString`β†’`Assert::string()->contains()`/`notContains()`, `assertStringContainsStringIgnoringCase`/`NotContainsStringIgnoringCase`β†’`Assert::string()->ignoringCase()->contains()`/`notContains()`, `assertStringContainsStringIgnoringLineEndings`β†’`Assert::string()->ignoringLineEndings()->contains()`, `assertStringEqualsStringIgnoringLineEndings`β†’`->ignoringLineEndings()->same()`, `assertStringEqualsStringIgnoringWhitespace`/`NotEquals`β†’`->ignoringWhitespace(lineBreaks: true)->same()`/`notSame()`, `assertEqualsIgnoringCase`/`NotEqualsIgnoringCase`β†’`->ignoringCase()->same()`/`notSame()` (both sides strings), `assertMatchesRegularExpression`/`DoesNotMatchRegularExpression` (and the PHPUnit 9 `assertRegExp`/`assertNotRegExp`)β†’`Assert::string()->matchesRegex()`/`notMatchesRegex()`, `assertNotContains`β†’`Assert::iterable()->notContains()`, `assertObjectHasProperty`β†’`Assert::object()->hasProperty()`, `assertIsList`β†’`Assert::array()->isList()`, `assertContainsOnlyInt`/`String`/…→`Assert::iterable()->allOf('int')` and `assertContainsOnlyInstancesOf`β†’`allInstanceOf()`, `assertSameSize`β†’`Assert::iterable()->sameSizeAs()` (`Countable` sides), `assertJson`β†’`Assert::json()` (JSON equality stays: PHPUnit compares canonical re-encoded JSON), `assertIs`β†’the type head (`assertIsCallable`β†’`Assert::callable()` included; `assertIsBool`β†’the flat `Assert::bool()`), and the predicate checks (`assertIsScalar`, `assertIsNot*`, `assertFileExists`, `assertDirectoryExists`, …)β†’`Assert::true|false(\is_bool($x))`, also the file/directory permission checks (`\file_exists($f) && !\is_readable($f)`, side-effect-free path), `assertFinite`/`Infinite`/`Nan` (`int|float` subject), the resource checks over `\gettype()` (a closed resource counts), `assertNotInstanceOf`β†’`Assert::false($x instanceof Foo)` and `assertObjectNotHasProperty`β†’`Assert::false(\property_exists($o, $p))` (object subject). Each is a 1:1 statement rewrite; `MergeAssertChainRector` then folds adjacent same-head chains that open with the same comparison modifiers. General coalescing of arbitrary independent `assert*` lines remains impractical* | 🧩 *function host now exists; `ExpectToAssertRector` still leaves negated `->not->` and chained `->toX()->toY()` expectations untouched β€” mappable next* | | **Exception expectation (bare)** | βœ… *bare `\Testo\Expect::exception($c)` β†’ `$this->expectException($c)` (`ExpectExceptionToPhpUnitRector`); the attribute form `#[\Testo\Assert\ExpectException($c)]` β†’ prepended `$this->expectException($c)` (`ExpectExceptionAttributeToPhpUnitRector`)* | 🟑 | βœ… *`TestCallToFunctionRector` folds `->throws(X::class)` into a prepended `\Testo\Expect::exception(X)` + `never` return type* | -| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; two modifiers on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` folds the sibling `expectExceptionMessage/Code` after `expectException` into the `->withMessageContaining()/->withCode()` chain (StmtsAware), across assignments that cannot throw β€” PHPUnit matches the message as a substring; any other statement ends the run* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | +| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; repeated `withMessage` or two constraints on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` preserves exact-empty, substring, last-wins and named message/code expectations; proven nonempty messages stay simple, adjacent overwritten literals are removed, and other messages use replaceable `withMessage(..., exact: ...)` with a named flag and no regex length limit. Across assignments a local retains registration and argument snapshots. Message regexes remain independent* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | | **Exception message by regex** (`expectExceptionMessageMatches`) | βœ… *`withMessageMatchingRegex`/`withMessagePattern`β†’`expectExceptionMessageMatches`* | βœ… *`ExpectExceptionToTestoRector` folds it into `->withMessageMatchingRegex()`* | βž– | | **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* | diff --git a/bridge/rector/README.md b/bridge/rector/README.md index 2062365a..a4abe542 100644 --- a/bridge/rector/README.md +++ b/bridge/rector/README.md @@ -19,6 +19,20 @@ also work with `$rectorConfig->import(...)`. `PHPUNIT_TO_TESTO` switches Rector to serial processing. A class that reaches `TestCase` through a project base class is recognised by the base's ancestry, and a parallel worker can read that base after another worker has already detached it, leaving the subclass's tests without `#[Test]` and silently undiscovered. Do not re-enable `withParallel()` for this set. +### Generated-code compatibility + +Converting dynamic or repeated PHPUnit exception messages can generate +`withMessage($message, exact: ...)`. The target project needs a `testo/assert` release providing +`ExpectedException::withMessage(string $message, bool $exact = true)` before running that output. +While this API is unreleased, use the matching Assert code from the same `1.x` checkout. +The bridge's development dependencies do not constrain Assert in the target project, especially +when Rector is installed separately. + +The generated flag is always named, including inside an unpacked argument array. An older Assert +therefore reports an unknown `exact` parameter instead of silently ignoring a positional flag and +changing a substring check to an exact match. Upgrade the target project's Assert before migration; +keep independent regex message constraints when reviewing the result. + ### Mock sets Testo core ships no mocking, so test doubles convert through their own sets, one per target library. diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php index 9db739cd..303973a2 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php @@ -6,20 +6,28 @@ use PhpParser\Node; use PhpParser\Node\Arg; +use PhpParser\Node\ArrayItem; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Assign; use PhpParser\Node\Expr\BinaryOp\Concat; +use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\FunctionLike; use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Param; use PhpParser\Node\Scalar; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; use Rector\Contract\PhpParser\Node\StmtsAwareInterface; use Rector\NodeTypeResolver\Node\AttributeKey; @@ -36,8 +44,11 @@ * The head `$this->expectException($c)` (also `self::`/`static::`) becomes * `\Testo\Expect::exception($c)`, and each immediately-following sibling statement is absorbed as a * chained modifier and removed: - * - `expectExceptionMessage($m)` => `->withMessageContaining($m)` β€” PHPUnit matches a substring, - * so the exact `->withMessage()` would fail tests that passed before; + * - a single provably nonempty message => `->withMessageContaining($m)`; + * - an empty literal message => `->withMessage('')`; + * - a string variable => `->withMessage($m, exact: $m === '')`; + * - retained repeated nonempty messages => `->withMessage($m, exact: false)`; + * - other messages => `->withMessage(...(static fn(string $m): array => [$m, 'exact' => $m === ''])($expr))`; * - `expectExceptionMessageMatches($re)` => `->withMessageMatchingRegex($re)`; * - `expectExceptionCode($n)` => `->withCode($n)`. * @@ -51,12 +62,13 @@ * statements), it operates at the statements level: it matches the enclosing * {@see StmtsAwareInterface} node and rewrites its `->stmts`. * - * Conservative by design: only folding calls that are siblings after the `expectException` statement - * are absorbed, and the only statements allowed between them are assignments that cannot throw - * (`$message = 'Type "float" is not supported.';`). The chain then takes the place of the last - * absorbed call, after those assignments. Any other statement ends the run: code that may throw - * decides which expectations PHPUnit had registered by then. A bare - * `expectExceptionMessage`/`Code` with no preceding `expectException` is left untouched. + * Arguments keep their evaluation order, including overwritten expectations. A typed closure + * chooses exact-empty or substring mode after evaluating a dynamic message once. + * Named source arguments are validated and emitted positionally. Across assignments, a fresh local + * holds the handler so registration and argument snapshots stay at their original statements. + * Any other statement ends the run. A bare message/code with no preceding class is left untouched. + * + * Message and regex expectations remain independent, as in PHPUnit. * * Only folds inside a class: the expectations belong to a test method, so a run in a free function * or at namespace level is left untouched. @@ -122,23 +134,42 @@ public function refactor(Node $node): ?Node continue; } + $argument = $this->argument($head, 'exception'); + if ($argument === null) { + $result[] = $stmt; + continue; + } + $chain = new StaticCall( new FullyQualified('Testo\\Expect'), new Identifier('exception'), - $head->args, + [new Arg($argument)], ); - # Absorb the expectExceptionMessage/Code siblings that follow, across assignments that - # cannot throw. The chain lands where the last modifier stood, after those assignments, - # so a modifier argument they define (`$message = '…';`) is set by then. - $between = []; + # Collect sibling modifiers, retaining intervening assignments in their source order. + $start = $i; + $modifiers = []; $pending = []; + $hasAssignments = false; + $messageCount = 0; for ($j = $i + 1; $j < $count; ++$j) { $next = $stmts[$j]; $modifier = $next instanceof Expression ? $this->matchModifier($next) : null; if ($modifier !== null) { - $chain = new MethodCall($chain, new Identifier($modifier[0]), $modifier[1]); - \array_push($between, ...$pending); + $previous = $modifiers[\array_key_last($modifiers)] ?? null; + if ($pending === [] && $modifier[0] === 'message' && $modifier[1] instanceof String_ + && \is_array($previous) && $previous[1][0] === 'message' + && $previous[1][1] instanceof String_ && $previous[0]->getComments() === [] + ) { + # Only adjacent literals: an intervening argument or assignment may throw + # while the earlier message expectation is still active. + \array_pop($modifiers); + --$messageCount; + } + $hasAssignments = $hasAssignments || $pending !== []; + \array_push($modifiers, ...$pending); + $modifiers[] = [$next, $modifier]; + $messageCount += (int) ($modifier[0] === 'message'); $pending = []; $i = $j; continue; @@ -152,8 +183,33 @@ public function refactor(Node $node): ?Node } \assert($stmt instanceof Expression); + if ($hasAssignments) { + # Keep registration and every argument evaluation at its original statement. + $expectation = $this->temporaryName($node, $stmt); + if ($expectation === null) { + \array_push($result, ...\array_slice($stmts, $start, $i - $start + 1)); + continue; + } + + $stmt->expr = new Assign($expectation, $chain); + $result[] = $stmt; + foreach ($modifiers as $modifier) { + if (!\is_array($modifier)) { + $result[] = $modifier; + continue; + } + [$statement, [$method, $value]] = $modifier; + $statement->expr = $this->modifier($expectation, $method, $value, $messageCount); + $result[] = $statement; + } + $changed = true; + continue; + } + + foreach ($modifiers as [, [$method, $value]]) { + $chain = $this->modifier($chain, $method, $value, $messageCount); + } $stmt->expr = $chain; - \array_push($result, ...$between); $result[] = $stmt; $changed = true; } @@ -194,7 +250,7 @@ private function matchExpectException(Expression $stmt): MethodCall|StaticCall|n * Maps a foldable `$this->expectExceptionMessage/MessageMatches/Code(...)` statement to a `[method, args]` * pair for the fluent chain, or null when the statement is not a foldable modifier. * - * @return array{0: non-empty-string, 1: array}|null + * @return array{non-empty-string, Expr}|null */ private function matchModifier(Expression $stmt): ?array { @@ -212,17 +268,105 @@ private function matchModifier(Expression $stmt): ?array return null; } - return match (true) { - $this->isName($expr->name, 'expectExceptionMessage') => ['withMessageContaining', $expr->args], - $this->isName($expr->name, 'expectExceptionMessageMatches') => ['withMessageMatchingRegex', $expr->args], - $this->isName($expr->name, 'expectExceptionCode') => ['withCode', $expr->args], + $mapping = match (true) { + $this->isName($expr->name, 'expectExceptionMessage') => ['message', 'message'], + $this->isName($expr->name, 'expectExceptionMessageMatches') => ['withMessageMatchingRegex', 'regularExpression'], + $this->isName($expr->name, 'expectExceptionCode') => ['withCode', 'code'], default => null, }; + if ($mapping === null) { + return null; + } + $argument = $this->argument($expr, $mapping[1]); + return $argument === null ? null : [$mapping[0], $argument]; + } + + private function argument(MethodCall|StaticCall $call, string $parameter): ?Expr + { + if (\count($call->args) !== 1 || !$call->args[0] instanceof Arg) { + return null; + } + $argument = $call->args[0]; + return $argument->unpack || ($argument->name !== null && $argument->name->toString() !== $parameter) + ? null : $argument->value; + } + + private function modifier(Expr $receiver, string $method, Expr $value, int $messageCount): MethodCall + { + $arguments = [new Arg($value)]; + if ($method === 'message') { + # Rector's type corrector removes non-empty-string accessories; keep PHPStan's proof. + $scope = $value->getAttribute(AttributeKey::SCOPE); + $type = $scope instanceof Scope ? $scope->getType($value) : $this->getType($value); + if ($messageCount === 1 && $type->isNonEmptyString()->yes()) { + $method = 'withMessageContaining'; + } else { + $method = 'withMessage'; + if ($value instanceof String_) { + $arguments = $value->value === '' ? [new Arg($value)] + : [new Arg($value), new Arg(new ConstFetch(new Name('false')), name: new Identifier('exact'))]; + } elseif ($type->isNonEmptyString()->yes()) { + $arguments[] = new Arg(new ConstFetch(new Name('false')), name: new Identifier('exact')); + } elseif ($value instanceof Variable && \is_string($value->name) && $type->isString()->yes()) { + $arguments[] = new Arg(new Identical($value, new String_('')), name: new Identifier('exact')); + } else { + $arguments = [$this->messageArguments($value)]; + } + } + } + return new MethodCall($receiver, new Identifier($method), $arguments); + } + + private function messageArguments(Expr $message): Arg + { + # A typed closure keeps PHPUnit's string parameter boundary (weak/strict caller coercion) + # and evaluates the original expression only once, including an overwritten expectation. + $value = new Variable('message'); + return new Arg(new FuncCall(new ArrowFunction([ + 'static' => true, + 'params' => [new Param($value, type: new Identifier('string'))], + 'returnType' => new Identifier('array'), + 'expr' => new Array_([ + new ArrayItem($value), + new ArrayItem(new Identical($value, new String_('')), new String_('exact')), + ]), + ]), [new Arg($message)]), unpack: true); + } + + private function temporaryName(Node $node, Expression $statement): ?Variable + { + # The method declaration's scope is outside the function, where every local is "maybe". + $scope = $statement->getAttribute(AttributeKey::SCOPE); + if ($scope instanceof Scope && $scope->canAnyVariableExist()) { + # After extract(), no local name can be proven fresh. + return null; + } + $finder = new NodeFinder(); + # A nested statement block does not contain variables read or captured elsewhere + # in its function. Reserve those names too, including conditionally defined locals. + foreach ($finder->findInstanceOf($this->getFile()->getNewStmts(), FunctionLike::class) as $function) { + if ($function === $node || $finder->findFirst($function, static fn(Node $child): bool => $child === $node) !== null) { + $node = $function; + break; + } + } + $used = []; + foreach ($finder->findInstanceOf($node, Variable::class) as $variable) { + if (!\is_string($variable->name)) { + return null; + } + $used[$variable->name] = true; + } + $name = 'testoExpectedException'; + for ($suffix = 2; isset($used[$name]) || ($scope instanceof Scope && !$scope->hasVariableType($name)->no()); ++$suffix) { + $name = 'testoExpectedException' . $suffix; + } + return new Variable($name); } /** - * Whether the statement assigns a value that cannot throw to a plain variable: a scalar, a - * constant, or a concatenation or array built from them and from variables. + * Whether the statement is an assignment we can retain between expectation calls. + * Constants and concatenations can throw, so registration must stay before the assignment. */ private function isSafeAssignment(Node $stmt): bool { diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/dynamic_locals_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/dynamic_locals_unchanged.php.inc new file mode 100644 index 00000000..9ef01089 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/dynamic_locals_unchanged.php.inc @@ -0,0 +1,12 @@ +expectException(\RuntimeException::class); + $message = 'boom'; + $this->expectExceptionMessage($message); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc index 09cfd6b1..b5a89915 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc @@ -18,8 +18,9 @@ class FoldAcrossAssignmentTest { public function testInitWithWrongType(): void { + $testoExpectedException = \Testo\Expect::exception(\InvalidArgumentException::class); $message = 'Type "float" is not supported. The valid types are: ' . self::TYPES . '.'; - \Testo\Expect::exception(\InvalidArgumentException::class)->withMessageContaining($message); + $testoExpectedException->withMessageContaining($message); new Compare(type: 'float'); } diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc new file mode 100644 index 00000000..3fa5a51d --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc @@ -0,0 +1,34 @@ +expectException(\Throwable::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessageMatches('~two~'); + $this->expectExceptionMessage($this->message()); + } + + public function dynamicMessageAndPattern(): void + { + $this->expectException(\Throwable::class); + $this->expectExceptionMessage($this->message()); + $this->expectExceptionMessageMatches('~two~'); + } +} +----- +withMessage('old', exact: false)->withMessageMatchingRegex('~two~')->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->message())); + } + + public function dynamicMessageAndPattern(): void + { + \Testo\Expect::exception(\Throwable::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->message()))->withMessageMatchingRegex('~two~'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/message_expressions.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/message_expressions.php.inc new file mode 100644 index 00000000..a15116cb --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/message_expressions.php.inc @@ -0,0 +1,78 @@ +expectException(\RuntimeException::class); + $this->expectExceptionMessage(self::MESSAGE); + } + + public function stringVariable(string $message): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + } + + public function concatenation(string $message): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('prefix ' . $message); + } + + public function formatted(string $message): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(sprintf('prefix %s', $message)); + } + + public function effectful(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($this->message()); + } + + private function message(): string + { + return ''; + } +} +----- +withMessageContaining(self::MESSAGE); + } + + public function stringVariable(string $message): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage($message, exact: $message === ''); + } + + public function concatenation(string $message): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('prefix ' . $message); + } + + public function formatted(string $message): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining(sprintf('prefix %s', $message)); + } + + public function effectful(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->message())); + } + + private function message(): string + { + return ''; + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc new file mode 100644 index 00000000..1c83c53c --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc @@ -0,0 +1,427 @@ +expectException(\RuntimeException::class); + $this->expectExceptionMessage(''); + throw new \RuntimeException('nonempty'); + } + + public function emptyPasses(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(''); + throw new \RuntimeException(''); + } + + public function lastMessageWins(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('one'); + $this->expectExceptionMessage('two'); + throw new \RuntimeException('two'); + } + + public function namedArguments(): void + { + $this->expectException(exception: \RuntimeException::class); + $this->expectExceptionMessage(message: 'named'); + $this->expectExceptionCode(code: 7); + throw new \RuntimeException('named', 7); + } + + public function literalRegexCharacters(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessage('~.[x]+$\\'); + throw new \RuntimeException('prefix ~.[x]+$\\ suffix'); + } + + public function dynamicEmptyMustFail(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($this->mark('')); + throw new \RuntimeException('nonempty'); + } + + public function evaluationOrder(): void + { + $this->trace = []; + $this->expectException($this->mark(\RuntimeException::class)); + $this->expectExceptionMessage($this->mark('one')); + $this->expectExceptionMessage($this->mark('two')); + $this->expectExceptionCode(1); + $this->expectExceptionCode(2); + \Testo\Assert::same($this->trace, [\RuntimeException::class, 'one', 'two']); + throw new \RuntimeException('two', 2); + } + + public function overwrittenArgumentThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('one'); + $this->expectExceptionMessage($this->throwOne()); + } + + public function independentPattern(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('two'); + $this->expectExceptionMessageMatches(regularExpression: '~^two$~'); + throw new \RuntimeException('two'); + } + + public function independentPatternMustFail(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('two'); + $this->expectExceptionMessageMatches('~^three$~'); + throw new \RuntimeException('two'); + } + + public function assignmentKeepsSnapshots(): void + { + $class = \RuntimeException::class; + $message = 'one'; + $this->expectException($class); + $this->expectExceptionMessage($message); + $class = \LogicException::class; + $message = 'two'; + $this->expectExceptionMessage($message); + throw new \RuntimeException('two'); + } + + public function temporaryDoesNotClobberSource(): void + { + $testoExpectedException = 'source'; + $this->expectException(\RuntimeException::class); + $message = 'two'; + $this->expectExceptionMessage($message); + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function longRepeatedMessage(): void + { + $message = str_repeat('a', 100000); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessage($message); + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + + public function substringMismatchMustFail(): void + { + $message = 'needle'; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + throw new \RuntimeException('needl'); + } + + public function lastEmptyWins(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessage(''); + throw new \RuntimeException(''); + } + + public function lastNonemptyWins(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(''); + $this->expectExceptionMessage('new'); + throw new \RuntimeException('prefix new suffix'); + } + + public function dynamicMessageAndPattern(): void + { + $message = 'two'; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + $this->expectExceptionMessageMatches('~^two$~'); + throw new \RuntimeException('two'); + } + + public function repeatedMessageAndPatternMustFail(): void + { + $this->expectException(\Throwable::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessageMatches('~^three$~'); + $this->expectExceptionMessage($this->mark('two')); + throw new \RuntimeException('two'); + } + + public function conditionalLocalSurvives(): void + { + if ($this->condition()) { + $testoExpectedException = 'source'; + } + if ($this->condition()) { + $this->expectException(\RuntimeException::class); + $message = 'two'; + $this->expectExceptionMessage($message); + } + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function laterLocalSurvives(): void + { + if ($this->condition()) { + $this->expectException(\RuntimeException::class); + $message = 'two'; + $this->expectExceptionMessage($message); + } + $testoExpectedException ??= 'source'; + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function assignmentCanThrow(): void + { + $part = new class { + public function __toString(): string + { + throw new \RuntimeException('assignment'); + } + }; + $this->expectException(\RuntimeException::class); + $message = 'prefix ' . $part; + $this->expectExceptionMessage($message); + } + + public function weakFalseMustFail(): void + { + $message = false; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + throw new \RuntimeException('nonempty'); + } + + public function overwrittenMessageMustFail(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('expected'); + $this->expectExceptionMessage($this->throwOne()); + } + + private function condition(): bool + { + return true; + } + + private function mark(string $value): string + { + $this->trace[] = $value; + return $value; + } + + private function throwOne(): string + { + throw new \RuntimeException('one'); + } +} +----- +withMessage(''); + throw new \RuntimeException('nonempty'); + } + + public function emptyPasses(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage(''); + throw new \RuntimeException(''); + } + + public function lastMessageWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('two'); + throw new \RuntimeException('two'); + } + + public function namedArguments(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('named')->withCode(7); + throw new \RuntimeException('named', 7); + } + + public function literalRegexCharacters(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('~.[x]+$\\'); + throw new \RuntimeException('prefix ~.[x]+$\\ suffix'); + } + + public function dynamicEmptyMustFail(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark(''))); + throw new \RuntimeException('nonempty'); + } + + public function evaluationOrder(): void + { + $this->trace = []; + \Testo\Expect::exception($this->mark(\RuntimeException::class))->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark('one')))->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark('two')))->withCode(1)->withCode(2); + \Testo\Assert::same($this->trace, [\RuntimeException::class, 'one', 'two']); + throw new \RuntimeException('two', 2); + } + + public function overwrittenArgumentThrows(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('one', exact: false)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->throwOne())); + } + + public function independentPattern(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('two')->withMessageMatchingRegex('~^two$~'); + throw new \RuntimeException('two'); + } + + public function independentPatternMustFail(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('two')->withMessageMatchingRegex('~^three$~'); + throw new \RuntimeException('two'); + } + + public function assignmentKeepsSnapshots(): void + { + $class = \RuntimeException::class; + $message = 'one'; + $testoExpectedException = \Testo\Expect::exception($class); + $testoExpectedException->withMessage($message, exact: false); + $class = \LogicException::class; + $message = 'two'; + $testoExpectedException->withMessage($message, exact: false); + throw new \RuntimeException('two'); + } + + public function temporaryDoesNotClobberSource(): void + { + $testoExpectedException = 'source'; + $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); + $message = 'two'; + $testoExpectedException2->withMessageContaining($message); + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function longRepeatedMessage(): void + { + $message = str_repeat('a', 100000); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage($message, exact: false); + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + + public function substringMismatchMustFail(): void + { + $message = 'needle'; + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining($message); + throw new \RuntimeException('needl'); + } + + public function lastEmptyWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage(''); + throw new \RuntimeException(''); + } + + public function lastNonemptyWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('new'); + throw new \RuntimeException('prefix new suffix'); + } + + public function dynamicMessageAndPattern(): void + { + $message = 'two'; + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining($message)->withMessageMatchingRegex('~^two$~'); + throw new \RuntimeException('two'); + } + + public function repeatedMessageAndPatternMustFail(): void + { + \Testo\Expect::exception(\Throwable::class)->withMessage('old', exact: false)->withMessageMatchingRegex('~^three$~')->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark('two'))); + throw new \RuntimeException('two'); + } + + public function conditionalLocalSurvives(): void + { + if ($this->condition()) { + $testoExpectedException = 'source'; + } + if ($this->condition()) { + $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); + $message = 'two'; + $testoExpectedException2->withMessageContaining($message); + } + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function laterLocalSurvives(): void + { + if ($this->condition()) { + $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); + $message = 'two'; + $testoExpectedException2->withMessageContaining($message); + } + $testoExpectedException ??= 'source'; + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function assignmentCanThrow(): void + { + $part = new class { + public function __toString(): string + { + throw new \RuntimeException('assignment'); + } + }; + $testoExpectedException = \Testo\Expect::exception(\RuntimeException::class); + $message = 'prefix ' . $part; + $testoExpectedException->withMessageContaining($message); + } + + public function weakFalseMustFail(): void + { + $message = false; + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($message)); + throw new \RuntimeException('nonempty'); + } + + public function overwrittenMessageMustFail(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('expected', exact: false)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->throwOne())); + } + + private function condition(): bool + { + return true; + } + + private function mark(string $value): string + { + $this->trace[] = $value; + return $value; + } + + private function throwOne(): string + { + throw new \RuntimeException('one'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc new file mode 100644 index 00000000..4bd4923c --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc @@ -0,0 +1,28 @@ +expectException(\TypeError::class); + $this->expectExceptionMessage($message); + } +} +----- +withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($message)); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/unsupported_arguments_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/unsupported_arguments_unchanged.php.inc new file mode 100644 index 00000000..2a144859 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/unsupported_arguments_unchanged.php.inc @@ -0,0 +1,12 @@ +expectException(wrong: \RuntimeException::class); + $this->expectException(...$arguments); + $this->expectException(\RuntimeException::class, 'extra'); + $this->expectException(...); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 5fb153de..6bd52103 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -43,17 +43,24 @@ Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit- `#[\Override]` and only gains the `#[\Testo\Test]` marks, as do the non-abstract test methods of a trait. **Residual:** methods are NOT renamed β€” Testo discovers by attribute, so keeping `testFoo()` is harmless, and prefix cleanup / call-site rewriting is left manual. -- **ExpectExceptionToTestoRector** (registered) β€” now folds the fluent chain, not just the bare - head. It operates at the statements level (matches the enclosing `StmtsAwareInterface` node and - rewrites its `->stmts`): after a `$this->expectException($c)` statement it absorbs the - following sibling `expectExceptionMessage($m)` / - `expectExceptionMessageMatches($re)` / `expectExceptionCode($n)` statements into - `\Testo\Expect::exception($c)->withMessageContaining($m)->withMessageMatchingRegex($re)->withCode($n)` - and removes them. PHPUnit's `expectExceptionMessage()` matches a substring, hence - `withMessageContaining()` rather than the exact `withMessage()`. Conservative: only assignments that - cannot throw (`$message = '…';`) may stand between the calls, and the chain takes the place of the - last absorbed one after them; any other statement ends the run, - and a bare `expectExceptionMessage`/`Code` with no preceding `expectException` is left untouched. +- **ExpectExceptionToTestoRector** (registered) β€” folds sibling class/message/regex/code + expectations while preserving argument evaluation order and last-wins messages/codes. A single + provably nonempty message uses `withMessageContaining()`, and an empty literal uses `withMessage('')`. + Adjacent overwritten message literals without comments are removed. Retained repeated nonempty + messages use `withMessage($message, exact: false)`; a string variable whose emptiness is unknown + uses `withMessage($message, exact: $message === '')`. Other expressions use + `withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($expression))` + to evaluate and coerce the argument once before selecting the mode. The named `exact` key makes + older Assert versions fail explicitly instead of ignoring a positional flag. See the README's + generated-code compatibility requirements. Named source parameters are validated and emitted + positionally. Across assignments a fresh local holds the + handler, keeping registration and argument snapshots at the original statements. Other statements + end the run. Message and regex expectations retain independent fields, including dynamic and + repeated messages. Handler names are reserved across the containing function, including conditional + and later locals. Variable variables or locals introduced by `extract()` prevent introducing a + handler local. Assignments can throw, + so handler registration remains at its original statement. + A bare message/code with no preceding class expectation remains manual. - **GroupToTestoRector** (registered) β€” collapses every PHPUnit group source on a node β€” the `@group` docblock annotation(s) **and** the repeatable single-name `#[Group]` attribute(s) β€” into one variadic `#[\Testo\Filter\Group('a', 'b', …)]` (Testo's `Group` is variadic but not diff --git a/bridge/rector/src/Testing/Internal/RectorRunner.php b/bridge/rector/src/Testing/Internal/RectorRunner.php index f97fa263..3603e34d 100644 --- a/bridge/rector/src/Testing/Internal/RectorRunner.php +++ b/bridge/rector/src/Testing/Internal/RectorRunner.php @@ -92,6 +92,16 @@ public function assertConverts(Path $fixturePath): void $this->channel->debug("# Input:\n$input"); $this->channel->debug("# Expected:\n$expected"); + $changed = $this->convert($input, $fixturePath->name()); + + Assert::same($changed, $expected, \sprintf('Fixture "%s" was not converted as expected', $fixturePath->name())); + } + + /** + * Run the configured rules and return executable PHP for runtime regression tests. + */ + public function convert(string $input, string $name = 'input'): string + { $inputFile = $this->writeTempFile($input); try { @@ -121,7 +131,7 @@ public function assertConverts(Path $fixturePath): void throw new \RuntimeException(\sprintf( "Rector failed on fixture \"%s\":\n%s", - $fixturePath->name(), + $name, \implode("\n", \array_map(static fn(SystemError $error): string => $error->getMessage(), $errors)), ), previous: $crash); } @@ -131,7 +141,7 @@ public function assertConverts(Path $fixturePath): void @\unlink($inputFile); } - Assert::same($changed, $expected, \sprintf('Fixture "%s" was not converted as expected', $fixturePath->name())); + return $changed; } /** diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php index 1ac0a4d1..4f3b6f27 100644 --- a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php @@ -7,10 +7,12 @@ use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Expression; use PHPStan\Analyser\Scope; use Rector\NodeTypeResolver\Node\AttributeKey; @@ -113,16 +115,23 @@ public function refactor(Node $node): ?array $stmts = [$this->expectStmt('expectException', [$this->arg($headArg->value)])]; $emitted = []; + $hasMessage = false; foreach (\array_reverse($links) as $link) { $modifier = $this->getName($link->name); + if ($modifier === 'withMessage') { + if ($hasMessage) { + return null; + } + $hasMessage = true; + } $mapped = $modifier === null ? null : $this->mapModifier($modifier, $link->args); if ($mapped === null) { # Any unmapped modifier aborts the whole conversion β€” never half-convert. return null; } - # A repeated PHPUnit expectation overrides the earlier one, while Testo checks both. + # Independent Testo constraints must not collapse onto one replaceable PHPUnit field. \assert($mapped->expr instanceof MethodCall); $method = (string) $this->getName($mapped->expr->name); if (isset($emitted[$method])) { @@ -147,10 +156,31 @@ public function refactor(Node $node): ?array */ private function mapModifier(string $modifier, array $args): ?Expression { - $first = ($args[0] ?? null) instanceof Arg ? $args[0]->value : null; - if ($first === null) { + $argument = $args[0] ?? null; + if (!$argument instanceof Arg || $argument->unpack) { return null; } + $first = $argument->value; + + if ($modifier === 'withMessage' && \count($args) > 1) { + $exact = $args[1]; + if (\count($args) !== 2 || !$exact instanceof Arg || $exact->unpack + || ($argument->name !== null && !$this->isName($argument->name, 'message')) + || ($exact->name !== null && !$this->isName($exact->name, 'exact')) + || !$exact->value instanceof ConstFetch + || (!$this->isName($exact->value->name, 'true') && !$this->isName($exact->value->name, 'false')) + ) { + return null; + } + if ($this->isName($exact->value->name, 'false')) { + if (!$first instanceof String_) { + return null; + } + if ($first->value === '') { + return $this->expectStmt('expectExceptionMessageMatches', [$this->arg(new String_('~~'))]); + } + } + } return match ($modifier) { 'withMessage', 'withMessageContaining' => $this->expectStmt('expectExceptionMessage', [$this->arg($first)]), diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/dynamic_message_mode_unchanged.php.inc b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/dynamic_message_mode_unchanged.php.inc new file mode 100644 index 00000000..88c82eb5 --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/dynamic_message_mode_unchanged.php.inc @@ -0,0 +1,11 @@ +withMessage($message, exact: false); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('message', exact: $exact); + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...$arguments); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc new file mode 100644 index 00000000..d90d3636 --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc @@ -0,0 +1,23 @@ +withMessage('a')->withMessage('', exact: false); + throw new \RuntimeException('zzz'); + } + + public function exactMessageWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('', exact: false)->withMessage('a'); + throw new \RuntimeException('a'); + } + + public function emptyModeChanges(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('')->withMessage('', exact: false); + throw new \RuntimeException('zzz'); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/with_message_mode.php.inc b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/with_message_mode.php.inc new file mode 100644 index 00000000..ae814cf2 --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/with_message_mode.php.inc @@ -0,0 +1,31 @@ +withMessage('boom', exact: false); + } + + public function emptySubstring(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('', exact: false); + } +} +----- +expectException(\RuntimeException::class); + $this->expectExceptionMessage('boom'); + } + + public function emptySubstring(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('~~'); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/TODO.md b/bridge/rector/src/TestoToPhpunit/TODO.md index 53a085fc..99ccf079 100644 --- a/bridge/rector/src/TestoToPhpunit/TODO.md +++ b/bridge/rector/src/TestoToPhpunit/TODO.md @@ -44,6 +44,11 @@ directory but are **not** registered in `config/testo-to-phpunit.php`. `expectExceptionMessage` and so loosens into a substring check. A chain with an unmapped modifier (`fromMethod`/`withPrevious`/`withoutPrevious`) or with two modifiers landing on the same PHPUnit call (PHPUnit keeps only the last) aborts the whole conversion and is left untouched for manual review. + A literal `withMessage('value', exact: false)` maps to `expectExceptionMessage`; an empty substring + maps to the always-matching `expectExceptionMessageMatches('~~')`. Dynamic comparison modes or + unpacked arguments stay manual so their empty-string semantics and evaluations are not lost. + Repeated `withMessage()` calls also stay manual, even when their modes map to different PHPUnit + methods: they replace one Testo field rather than establishing independent constraints. - **`ExpectExceptionAttributeToPhpUnitRector`** (registered) β€” converts the attribute form `#[\Testo\Assert\ExpectException($class)]` into a `$this->expectException($class);` statement prepended to the method body (PHPUnit dropped its `ExpectException` attribute, leaving only the diff --git a/bridge/rector/tests/Feature/ExceptionExpectationRuntimeTest.php b/bridge/rector/tests/Feature/ExceptionExpectationRuntimeTest.php new file mode 100644 index 00000000..16655fc6 --- /dev/null +++ b/bridge/rector/tests/Feature/ExceptionExpectationRuntimeTest.php @@ -0,0 +1,111 @@ +convert($input); + + $this->assertResults($converted, [ + 'emptyMustFail' => [Status::Failed, 1], + 'emptyPasses' => [Status::Passed, 1], + 'lastMessageWins' => [Status::Passed, 1], + 'namedArguments' => [Status::Passed, 1], + 'literalRegexCharacters' => [Status::Passed, 1], + 'dynamicEmptyMustFail' => [Status::Failed, 1], + 'evaluationOrder' => [Status::Passed, 2], + 'overwrittenArgumentThrows' => [Status::Passed, 1], + 'independentPattern' => [Status::Passed, 1], + 'independentPatternMustFail' => [Status::Failed, 1], + 'assignmentKeepsSnapshots' => [Status::Passed, 1], + 'temporaryDoesNotClobberSource' => [Status::Passed, 2], + 'longRepeatedMessage' => [Status::Passed, 1], + 'substringMismatchMustFail' => [Status::Failed, 1], + 'lastEmptyWins' => [Status::Passed, 1], + 'lastNonemptyWins' => [Status::Passed, 1], + 'dynamicMessageAndPattern' => [Status::Passed, 1], + 'repeatedMessageAndPatternMustFail' => [Status::Failed, 1], + 'conditionalLocalSurvives' => [Status::Passed, 2], + 'laterLocalSurvives' => [Status::Passed, 2], + 'assignmentCanThrow' => [Status::Passed, 1], + 'weakFalseMustFail' => [Status::Failed, 1], + 'overwrittenMessageMustFail' => [Status::Failed, 1], + ]); + } + + public function strictMessageCoercionStillThrows(): void + { + $fixture = \dirname(__DIR__, 2) . '/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc'; + [$input] = \explode("-----\n", \str_replace("\r\n", "\n", (string) \file_get_contents($fixture)), 2); + $runner = new RectorRunner(new RecordingMessenger(), [ExpectExceptionToTestoRector::class]); + + $this->assertResults($runner->convert($input), ['invalidStringStillThrows' => [Status::Passed, 1]]); + } + + public function repeatedMessageModesKeepTheirVerdictsInReverse(): void + { + $fixture = \dirname(__DIR__, 2) . '/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc'; + $runner = new RectorRunner(new RecordingMessenger(), [ExpectExceptionToPhpUnitRector::class]); + + $this->assertResults($runner->convert((string) \file_get_contents($fixture)), [ + 'emptySubstringWins' => [Status::Passed, 1], + 'exactMessageWins' => [Status::Passed, 1], + 'emptyModeChanges' => [Status::Passed, 1], + ]); + } + + /** + * @param array $expected + */ + private function assertResults(string $converted, array $expected): void + { + $file = \sys_get_temp_dir() . '/testo-converted-' . \bin2hex(\random_bytes(8)) . '.php'; + $sink = \fopen('php://memory', 'w+b'); + try { + \file_put_contents($file, $converted); + $app = Application::createFromConfig(new ApplicationConfig( + suites: [new SuiteConfig('Converted', location: new FinderConfig(include: [$file]))], + )); + $app->getContainer()->set(new ConsoleStreams($sink, $sink), ConsoleStreams::class); + $result = $app->run(); + } finally { + \fclose($sink); + @\unlink($file); + } + + $actual = []; + foreach ($result as $suite) { + foreach ($suite as $case) { + foreach ($case as $test) { + $actual[$test->info->name] = [$test->status, $test->summary->metric('assertions')]; + } + } + } + \ksort($actual); + \ksort($expected); + Assert::same($actual, $expected); + } +} diff --git a/bridge/rector/tests/suites.php b/bridge/rector/tests/suites.php index 0b30e559..ea9343f4 100644 --- a/bridge/rector/tests/suites.php +++ b/bridge/rector/tests/suites.php @@ -13,6 +13,7 @@ * `#[TestRectorFixtures]` and runs their co-located `*.php.inc` fixtures as data sets. * - `Bridge/Rector/Unit` holds the plain unit tests for the harness itself (path resolution, * the containment guard, the attribute). + * - `Bridge/Rector/Feature` executes converted tests through the Testo pipeline. */ return [ new SuiteConfig( @@ -28,4 +29,8 @@ include: [__DIR__ . '/Unit'], ), ), + new SuiteConfig( + name: 'Bridge/Rector/Feature', + location: new FinderConfig(include: [__DIR__ . '/Feature']), + ), ]; diff --git a/plugin/assert/src/Api/ExpectedException.php b/plugin/assert/src/Api/ExpectedException.php index 1525f2b2..f46539cd 100644 --- a/plugin/assert/src/Api/ExpectedException.php +++ b/plugin/assert/src/Api/ExpectedException.php @@ -31,11 +31,12 @@ interface ExpectedException public function fromMethod(string $class, string $method): static; /** - * The expected exception should have the exact message. + * The expected exception should have the given message. * - * Subsequent calls replace the previous expectation. + * Exact by default; with `$exact = false`, the message must contain the given substring. + * Subsequent calls replace both the message and its comparison mode. */ - public function withMessage(string $message): static; + public function withMessage(string $message, bool $exact = true): static; /** * The expected exception message should match the given regular expression. diff --git a/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php b/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php index 82a600ee..81ca52d0 100644 --- a/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php +++ b/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php @@ -26,6 +26,7 @@ final class ExpectExceptionHandler implements ExpectedException private array $fromMethod = []; private ?string $expectedMessage = null; + private bool $expectedMessageExact = true; /** @var non-empty-string|null */ private ?string $expectedMessagePattern = null; @@ -110,12 +111,13 @@ public function fromMethod(string $class, string $method): static } /** - * The expected exception should have the exact message. + * The expected exception message should match exactly or contain the given substring. */ #[\Override] - public function withMessage(string $message): static + public function withMessage(string $message, bool $exact = true): static { $this->expectedMessage = $message; + $this->expectedMessageExact = $exact; return $this; } @@ -247,10 +249,14 @@ private function evaluate(?\Throwable $actual): Expectation|\Throwable # Message check if ($this->expectedMessage !== null) { - $actual->getMessage() === $this->expectedMessage - ? $composite->success('message is "' . $this->expectedMessage . '"') + $label = ($this->expectedMessageExact ? 'message is "' : 'message contains "') . $this->expectedMessage . '"'; + $matches = $this->expectedMessageExact + ? $actual->getMessage() === $this->expectedMessage + : \str_contains($actual->getMessage(), $this->expectedMessage); + $matches + ? $composite->success($label) : $composite->fail( - expectation: 'message is "' . $this->expectedMessage . '"', + expectation: $label, reason: 'got "' . $actual->getMessage() . '"', ); } diff --git a/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php b/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php index 79ab7712..2bea8f29 100644 --- a/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php +++ b/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php @@ -58,6 +58,24 @@ public function wrongMessage(): void ->contains('got "actual message"'); } + public function wrongSubstringMessage(): void + { + $result = TestRunner::runTest([ExpectExceptionNegative::class, 'wrongSubstringMessage']); + + Assert::same($result->status, Status::Failed); + Assert::instanceOf($result->failure, Expectation::class); + Assert::string($result->failure->getFailReason())->contains('message contains "needle"'); + } + + public function exactModeIsRestored(): void + { + $result = TestRunner::runTest([ExpectExceptionNegative::class, 'exactModeIsRestored']); + + Assert::same($result->status, Status::Failed); + Assert::instanceOf($result->failure, Expectation::class); + Assert::string($result->failure->getFailReason())->contains('message is "expected"'); + } + public function failureMessageCarriesTheReason(): void { $result = TestRunner::runTest([ExpectExceptionNegative::class, 'wrongMessage']); diff --git a/plugin/assert/tests/Self/ExpectExceptionTest.php b/plugin/assert/tests/Self/ExpectExceptionTest.php index 261350e0..c1be9b7c 100644 --- a/plugin/assert/tests/Self/ExpectExceptionTest.php +++ b/plugin/assert/tests/Self/ExpectExceptionTest.php @@ -157,6 +157,43 @@ public function messageContaining(): never throw new \RuntimeException('File config.yml not found'); } + #[Test] + public function substringMessageReplacesEarlierExactMessage(): never + { + Expect::exception(\RuntimeException::class) + ->withMessage('old') + ->withMessage('new', exact: false); + + throw new \RuntimeException('prefix new suffix'); + } + + #[Test] + public function exactMessageReplacesEarlierSubstring(): never + { + Expect::exception(\RuntimeException::class) + ->withMessage('old', exact: false) + ->withMessage('new'); + + throw new \RuntimeException('new'); + } + + #[Test] + public function emptySubstringAcceptsANonemptyMessage(): never + { + Expect::exception(\RuntimeException::class)->withMessage('', exact: false); + + throw new \RuntimeException('nonempty'); + } + + #[Test] + public function longSubstringMessage(): never + { + $message = \str_repeat('a', 100000); + Expect::exception(\RuntimeException::class)->withMessage($message, exact: false); + + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + #[Test] public function previous(): void { diff --git a/plugin/assert/tests/Stub/ExpectExceptionNegative.php b/plugin/assert/tests/Stub/ExpectExceptionNegative.php index 5d14d9ad..f2820e04 100644 --- a/plugin/assert/tests/Stub/ExpectExceptionNegative.php +++ b/plugin/assert/tests/Stub/ExpectExceptionNegative.php @@ -81,6 +81,24 @@ public function wrongMessageContaining(): never throw new \RuntimeException('haystack without the word'); } + #[Test] + public function wrongSubstringMessage(): never + { + Expect::exception(\RuntimeException::class)->withMessage('needle', exact: false); + + throw new \RuntimeException('haystack'); + } + + #[Test] + public function exactModeIsRestored(): never + { + Expect::exception(\RuntimeException::class) + ->withMessage('old', exact: false) + ->withMessage('expected'); + + throw new \RuntimeException('prefix expected'); + } + /** * Wrong message and wrong code at once. */ diff --git a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md index 25f43595..9584d25a 100644 --- a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md +++ b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md @@ -62,7 +62,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->assertIsResource($x)` / `assertIsClosedResource($x)` | `Assert::true(\str_starts_with(\gettype($x), 'resource'))` / `Assert::same(\gettype($x), 'resource (closed)')` β€” PHPUnit counts a closed resource, `is_resource()` does not. | | `$this->assertJson($s)` | `Assert::json($s)` (no `$message` arg). The JSON equality assertions have no counterpart: PHPUnit compares canonical re-encoded JSON (`1` β‰  `1.0`, `{}` β‰  `[]`), which `Assert::equals()` over decoded values does not. | | `$this->expectException(X::class)` before Act | `Expect::exception(X::class)->withCode(...)` before Act. Method return type becomes `never`. | -| `$this->expectExceptionMessage('...')` | `->withMessageContaining('...')` β€” PHPUnit matches a substring; `withMessage()` is an exact match and fails where PHPUnit passed. | +| `$this->expectExceptionMessage('...')` | A single provably nonempty message uses `->withMessageContaining(...)`; `''` uses `->withMessage('')`. Adjacent overwritten literals are removed. Retained nonempty messages use `withMessage($message, exact: false)`; a string variable uses `withMessage($message, exact: $message === '')`. Other expressions use `withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($expression))` to evaluate/coerce once. This requires the Assert API with the `exact` parameter; older versions reject the named flag explicitly. Substrings have no regex length limit; independent regex constraints stay separate. | | `$this->expectExceptionMessageMatches('/.../')` | `->withMessageMatchingRegex('/.../')`. | | `$this->markTestSkipped('reason')` | `#[Skip('reason')]` from **`Testo\Skip`** when the call opens the test unconditionally β€” the test then never enters the pipeline (no hooks, no provider, no retries). A guarded call, one deeper in the body, or a non-literal message stays runtime: `throw new \Testo\Core\Exception\SkipTest('reason')` from the test body. | | `$this->markTestIncomplete('reason')` | No "incomplete" status. Port to `throw new SkipTest('TODO: reason')`, or leave the body empty β†’ `Status::Risky`. | diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index e50170d3..5e24d061 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -121,6 +121,9 @@ public function rejectsNegativeAmount(): never ``` Other Expect modifiers: `withMessageContaining(...)`, `withMessageMatchingRegex(...)` (`withMessagePattern()` is its deprecated alias; the `testo-shift` Rector set of `testo/bridge-rector` renames it), `withPrevious(class, closure)`, memory-leak expectations. +`withMessage($message, exact: false)` checks a substring without regex limits; repeated calls replace +the message and comparison mode. `withMessage()` defaults back to exact matching. The separate +`withMessageContaining()` modifier accumulates substrings that must all match. Do **not** use try/catch-based assertions for expected exceptions β€” `Expect::exception` is the correct API. ## Marking a test as skipped or cancelled