From fac4c985d654a1ee2d0cca65e4c10895d65750c9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 11 Sep 2026 23:52:44 +0400 Subject: [PATCH 1/3] feat(bridge-rector): convert PHPUnit mocks onto the Double bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateMockToDoubleRector turns createMock/createStub and their expects()/method()/will*() chains into \JMac\Testing\Double calls, now that testo/bridge-double supplies the target API the stub rule was written without. The invocation matcher moves onto the verb (any→allows, else expects + times()/never()), the method name onto expects('m')/allows('m'), and the return verbs onto returns/throws/resolves. The chain is rebuilt at statement level, not per call: an unmappable link (willReturnMap, willReturnSelf, a variable matcher, with() constraint objects, getMockBuilder, prophesize) leaves the whole statement untouched instead of the inner expects()->method() being rewritten on its own. The MockToTestoRector stub, FEATURE_PARITY.md, TODO.md and the migrate-from-phpunit skill are narrowed to those residual manual forms. Assisted-By: Claude Opus 4.8 --- bridge/rector/FEATURE_PARITY.md | 8 +- bridge/rector/config/phpunit-to-testo.php | 5 + .../CreateMockToDoubleRector.php | 275 ++++++++++++++++++ .../create_mock.php.inc | 19 ++ .../create_stub.php.inc | 19 ++ .../expects_once_with_return.php.inc | 19 ++ .../inline_factory_chain.php.inc | 19 ++ .../legacy_will.php.inc | 21 ++ .../CreateMockToDoubleRector/matchers.php.inc | 29 ++ .../non_mock_chain_unchanged.php.inc | 10 + .../stub_method.php.inc | 25 ++ .../unsupported_left_unchanged.php.inc | 10 + .../src/PhpunitToTesto/MockToTestoRector.php | 28 +- bridge/rector/src/PhpunitToTesto/TODO.md | 20 +- .../references/migrate-with-rector.md | 2 +- .../references/phpunit-to-testo-map.md | 4 +- 16 files changed, 491 insertions(+), 22 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_mock.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_stub.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/expects_once_with_return.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/inline_factory_chain.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/matchers.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/stub_method.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index 9c938057..418ca1a2 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -28,7 +28,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **Data providers** (`#[DataProvider]`/`#[DataSet]` ↔ `->with`) | ✅ *`DataProviderToPhpUnitRector` renames `#[\Testo\Data\DataProvider]` → `#[DataProvider]` and `#[\Testo\Data\DataSet([…], 'label')]` → `#[TestWith([…], 'label')]` (both repeatable, args verbatim)* | ✅ *both `@dataProvider` annotation **and** `#[DataProvider]` attribute → `#[\Testo\Data\DataProvider]`; cross-class external form left as TODO* | 🟡 *inline `->with([ rows ])` → one repeated `#[\Testo\Data\DataSet]` per row; a named `->with('x')` / `dataset()` definition needs a provider — TODO* | | **Groups** (`#[Group]`) | ✅ *`GroupToPhpUnitRector` expands variadic → repeated `#[Group]`; `GroupInheritanceToPhpUnitRector` flattens both the class-level inheritance union (parents + traits) and the method-level prototype chain (a leaf method inherits the groups of the same-named parent-class method). Residual: traits are intentionally not consulted at method level — matches Testo, whose prototype walk skips them* | ✅ *`GroupToTestoRector` collapses `@group` annotations **and** repeated `#[Group]` into one variadic `#[\Testo\Filter\Group]`* | ✅ *`->group('a','b')` → `#[\Testo\Filter\Group('a','b')]`* | | **ExpectNoAssertions** (`#[\Testo\Assert\ExpectNoAssertions]` ↔ `#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]`) | ✅ *`ExpectNoAssertionsToPhpUnitRector` (attribute rename; both sides method/function-level only — no fan-out)* | ✅ *`DoesNotPerformAssertionsToTestoRector` (attribute rename)* | ➖ | -| **Mocks** (`createMock`/`getMockBuilder`/`prophesize`) | ➖ | ⛔ *Testo has no built-in mocking* | ➖ | +| **Mocks** (`createMock`/`createStub` + `expects`/`method`/`will*`) | ➖ | 🟡 *`CreateMockToDoubleRector` converts onto the Double bridge (`testo/bridge-double`): `createMock`/`createStub` → `Double::for`, and the configuration chain onto `expects`/`allows`/`with`/`returns`/`throws`/`resolves` — the invocation matcher moves onto the verb (`once`→`times(1)`, `exactly`→`times`, `never`→`never`, `atLeastOnce`/`atLeast`/`atMost`→`times(minimum:/maximum:)`, `any`→`allows`), and the method name off `->method()` onto `expects('m')`. All-or-nothing per chain: `willReturnMap`/`willReturnSelf`/`willReturnArgument`, a variable matcher, `getMockBuilder`, `prophesize`, and constraint args inside `with()` (`equalTo`/`anything`) have no faithful target and leave the statement untouched — see `MockToTestoRector` (stub) and TODO.md* | ➖ | | **Memory-leak expectations** | ⛔ *no PHPUnit equivalent* | ➖ | ➖ | | **Retry / Repeat** (`#[Retry]`/`#[Repeat]`) | 🟡 *`RepeatRetryRector` converts `#[\Testo\Repeat]`/`#[\Testo\Retry]` → PHPUnit `#[Repeat]`/`#[Retry]` (PHPUnit 13.3+): `maxFailures`→`failureThreshold` (+1), Testo defaults made explicit. PHPUnit's are `TARGET_METHOD` only, so a class-level Testo attribute is fanned out onto each test method (a method's own attribute overrides it, not doubled); `markFlaky` is dropped (no PHPUnit equivalent)* | 🟡 *`RepeatRetryToTestoRector` converts `#[Repeat]`/`#[Retry]` → Testo's attributes: `failureThreshold`→`maxFailures` (−1; the default 1 folds to Testo's default 0 and is omitted)* | ➖ | | **Fiber** (`#[RunInFiber]`, `Coroutine::spawn/await/concurrently`) | ⛔ *no PHPUnit/Pest equivalent — neither has a fiber/coroutine test attribute or an in-test coroutine scope* | ➖ | ➖ | @@ -101,9 +101,13 @@ name from the description (kept as the docblock) and folding the fluent modifier attributes / body statements. It bails (leaves the statement untouched) on a non-literal description, a `use (...)`-capturing closure, or any unrecognised modifier — see `src/PestToTesto/TODO.md`. -The remaining ⛔ rows are intentionally out of scope: a missing target feature (mocking, `arch()`, +The remaining ⛔ rows are intentionally out of scope: a missing target feature (`arch()`, memory-leak, PHPUnit `assertThat` constraints), the substring-vs-regex exception-message mismatch, or Pest `uses()` (a function has no base class / traits / `$this`). +Mocks moved off this list: with the Double bridge there is now a target API, so `createMock`/ +`createStub` and their `expects`/`method`/`will*` chains convert as a documented 🟡 +(`CreateMockToDoubleRector`); only the unmappable links (`willReturnMap`, `prophesize`, +`with()` constraint objects, …) stay manual. Retry/Repeat moved off this list: PHPUnit 13.3 added `#[Repeat]`/`#[Retry]`, so both directions now convert as a documented 🟡 (`RepeatRetryRector` / `RepeatRetryToTestoRector`). PHPUnit's `markTestIncomplete` moved off this list — it now converts to a Skipped throw with an diff --git a/bridge/rector/config/phpunit-to-testo.php b/bridge/rector/config/phpunit-to-testo.php index e20bfc27..af506ade 100644 --- a/bridge/rector/config/phpunit-to-testo.php +++ b/bridge/rector/config/phpunit-to-testo.php @@ -5,6 +5,7 @@ use Rector\Config\RectorConfig; use Testo\Bridge\Rector\PhpunitToTesto\AssertCallToTestoRector; use Testo\Bridge\Rector\PhpunitToTesto\CoversClassToCoversRector; +use Testo\Bridge\Rector\PhpunitToTesto\CreateMockToDoubleRector; use Testo\Bridge\Rector\PhpunitToTesto\DataProviderAnnotationToTestoRector; use Testo\Bridge\Rector\PhpunitToTesto\DataProviderAttributeToTestoRector; use Testo\Bridge\Rector\PhpunitToTesto\DoesNotPerformAssertionsToTestoRector; @@ -61,4 +62,8 @@ # Repeat/Retry method attributes (PHPUnit 13.3+) map onto Testo's #[Repeat]/#[Retry]. $rectorConfig->rule(RepeatRetryToTestoRector::class); + + # Mocks/stubs onto the Double bridge: createMock/createStub → Double::for, and the + # expects()/method()/will*() configuration chain onto expects/allows/with/returns/throws/resolves. + $rectorConfig->rule(CreateMockToDoubleRector::class); }; diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php new file mode 100644 index 00000000..e6649ee5 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php @@ -0,0 +1,275 @@ +createMock(Dependency::class); + * $dep->expects($this->once())->method('run')->with('x')->willReturn('y'); + * // becomes + * $dep = \JMac\Testing\Double::for(Dependency::class); + * $dep->expects('run')->times(1)->with('x')->returns('y'); + * + * Two transforms cooperate over Rector's fix-point passes: + * + * - `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`. + * - a configuration chain is rebuilt from its outermost call: PHPUnit's invocation matcher moves + * off `expects()` and onto the verb — `$this->any()` picks `allows()` (optional), every other + * matcher keeps `expects()` (required) and folds into a trailing `times()`/`never()`; the method + * name moves from `->method('m')` onto `expects('m')`/`allows('m')`; `->with()` is kept; and the + * return verbs map `willReturn`/`willReturnOnConsecutiveCalls` → `returns`, `willThrowException` + * → `throws`, `willReturnCallback` → `resolves`, plus the legacy `will($this->returnValue()/ + * throwException()/returnCallback())` wrappers onto the same three. + * + * Matcher map: `once` → `times(1)`, `exactly($n)` → `times($n)`, `never` → `never()`, + * `atLeastOnce` → `times(minimum: 1)`, `atLeast($n)` → `times(minimum: $n)`, + * `atMost($n)` → `times(maximum: $n)`, `any` → `allows()` (no count). + * + * Conservative by design: a chain is rewritten only when it carries a PHPUnit mock signal — an + * `expects()` with a recognised matcher, or one of the `will*` return verbs — so an unrelated + * fluent chain is left alone. Any unrecognised link (`willReturnMap`, `willReturnSelf`, + * `willReturnArgument`, a variable matcher, `getMockBuilder()`, `prophesize()`) aborts the whole + * chain rather than converting it in part; those stay for manual migration (see + * {@see MockToTestoRector} and TODO.md). Argument constraints inside `with()` (`$this->equalTo()`, + * `$this->anything()`, …) are passed through untouched — a literal expected value converts cleanly, + * a constraint object does not and is left for the author to map onto an `Argument::*` matcher. + */ +#[TestRectorFixtures('CreateMockToDoubleRector')] +final class CreateMockToDoubleRector extends AbstractRector +{ + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Convert PHPUnit `createMock()`/`createStub()` and their `expects()/method()/will*()` configuration chains into `\JMac\Testing\Double` calls', + [ + new CodeSample( + <<<'PHP' + $dep = $this->createMock(Dependency::class); + $dep->expects($this->once())->method('run')->with('x')->willReturn('y'); + PHP, + <<<'PHP' + $dep = \JMac\Testing\Double::for(Dependency::class); + $dep->expects('run')->times(1)->with('x')->returns('y'); + PHP, + ), + ], + ); + } + + /** + * @return array> + */ + #[\Override] + public function getNodeTypes(): array + { + return [Expression::class, MethodCall::class]; + } + + /** + * The chain rebuild runs at statement level and the mock-factory rewrite at call level, so the two + * never interfere: an unconvertible outer link (e.g. `willReturnSelf()`) leaves the whole statement + * alone instead of the inner `expects()->method()` being rewritten on its own by a call-level visit. + * + * @param Expression|MethodCall $node + */ + #[\Override] + public function refactor(Node $node): ?Node + { + if ($node instanceof MethodCall) { + return $this->matchMockFactory($node); + } + + if (!$node->expr instanceof MethodCall) { + return null; + } + + $rebuilt = $this->rebuildMockChain($node->expr); + if ($rebuilt === null) { + return null; + } + + $node->expr = $rebuilt; + + return $node; + } + + /** + * `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`. + */ + private function matchMockFactory(MethodCall $node): ?StaticCall + { + if (!$this->isName($node->var, 'this')) { + return null; + } + + if (!$this->isName($node->name, 'createMock') && !$this->isName($node->name, 'createStub')) { + return null; + } + + return $this->doubleFor($node->args); + } + + /** + * Rebuilds a configuration chain into its Double form, or returns null when the chain carries no + * PHPUnit mock signal or hits a link with no faithful counterpart. + * + * Called with the statement's whole expression, so the entire chain is converted in one shot. The + * result is idempotent: a second pass sees `expects('m')` (a string argument where a matcher used + * to be) and the Double return verbs, none of which re-trigger a rewrite. + */ + private function rebuildMockChain(MethodCall $node): ?MethodCall + { + $segments = []; + $cursor = $node; + while ($cursor instanceof MethodCall) { + $segments[] = $cursor; + $cursor = $cursor->var; + } + $segments = \array_reverse($segments); + + $result = $segments[0]->var; + $isMock = false; + $count = \count($segments); + + for ($i = 0; $i < $count; ++$i) { + $name = $this->segmentName($segments[$i]); + if ($name === null) { + return null; + } + + if ($name === 'expects') { + $matcher = $this->analyzeMatcher($segments[$i]->args[0] ?? null); + $methodSegment = $segments[$i + 1] ?? null; + if ($matcher === null || $methodSegment === null || $this->segmentName($methodSegment) !== 'method') { + return null; + } + + $result = new MethodCall($result, new Identifier($matcher['verb']), $methodSegment->args); + if ($matcher['call'] !== null) { + $result = new MethodCall($result, new Identifier($matcher['call'][0]), $matcher['call'][1]); + } + $isMock = true; + ++$i; + continue; + } + + $rewrite = $this->rewriteSegment($name, $segments[$i]); + if ($rewrite === null) { + return null; + } + + $result = new MethodCall($result, new Identifier($rewrite[0]), $rewrite[1]); + $isMock = $isMock || $rewrite[2]; + } + + return $isMock ? $result : null; + } + + /** + * Maps a single non-`expects` chain link to `[verb, args, isMockSignal]`, or null when the link + * has no faithful Double counterpart and the whole chain must be left alone. + * + * @return array{0: non-empty-string, 1: list, 2: bool}|null + */ + private function rewriteSegment(string $name, MethodCall $segment): ?array + { + return match ($name) { + # A bare stub method (`$stub->method('m')`), not yet a signal on its own — a following + # `will*` confirms it is a mock chain. + 'method' => ['allows', $segment->args, false], + 'with' => ['with', $segment->args, false], + 'willReturn', 'willReturnOnConsecutiveCalls' => ['returns', $segment->args, true], + 'willThrowException' => ['throws', $segment->args, true], + 'willReturnCallback' => ['resolves', $segment->args, true], + 'will' => $this->mapWill($segment->args[0] ?? null), + default => null, + }; + } + + /** + * Legacy `will($this->returnValue()/throwException()/returnCallback())` → the matching Double verb. + * + * @return array{0: non-empty-string, 1: list, 2: bool}|null + */ + private function mapWill(Arg|\PhpParser\Node\VariadicPlaceholder|null $arg): ?array + { + if (!$arg instanceof Arg) { + return null; + } + + $inner = $arg->value; + if (!$inner instanceof MethodCall && !$inner instanceof StaticCall) { + return null; + } + + return match (true) { + $this->isName($inner->name, 'returnValue') => ['returns', $inner->args, true], + $this->isName($inner->name, 'throwException') => ['throws', $inner->args, true], + $this->isName($inner->name, 'returnCallback') => ['resolves', $inner->args, true], + default => null, + }; + } + + /** + * Turns a PHPUnit invocation matcher (`$this->once()`, `self::exactly(2)`, …) into the verb the + * expectation should carry plus an optional trailing `times()`/`never()` call. Returns null for a + * variable or unrecognised matcher, aborting the conversion. + * + * @return array{verb: 'expects'|'allows', call: array{0: non-empty-string, 1: list}|null}|null + */ + private function analyzeMatcher(Arg|\PhpParser\Node\VariadicPlaceholder|null $arg): ?array + { + if (!$arg instanceof Arg) { + return null; + } + + $matcher = $arg->value; + if (!$matcher instanceof MethodCall && !$matcher instanceof StaticCall) { + return null; + } + + $argument = $matcher->args[0] ?? null; + $value = $argument instanceof Arg ? $argument->value : null; + + return match (true) { + $this->isName($matcher->name, 'any') => ['verb' => 'allows', 'call' => null], + $this->isName($matcher->name, 'once') => ['verb' => 'expects', 'call' => ['times', [new Arg(new Int_(1))]]], + $this->isName($matcher->name, 'never') => ['verb' => 'expects', 'call' => ['never', []]], + $this->isName($matcher->name, 'exactly') && $value !== null => ['verb' => 'expects', 'call' => ['times', [new Arg($value)]]], + $this->isName($matcher->name, 'atLeastOnce') => ['verb' => 'expects', 'call' => ['times', [new Arg(new Int_(1), name: new Identifier('minimum'))]]], + $this->isName($matcher->name, 'atLeast') && $value !== null => ['verb' => 'expects', 'call' => ['times', [new Arg($value, name: new Identifier('minimum'))]]], + $this->isName($matcher->name, 'atMost') && $value !== null => ['verb' => 'expects', 'call' => ['times', [new Arg($value, name: new Identifier('maximum'))]]], + default => null, + }; + } + + private function segmentName(MethodCall $segment): ?string + { + return $segment->name instanceof Identifier ? $segment->name->toString() : null; + } + + /** + * @param list $args + */ + private function doubleFor(array $args): StaticCall + { + return new StaticCall(new FullyQualified('JMac\\Testing\\Double'), new Identifier('for'), $args); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_mock.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_mock.php.inc new file mode 100644 index 00000000..d7262634 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_mock.php.inc @@ -0,0 +1,19 @@ +createMock(Dependency::class); + } +} +----- +createStub(Dependency::class); + } +} +----- +expects($this->once())->method('run')->with('x')->willReturn('y'); + } +} +----- +expects('run')->times(1)->with('x')->returns('y'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/inline_factory_chain.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/inline_factory_chain.php.inc new file mode 100644 index 00000000..c2d56440 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/inline_factory_chain.php.inc @@ -0,0 +1,19 @@ +createMock(Dependency::class)->method('run')->willReturn('y'); + } +} +----- +allows('run')->returns('y'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will.php.inc new file mode 100644 index 00000000..00ae83b0 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will.php.inc @@ -0,0 +1,21 @@ +expects($this->once())->method('run')->will($this->returnValue('y')); + $dep->method('boom')->will($this->throwException(new \RuntimeException())); + } +} +----- +expects('run')->times(1)->returns('y'); + $dep->allows('boom')->throws(new \RuntimeException()); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/matchers.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/matchers.php.inc new file mode 100644 index 00000000..ddbe89a7 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/matchers.php.inc @@ -0,0 +1,29 @@ +expects($this->never())->method('a'); + $dep->expects($this->exactly(3))->method('b')->willReturn('b'); + $dep->expects($this->any())->method('c')->willReturn('c'); + $dep->expects($this->atLeastOnce())->method('d')->willReturn('d'); + $dep->expects($this->atLeast(2))->method('e')->willReturn('e'); + $dep->expects($this->atMost(5))->method('f')->willReturn('f'); + } +} +----- +expects('a')->never(); + $dep->expects('b')->times(3)->returns('b'); + $dep->allows('c')->returns('c'); + $dep->expects('d')->times(minimum: 1)->returns('d'); + $dep->expects('e')->times(minimum: 2)->returns('e'); + $dep->expects('f')->times(maximum: 5)->returns('f'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc new file mode 100644 index 00000000..3aa9f481 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc @@ -0,0 +1,10 @@ +where('active', true)->orderBy('name')->get(); + $dep->method('run'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/stub_method.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/stub_method.php.inc new file mode 100644 index 00000000..23b54384 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/stub_method.php.inc @@ -0,0 +1,25 @@ +method('run')->willReturn('y'); + $dep->method('boom')->willThrowException(new \RuntimeException()); + $dep->method('lazy')->willReturnCallback(fn () => 1); + $dep->method('seq')->willReturnOnConsecutiveCalls(1, 2, 3); + } +} +----- +allows('run')->returns('y'); + $dep->allows('boom')->throws(new \RuntimeException()); + $dep->allows('lazy')->resolves(fn () => 1); + $dep->allows('seq')->returns(1, 2, 3); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc new file mode 100644 index 00000000..35309388 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc @@ -0,0 +1,10 @@ +method('run')->willReturnMap([['a', 1], ['b', 2]]); + $dep->expects($this->once())->method('self')->willReturnSelf(); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php index 96ad00df..e3c51179 100644 --- a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php @@ -12,32 +12,32 @@ /** * STUB — not implemented, not registered. * - * Intended target: PHPUnit/Prophecy mock creation — `createMock()`, - * `getMockBuilder()`, `createStub()`, `createMockForIntersectionOfInterfaces()`, - * `prophesize()`. + * The convertible mock forms now have a target API — the Double bridge (`testo/bridge-double`) — and + * are handled by the registered {@see CreateMockToDoubleRector}: `createMock()`/`createStub()` and + * their `expects()`/`method()`/`will*()` chains. This stub documents only what stays out of reach. * - * @todo Unconvertible automatically. Testo ships NO built-in mocking/doubling - * facility — there is no target API to rewrite these calls into. The expectation - * model (PHPUnit's `->expects()->method()->willReturn()`, Prophecy's promises and - * `reveal()`) has no Testo equivalent, so there is nothing to map onto. - * Migration must be done manually: introduce a standalone mocking library - * (e.g. Mockery, phpspec/prophecy used directly) or hand-write fakes/test doubles. - * This rule exists only to document the gap; it never modifies code. + * @todo No faithful automatic conversion for the residual forms: `getMockBuilder()->...->getMock()` + * and Prophecy's `prophesize()` (a different creation/expectation model), the return shapes + * `willReturnMap()`/`willReturnSelf()`/`willReturnArgument()`, a variable invocation matcher, and + * PHPUnit constraint objects passed to `with()` (`equalTo()`, `anything()`, composites — the same + * gap as `assertThat`). Migrate these by hand: the matching `\JMac\Testing\Double` / + * `Argument::*` form, a standalone mocking library (Mockery, phpspec/prophecy), or a hand-written + * fake. This rule exists only to document the gap; it never modifies code. */ final class MockToTestoRector extends AbstractRector { public function getRuleDefinition(): RuleDefinition { return new RuleDefinition( - 'STUB: PHPUnit/Prophecy mocks have no Testo equivalent — manual migration required (see @todo)', + 'STUB: mock forms with no faithful Double target (getMockBuilder/prophesize/willReturnMap/with-constraints) — manual migration required (see @todo)', [ new CodeSample( <<<'PHP' - $dep = $this->createMock(Dependency::class); + $dep = $this->getMockBuilder(Dependency::class)->getMock(); PHP, <<<'PHP' - // No Testo equivalent: replace with a manual fake or a third-party mocking library. - $dep = $this->createMock(Dependency::class); + // No faithful Double target: migrate by hand (see CreateMockToDoubleRector for the forms that do convert). + $dep = $this->getMockBuilder(Dependency::class)->getMock(); PHP, ), ], diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 573c9a51..86b00268 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -7,9 +7,12 @@ exist for each so the intent and blockers are discoverable in code. ## Stubbed (not registered) -- **MockToTestoRector** — `createMock`/`getMockBuilder`/`createStub`/`prophesize`: - Testo ships no built-in mocking, so there is no target API. Replace manually with a - third-party mocking library or hand-written fakes. +- **MockToTestoRector** — the mock forms `CreateMockToDoubleRector` (registered, see below) + cannot faithfully convert: `getMockBuilder()->...->getMock()` and `prophesize()` (a different + creation/expectation model), `willReturnMap`/`willReturnSelf`/`willReturnArgument`, a variable + invocation matcher, and constraint objects inside `with()` (`equalTo`/`anything`/composites — the + same gap as `AssertThatConstraintRector`). Replace manually with the matching Double form, a + third-party mocking library, or a hand-written fake. - **AssertThatConstraintRector** — `assertThat($v, $constraint)`: relies on PHPUnit constraint objects (and composites/callbacks) with no Testo equivalent. - **ExpectExceptionMessageMatchesRector** — regex message matching; Testo's @@ -17,6 +20,17 @@ exist for each so the intent and blockers are discoverable in code. ## Implemented since the first cut +- **CreateMockToDoubleRector** (registered) — converts PHPUnit mocks/stubs onto the Double bridge + (`testo/bridge-double`), which gives the previously-missing target API. `$this->createMock(X)` / + `$this->createStub(X)` → `\JMac\Testing\Double::for(X)`; the configuration chain is rebuilt at + statement level so it is never converted in part: the invocation matcher moves off `expects()` onto + the verb (`any`→`allows`, everything else keeps `expects` and folds into `times()`/`never()` — + `once`→`times(1)`, `exactly($n)`→`times($n)`, `atLeastOnce`→`times(minimum: 1)`, + `atLeast`/`atMost`→`times(minimum:/maximum:)`), the method name moves off `->method('m')` onto + `expects('m')`/`allows('m')`, and the returns map `willReturn`/`willReturnOnConsecutiveCalls`→`returns`, + `willThrowException`→`throws`, `willReturnCallback`→`resolves` (plus the legacy + `will($this->returnValue()/throwException()/returnCallback())` wrappers). A chain carrying an + unmappable link is left whole for manual work — see the `MockToTestoRector` stub above. - **MarkTestIncompleteRector** (registered) — Testo has no dedicated "incomplete" status, so `$this->markTestIncomplete($m)` (also `self::`/`static::`) maps to the nearest one: a `throw new \Testo\Core\Exception\SkipTest(...)` (Skipped). Both statuses neither pass nor fail and diff --git a/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md b/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md index c3e6b0d5..3dfa93bd 100644 --- a/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md +++ b/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md @@ -4,7 +4,7 @@ Use Rector (via `testo/bridge-rector`) to do the **mechanical** bulk of the conv whole scope in one deterministic pass, then finish with an AI/human **structural** pass. This is the recommended approach for any non-trivial suite: Rector rewrites hundreds of assert calls (with the correct argument-order swap), lifecycle methods, data providers and groups in seconds and never makes -a typo — but it cannot remove `extends TestCase` or convert mocks, so a finishing pass is mandatory. +a typo, and converts the common `createMock`/`createStub` mock chains onto the Double bridge — but the mock forms with no faithful Double target (`getMockBuilder`, `prophesize`, `willReturnMap`, `with()` constraint objects) are left for a finishing pass, so one is mandatory. Prerequisite: you have a **restore point** (skill Phase 1) and an agreed **scope** (skill Phase 2). All commands run from the project root. `` is the binary resolved in the skill (`php -r "echo PHP_BINARY;"`). 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 a3009148..400e723f 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 @@ -11,7 +11,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | Handled automatically by the `phpunit-to-testo` Rector set | Needs AI/human work (no faithful rule) | |---|---| -| assert calls (+ arg-order swap), bare `expectException`, `markTestSkipped`, `setUp`/`tearDown` → attributes, `@dataProvider`/`#[DataProvider]`, `@group`/`#[Group]`, `#[CoversClass]` → `#[Covers]`, `#[DoesNotPerformAssertions]` | **remove `extends TestCase` + reconcile discovery**, mocks, `assertThat` constraints, `expectExceptionMessageMatches` (regex), `markTestIncomplete`, fluent exception message/code folding | +| assert calls (+ arg-order swap), bare `expectException`, `markTestSkipped`, `setUp`/`tearDown` → attributes, `@dataProvider`/`#[DataProvider]`, `@group`/`#[Group]`, `#[CoversClass]` → `#[Covers]`, `#[DoesNotPerformAssertions]`, `createMock`/`createStub` + `expects`/`method`/`will*` → Double (`testo/bridge-double`) | **remove `extends TestCase` + reconcile discovery**, the mock forms with no Double target (`getMockBuilder`, `prophesize`, `willReturnMap`/`willReturnSelf`, `with()` constraint objects), `assertThat` constraints, `expectExceptionMessageMatches` (regex) | > The left column is mechanical; the right column is why **every** migration ends with an AI/human > pass — Rector alone leaves the test class still extending `TestCase`, so Testo will not discover it. @@ -44,7 +44,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->markTestSkipped('reason')` | `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`. | | `#[DoesNotPerformAssertions]` / `$this->expectNotToPerformAssertions()` | `#[ExpectNoAssertions]` from **`Testo\Assert`**, on a method or function (not a class) — no method-call form. Two-way contract: a marked test that *does* assert is `Status::Risky`. | -| `$this->createMock(Foo::class)` | Testo core ships no mocking. Bring your own (Mockery, Prophecy) or — preferred — a hand-rolled fake. Keeping Mockery? Add `testo/bridge-mockery`: it verifies expectations and isolates mocks after every test (drops the `tearDown()` / `MockeryPHPUnitIntegration` boilerplate) and counts a fulfilled expectation as an assertion, so a mock-only test stays out of `Status::Risky`. **Never** mock `final` classes or enums. | +| `$this->createMock(Foo::class)` | Testo core ships no mocking; the doubling library is `testo/bridge-double`. `$this->createMock`/`createStub` → `\JMac\Testing\Double::for(Foo::class)` and the `expects()->method()->willReturn()` chain → `expects('m')->times(1)->returns(...)` (the `CreateMockToDoubleRector` Rector rule does this automatically; `willReturnMap`/`willReturnSelf`, `getMockBuilder`, `prophesize` and `with()` constraint objects stay manual). Prefer keeping Mockery instead? Add `testo/bridge-mockery` — like the Double bridge, it verifies and isolates mocks after every test (drops the `tearDown()` / `MockeryPHPUnitIntegration` boilerplate) and counts a fulfilled expectation as an assertion, so a mock-only test stays out of `Status::Risky`. **Never** mock `final` classes or enums. | | `assertThat($v, $constraint)` | No constraint objects. Decompose into concrete `Assert::*` calls. | | `@group slow` / `#[Group('slow')]` | `#[Group('slow')]` from **`Testo\Filter\Group`**. Not repeatable — merge: `#[Group('slow','db')]`. Class-level groups are inherited (union with the method's). Select `--group=slow`, exclude `--group=!slow`. | | `#[Repeat($times, $threshold)]` (PHPUnit 13.3+) | `#[\Testo\Repeat(times: $times, maxFailures: $threshold - 1)]` — `failureThreshold` (aborting count) → `maxFailures` (tolerated count), off by one; default `1` → omitted. | From 9bb63bd354ae3f1e8a2cdf31b03f3f0f6288c824 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 12 Sep 2026 13:18:05 +0400 Subject: [PATCH 2/3] feat(bridge-rector): map mock with() constraints, intersections and willReturnArgument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends CreateMockToDoubleRector so a converted mock never leaves a raw PHPUnit constraint behind. `with()` argument constraints now map onto `Argument::*` (`anything`→`any`, `identicalTo`→`same`, `isInstanceOf`/`isType`→`type`, `callback`→`satisfies`, `contains`→`contains`, `matchesRegularExpression`→`matches`; `equalTo($x)` unwraps to bare `$x`); a constraint with no faithful matcher (`stringContains`, `greaterThan`, `logicalOr`) aborts the whole chain instead of emitting a `$this->…()` call that breaks once the test loses its TestCase base. Also adds `createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)` and `willReturnArgument($n)` → `resolves(fn (...$a) => $a[$n])`. Docs (FEATURE_PARITY, TODO, the MockToTestoRector stub, the migrate skill) narrowed to the remaining manual forms. Assisted-By: Claude Opus 4.8 --- bridge/rector/FEATURE_PARITY.md | 9 +- .../CreateMockToDoubleRector.php | 158 ++++++++++++++++-- .../intersection.php.inc | 19 +++ .../unsupported_left_unchanged.php.inc | 1 + .../willreturn_argument.php.inc | 19 +++ .../with_constraints.php.inc | 21 +++ .../src/PhpunitToTesto/MockToTestoRector.php | 12 +- bridge/rector/src/PhpunitToTesto/TODO.md | 32 ++-- .../references/migrate-with-rector.md | 2 +- .../references/phpunit-to-testo-map.md | 4 +- 10 files changed, 237 insertions(+), 40 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/intersection.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_argument.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_constraints.php.inc diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index 418ca1a2..28be55c1 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -28,7 +28,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **Data providers** (`#[DataProvider]`/`#[DataSet]` ↔ `->with`) | ✅ *`DataProviderToPhpUnitRector` renames `#[\Testo\Data\DataProvider]` → `#[DataProvider]` and `#[\Testo\Data\DataSet([…], 'label')]` → `#[TestWith([…], 'label')]` (both repeatable, args verbatim)* | ✅ *both `@dataProvider` annotation **and** `#[DataProvider]` attribute → `#[\Testo\Data\DataProvider]`; cross-class external form left as TODO* | 🟡 *inline `->with([ rows ])` → one repeated `#[\Testo\Data\DataSet]` per row; a named `->with('x')` / `dataset()` definition needs a provider — TODO* | | **Groups** (`#[Group]`) | ✅ *`GroupToPhpUnitRector` expands variadic → repeated `#[Group]`; `GroupInheritanceToPhpUnitRector` flattens both the class-level inheritance union (parents + traits) and the method-level prototype chain (a leaf method inherits the groups of the same-named parent-class method). Residual: traits are intentionally not consulted at method level — matches Testo, whose prototype walk skips them* | ✅ *`GroupToTestoRector` collapses `@group` annotations **and** repeated `#[Group]` into one variadic `#[\Testo\Filter\Group]`* | ✅ *`->group('a','b')` → `#[\Testo\Filter\Group('a','b')]`* | | **ExpectNoAssertions** (`#[\Testo\Assert\ExpectNoAssertions]` ↔ `#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]`) | ✅ *`ExpectNoAssertionsToPhpUnitRector` (attribute rename; both sides method/function-level only — no fan-out)* | ✅ *`DoesNotPerformAssertionsToTestoRector` (attribute rename)* | ➖ | -| **Mocks** (`createMock`/`createStub` + `expects`/`method`/`will*`) | ➖ | 🟡 *`CreateMockToDoubleRector` converts onto the Double bridge (`testo/bridge-double`): `createMock`/`createStub` → `Double::for`, and the configuration chain onto `expects`/`allows`/`with`/`returns`/`throws`/`resolves` — the invocation matcher moves onto the verb (`once`→`times(1)`, `exactly`→`times`, `never`→`never`, `atLeastOnce`/`atLeast`/`atMost`→`times(minimum:/maximum:)`, `any`→`allows`), and the method name off `->method()` onto `expects('m')`. All-or-nothing per chain: `willReturnMap`/`willReturnSelf`/`willReturnArgument`, a variable matcher, `getMockBuilder`, `prophesize`, and constraint args inside `with()` (`equalTo`/`anything`) have no faithful target and leave the statement untouched — see `MockToTestoRector` (stub) and TODO.md* | ➖ | +| **Mocks** (`createMock`/`createStub` + `expects`/`method`/`will*`/`with`) | ➖ | 🟡 *`CreateMockToDoubleRector` converts onto the Double bridge (`testo/bridge-double`): `createMock`/`createStub` → `Double::for`, `createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)`, and the configuration chain onto `expects`/`allows`/`with`/`returns`/`throws`/`resolves` — the invocation matcher moves onto the verb (`once`→`times(1)`, `exactly`→`times`, `never`→`never`, `atLeastOnce`/`atLeast`/`atMost`→`times(minimum:/maximum:)`, `any`→`allows`), the method name off `->method()` onto `expects('m')`, the returns (incl. `willReturnArgument($n)`→`resolves(fn (...$a) => $a[$n])` and legacy `will(...)`), and `with()` constraints onto `Argument::*` (`anything`→`any`, `identicalTo`→`same`, `isInstanceOf`/`isType`→`type`, `callback`→`satisfies`, `contains`→`contains`, `matchesRegularExpression`→`matches`; `equalTo($x)`→bare `$x`). All-or-nothing per chain: `willReturnMap`/`willReturnSelf`, a variable matcher, `getMockBuilder`, `prophesize`, and `with()` constraints with no `Argument` form (`stringContains`, `greaterThan`, `logicalOr`, …) leave the statement untouched — see `MockToTestoRector` (stub) and TODO.md* | ➖ | | **Memory-leak expectations** | ⛔ *no PHPUnit equivalent* | ➖ | ➖ | | **Retry / Repeat** (`#[Retry]`/`#[Repeat]`) | 🟡 *`RepeatRetryRector` converts `#[\Testo\Repeat]`/`#[\Testo\Retry]` → PHPUnit `#[Repeat]`/`#[Retry]` (PHPUnit 13.3+): `maxFailures`→`failureThreshold` (+1), Testo defaults made explicit. PHPUnit's are `TARGET_METHOD` only, so a class-level Testo attribute is fanned out onto each test method (a method's own attribute overrides it, not doubled); `markFlaky` is dropped (no PHPUnit equivalent)* | 🟡 *`RepeatRetryToTestoRector` converts `#[Repeat]`/`#[Retry]` → Testo's attributes: `failureThreshold`→`maxFailures` (−1; the default 1 folds to Testo's default 0 and is omitted)* | ➖ | | **Fiber** (`#[RunInFiber]`, `Coroutine::spawn/await/concurrently`) | ⛔ *no PHPUnit/Pest equivalent — neither has a fiber/coroutine test attribute or an in-test coroutine scope* | ➖ | ➖ | @@ -105,9 +105,10 @@ The remaining ⛔ rows are intentionally out of scope: a missing target feature memory-leak, PHPUnit `assertThat` constraints), the substring-vs-regex exception-message mismatch, or Pest `uses()` (a function has no base class / traits / `$this`). Mocks moved off this list: with the Double bridge there is now a target API, so `createMock`/ -`createStub` and their `expects`/`method`/`will*` chains convert as a documented 🟡 -(`CreateMockToDoubleRector`); only the unmappable links (`willReturnMap`, `prophesize`, -`with()` constraint objects, …) stay manual. +`createStub` (and intersection mocks), their `expects`/`method`/`will*` chains, and `with()` +constraints convert as a documented 🟡 (`CreateMockToDoubleRector`); only the unmappable links +(`willReturnMap`/`willReturnSelf`, `getMockBuilder`, `prophesize`, and `with()` constraints with no +`Argument` form) stay manual. Retry/Repeat moved off this list: PHPUnit 13.3 added `#[Repeat]`/`#[Retry]`, so both directions now convert as a documented 🟡 (`RepeatRetryRector` / `RepeatRetryToTestoRector`). PHPUnit's `markTestIncomplete` moved off this list — it now converts to a Skipped throw with an diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php index e6649ee5..b140af96 100644 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php @@ -6,10 +6,15 @@ use PhpParser\Node; use PhpParser\Node\Arg; +use PhpParser\Node\Expr\ArrayDimFetch; +use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Param; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt\Expression; use Rector\Rector\AbstractRector; @@ -29,14 +34,19 @@ * * Two transforms cooperate over Rector's fix-point passes: * - * - `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`. + * - `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`, and + * `createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)`. * - a configuration chain is rebuilt from its outermost call: PHPUnit's invocation matcher moves * off `expects()` and onto the verb — `$this->any()` picks `allows()` (optional), every other * matcher keeps `expects()` (required) and folds into a trailing `times()`/`never()`; the method - * name moves from `->method('m')` onto `expects('m')`/`allows('m')`; `->with()` is kept; and the - * return verbs map `willReturn`/`willReturnOnConsecutiveCalls` → `returns`, `willThrowException` - * → `throws`, `willReturnCallback` → `resolves`, plus the legacy `will($this->returnValue()/ - * throwException()/returnCallback())` wrappers onto the same three. + * name moves from `->method('m')` onto `expects('m')`/`allows('m')`; and the return verbs map + * `willReturn`/`willReturnOnConsecutiveCalls` → `returns`, `willThrowException` → `throws`, + * `willReturnCallback` → `resolves`, `willReturnArgument($n)` → `resolves(fn (...$a) => $a[$n])`, + * plus the legacy `will($this->returnValue()/throwException()/returnCallback())` wrappers. + * - `->with()` argument constraints become `Argument::*` matchers — `anything()` → `any()`, + * `identicalTo()` → `same()`, `isInstanceOf()`/`isType()` → `type()`, `callback()` → `satisfies()`, + * `contains()` → `contains()`, `matchesRegularExpression()` → `matches()`; `equalTo($x)` unwraps to + * the bare `$x` (Double matches by equality by default); a plain value passes through. * * Matcher map: `once` → `times(1)`, `exactly($n)` → `times($n)`, `never` → `never()`, * `atLeastOnce` → `times(minimum: 1)`, `atLeast($n)` → `times(minimum: $n)`, @@ -44,12 +54,11 @@ * * Conservative by design: a chain is rewritten only when it carries a PHPUnit mock signal — an * `expects()` with a recognised matcher, or one of the `will*` return verbs — so an unrelated - * fluent chain is left alone. Any unrecognised link (`willReturnMap`, `willReturnSelf`, - * `willReturnArgument`, a variable matcher, `getMockBuilder()`, `prophesize()`) aborts the whole - * chain rather than converting it in part; those stay for manual migration (see - * {@see MockToTestoRector} and TODO.md). Argument constraints inside `with()` (`$this->equalTo()`, - * `$this->anything()`, …) are passed through untouched — a literal expected value converts cleanly, - * a constraint object does not and is left for the author to map onto an `Argument::*` matcher. + * fluent chain is left alone. Any link with no faithful counterpart aborts the whole chain rather + * than converting it in part: `willReturnMap`/`willReturnSelf`, a variable matcher, `getMockBuilder()`, + * `prophesize()`, or a `with()` constraint that has no `Argument::*` form (`stringContains`, + * `greaterThan`, `logicalOr`, …) — leaving a raw `$this->…()` constraint would break once the test + * loses its TestCase base. Those stay for manual migration (see {@see MockToTestoRector} and TODO.md). */ #[TestRectorFixtures('CreateMockToDoubleRector')] final class CreateMockToDoubleRector extends AbstractRector @@ -111,7 +120,9 @@ public function refactor(Node $node): ?Node } /** - * `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`. + * `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`, and + * `$this->createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)` (an array literal + * only — a computed target list has nothing to unpack and is left alone). */ private function matchMockFactory(MethodCall $node): ?StaticCall { @@ -119,11 +130,37 @@ private function matchMockFactory(MethodCall $node): ?StaticCall return null; } - if (!$this->isName($node->name, 'createMock') && !$this->isName($node->name, 'createStub')) { + if ($this->isName($node->name, 'createMock') || $this->isName($node->name, 'createStub')) { + return $this->doubleFor($node->args); + } + + if ($this->isName($node->name, 'createMockForIntersectionOfInterfaces')) { + return $this->intersectionDouble($node->args); + } + + return null; + } + + /** + * @param list $args + */ + private function intersectionDouble(array $args): ?StaticCall + { + $first = $args[0] ?? null; + if (!$first instanceof Arg || !$first->value instanceof Array_) { return null; } - return $this->doubleFor($node->args); + $targets = []; + foreach ($first->value->items as $item) { + if ($item === null) { + return null; + } + + $targets[] = new Arg($item->value); + } + + return $targets === [] ? null : $this->doubleFor($targets); } /** @@ -194,15 +231,106 @@ private function rewriteSegment(string $name, MethodCall $segment): ?array # A bare stub method (`$stub->method('m')`), not yet a signal on its own — a following # `will*` confirms it is a mock chain. 'method' => ['allows', $segment->args, false], - 'with' => ['with', $segment->args, false], + 'with' => $this->mapWith($segment->args), 'willReturn', 'willReturnOnConsecutiveCalls' => ['returns', $segment->args, true], 'willThrowException' => ['throws', $segment->args, true], 'willReturnCallback' => ['resolves', $segment->args, true], + 'willReturnArgument' => $this->mapReturnArgument($segment->args), 'will' => $this->mapWill($segment->args[0] ?? null), default => null, }; } + /** + * Maps a `with()` call, translating PHPUnit argument constraints to `Argument::*` matchers. A plain + * value passes through; a `$this->`/`self::` call is treated as a constraint and mapped, or — when + * its name has no faithful matcher (`stringContains`, `greaterThan`, `logicalOr`, …) — aborts the + * whole chain, since leaving the raw constraint call would break once the test loses its TestCase base. + * + * @param list $args + * @return array{0: non-empty-string, 1: list, 2: bool}|null + */ + private function mapWith(array $args): ?array + { + $mapped = []; + foreach ($args as $arg) { + if (!$arg instanceof Arg) { + $mapped[] = $arg; + continue; + } + + $constraint = $this->mapConstraint($arg); + if ($constraint === null) { + return null; + } + + $mapped[] = $constraint; + } + + return ['with', $mapped, false]; + } + + /** + * A single `with()` argument: a PHPUnit constraint (`$this->equalTo()`, `$this->anything()`, …) + * mapped to its `Argument::*` form (or unwrapped for `equalTo`, whose value already matches by + * equality), a plain value returned unchanged, or null to abort when a `$this->`/`self::` call has + * no faithful matcher. + */ + private function mapConstraint(Arg $arg): ?Arg + { + $value = $arg->value; + $isConstraintCall = ($value instanceof MethodCall && $this->isName($value->var, 'this')) + || ($value instanceof StaticCall && ($this->isName($value->class, 'self') || $this->isName($value->class, 'static'))); + if (!$isConstraintCall) { + return $arg; + } + + \assert($value instanceof MethodCall || $value instanceof StaticCall); + $name = $value->name instanceof Identifier ? $value->name->toString() : null; + $inner = ($value->args[0] ?? null) instanceof Arg ? $value->args[0]->value : null; + + return match ($name) { + 'anything' => new Arg($this->argument('any')), + 'equalTo' => $inner !== null ? new Arg($inner) : null, + 'identicalTo' => $inner !== null ? new Arg($this->argument('same', [new Arg($inner)])) : null, + 'isInstanceOf', 'isType' => $inner !== null ? new Arg($this->argument('type', [new Arg($inner)])) : null, + 'callback' => $inner !== null ? new Arg($this->argument('satisfies', [new Arg($inner)])) : null, + 'contains' => $inner !== null ? new Arg($this->argument('contains', [new Arg($inner)])) : null, + 'matchesRegularExpression' => $inner !== null ? new Arg($this->argument('matches', [new Arg($inner)])) : null, + default => null, + }; + } + + /** + * `willReturnArgument($n)` → `resolves(fn (...$args) => $args[$n])`, so the Nth call argument is + * returned the same way PHPUnit echoes it back. + * + * @param list $args + * @return array{0: non-empty-string, 1: list, 2: bool}|null + */ + private function mapReturnArgument(array $args): ?array + { + $index = ($args[0] ?? null) instanceof Arg ? $args[0]->value : null; + if (!$index instanceof Node\Expr) { + return null; + } + + $resolver = new ArrowFunction([ + 'params' => [new Param(new Variable('args'), null, null, false, true)], + 'expr' => new ArrayDimFetch(new Variable('args'), $index), + ]); + + return ['resolves', [new Arg($resolver)], true]; + } + + /** + * @param list $args + */ + private function argument(string $method, array $args = []): StaticCall + { + return new StaticCall(new FullyQualified('JMac\\Testing\\Matching\\Argument'), new Identifier($method), $args); + } + /** * Legacy `will($this->returnValue()/throwException()/returnCallback())` → the matching Double verb. * diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/intersection.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/intersection.php.inc new file mode 100644 index 00000000..3f23b04d --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/intersection.php.inc @@ -0,0 +1,19 @@ +createMockForIntersectionOfInterfaces([A::class, B::class]); + } +} +----- +method('run')->willReturnMap([['a', 1], ['b', 2]]); $dep->expects($this->once())->method('self')->willReturnSelf(); + $dep->method('c')->with($this->greaterThan(5))->willReturn('u'); } } diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_argument.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_argument.php.inc new file mode 100644 index 00000000..a70ee4e5 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_argument.php.inc @@ -0,0 +1,19 @@ +method('args')->willReturnArgument(1); + } +} +----- +allows('args')->resolves(fn(...$args) => $args[1]); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_constraints.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_constraints.php.inc new file mode 100644 index 00000000..b74c035a --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_constraints.php.inc @@ -0,0 +1,21 @@ +expects($this->once())->method('run')->with($this->equalTo('a'), $this->anything(), $this->isInstanceOf(Foo::class))->willReturn('y'); + $dep->method('cb')->with($this->callback(fn ($x) => $x > 0), $this->identicalTo(3), $this->matchesRegularExpression('/x/'))->willReturn('z'); + } +} +----- +expects('run')->times(1)->with('a', \JMac\Testing\Matching\Argument::any(), \JMac\Testing\Matching\Argument::type(Foo::class))->returns('y'); + $dep->allows('cb')->with(\JMac\Testing\Matching\Argument::satisfies(fn ($x) => $x > 0), \JMac\Testing\Matching\Argument::same(3), \JMac\Testing\Matching\Argument::matches('/x/'))->returns('z'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php index e3c51179..ad07aa40 100644 --- a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php @@ -18,11 +18,13 @@ * * @todo No faithful automatic conversion for the residual forms: `getMockBuilder()->...->getMock()` * and Prophecy's `prophesize()` (a different creation/expectation model), the return shapes - * `willReturnMap()`/`willReturnSelf()`/`willReturnArgument()`, a variable invocation matcher, and - * PHPUnit constraint objects passed to `with()` (`equalTo()`, `anything()`, composites — the same - * gap as `assertThat`). Migrate these by hand: the matching `\JMac\Testing\Double` / - * `Argument::*` form, a standalone mocking library (Mockery, phpspec/prophecy), or a hand-written - * fake. This rule exists only to document the gap; it never modifies code. + * `willReturnMap()`/`willReturnSelf()`, a variable invocation matcher, and the `with()` constraints + * that have no `Argument::*` equivalent (`stringContains()` — substring, vs Double's iterable-only + * `contains`; `greaterThan()`/`lessThan()`, and `logicalOr()`/`logicalAnd()`/`logicalNot()` + * composites — the same gap as `assertThat`). Migrate these by hand: the matching + * `\JMac\Testing\Double` / `Argument::*` form, a standalone mocking library (Mockery, + * phpspec/prophecy), or a hand-written fake. This rule exists only to document the gap; it never + * modifies code. */ final class MockToTestoRector extends AbstractRector { diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 86b00268..c52335e3 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -9,10 +9,11 @@ exist for each so the intent and blockers are discoverable in code. - **MockToTestoRector** — the mock forms `CreateMockToDoubleRector` (registered, see below) cannot faithfully convert: `getMockBuilder()->...->getMock()` and `prophesize()` (a different - creation/expectation model), `willReturnMap`/`willReturnSelf`/`willReturnArgument`, a variable - invocation matcher, and constraint objects inside `with()` (`equalTo`/`anything`/composites — the - same gap as `AssertThatConstraintRector`). Replace manually with the matching Double form, a - third-party mocking library, or a hand-written fake. + creation/expectation model), `willReturnMap`/`willReturnSelf`, a variable invocation matcher, and + `with()` constraints with no `Argument::*` equivalent (`stringContains` — substring, whereas Double's + `contains` is iterable-only; `greaterThan`/`lessThan`, `logicalOr`/`logicalAnd`/`logicalNot` + composites — the same gap as `AssertThatConstraintRector`). Replace manually with the matching Double + form, a third-party mocking library, or a hand-written fake. - **AssertThatConstraintRector** — `assertThat($v, $constraint)`: relies on PHPUnit constraint objects (and composites/callbacks) with no Testo equivalent. - **ExpectExceptionMessageMatchesRector** — regex message matching; Testo's @@ -22,15 +23,20 @@ exist for each so the intent and blockers are discoverable in code. - **CreateMockToDoubleRector** (registered) — converts PHPUnit mocks/stubs onto the Double bridge (`testo/bridge-double`), which gives the previously-missing target API. `$this->createMock(X)` / - `$this->createStub(X)` → `\JMac\Testing\Double::for(X)`; the configuration chain is rebuilt at - statement level so it is never converted in part: the invocation matcher moves off `expects()` onto - the verb (`any`→`allows`, everything else keeps `expects` and folds into `times()`/`never()` — - `once`→`times(1)`, `exactly($n)`→`times($n)`, `atLeastOnce`→`times(minimum: 1)`, - `atLeast`/`atMost`→`times(minimum:/maximum:)`), the method name moves off `->method('m')` onto - `expects('m')`/`allows('m')`, and the returns map `willReturn`/`willReturnOnConsecutiveCalls`→`returns`, - `willThrowException`→`throws`, `willReturnCallback`→`resolves` (plus the legacy - `will($this->returnValue()/throwException()/returnCallback())` wrappers). A chain carrying an - unmappable link is left whole for manual work — see the `MockToTestoRector` stub above. + `$this->createStub(X)` → `\JMac\Testing\Double::for(X)`, `createMockForIntersectionOfInterfaces([A, B])` + → `Double::for(A, B)`; the configuration chain is rebuilt at statement level so it is never converted + in part: the invocation matcher moves off `expects()` onto the verb (`any`→`allows`, everything else + keeps `expects` and folds into `times()`/`never()` — `once`→`times(1)`, `exactly($n)`→`times($n)`, + `atLeastOnce`→`times(minimum: 1)`, `atLeast`/`atMost`→`times(minimum:/maximum:)`), the method name + moves off `->method('m')` onto `expects('m')`/`allows('m')`, the returns map + `willReturn`/`willReturnOnConsecutiveCalls`→`returns`, `willThrowException`→`throws`, + `willReturnCallback`→`resolves`, `willReturnArgument($n)`→`resolves(fn (...$a) => $a[$n])` (plus the + legacy `will($this->returnValue()/throwException()/returnCallback())` wrappers), and `with()` + constraints map onto `Argument::*` (`anything`→`any`, `identicalTo`→`same`, `isInstanceOf`/`isType` + →`type`, `callback`→`satisfies`, `contains`→`contains`, `matchesRegularExpression`→`matches`; + `equalTo($x)`→bare `$x`). A chain carrying an unmappable link — including a `with()` constraint with + no `Argument` form — is left whole for manual work, so a raw `$this->…()` constraint never survives + into a class that has lost its TestCase base. See the `MockToTestoRector` stub above. - **MarkTestIncompleteRector** (registered) — Testo has no dedicated "incomplete" status, so `$this->markTestIncomplete($m)` (also `self::`/`static::`) maps to the nearest one: a `throw new \Testo\Core\Exception\SkipTest(...)` (Skipped). Both statuses neither pass nor fail and diff --git a/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md b/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md index 3dfa93bd..21fbd152 100644 --- a/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md +++ b/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md @@ -4,7 +4,7 @@ Use Rector (via `testo/bridge-rector`) to do the **mechanical** bulk of the conv whole scope in one deterministic pass, then finish with an AI/human **structural** pass. This is the recommended approach for any non-trivial suite: Rector rewrites hundreds of assert calls (with the correct argument-order swap), lifecycle methods, data providers and groups in seconds and never makes -a typo, and converts the common `createMock`/`createStub` mock chains onto the Double bridge — but the mock forms with no faithful Double target (`getMockBuilder`, `prophesize`, `willReturnMap`, `with()` constraint objects) are left for a finishing pass, so one is mandatory. +a typo, and converts the common `createMock`/`createStub` mock chains (including `with()` constraints → `Argument::*`) onto the Double bridge — but the mock forms with no faithful Double target (`getMockBuilder`, `prophesize`, `willReturnMap`/`willReturnSelf`, unmappable constraints like `stringContains`) are left for a finishing pass, so one is mandatory. Prerequisite: you have a **restore point** (skill Phase 1) and an agreed **scope** (skill Phase 2). All commands run from the project root. `` is the binary resolved in the skill (`php -r "echo PHP_BINARY;"`). 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 400e723f..b8e1b9fa 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 @@ -11,7 +11,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | Handled automatically by the `phpunit-to-testo` Rector set | Needs AI/human work (no faithful rule) | |---|---| -| assert calls (+ arg-order swap), bare `expectException`, `markTestSkipped`, `setUp`/`tearDown` → attributes, `@dataProvider`/`#[DataProvider]`, `@group`/`#[Group]`, `#[CoversClass]` → `#[Covers]`, `#[DoesNotPerformAssertions]`, `createMock`/`createStub` + `expects`/`method`/`will*` → Double (`testo/bridge-double`) | **remove `extends TestCase` + reconcile discovery**, the mock forms with no Double target (`getMockBuilder`, `prophesize`, `willReturnMap`/`willReturnSelf`, `with()` constraint objects), `assertThat` constraints, `expectExceptionMessageMatches` (regex) | +| assert calls (+ arg-order swap), bare `expectException`, `markTestSkipped`, `setUp`/`tearDown` → attributes, `@dataProvider`/`#[DataProvider]`, `@group`/`#[Group]`, `#[CoversClass]` → `#[Covers]`, `#[DoesNotPerformAssertions]`, `createMock`/`createStub` (+ intersection) + `expects`/`method`/`will*`/`with` constraints → Double (`testo/bridge-double`) | **remove `extends TestCase` + reconcile discovery**, the mock forms with no Double target (`getMockBuilder`, `prophesize`, `willReturnMap`/`willReturnSelf`, unmappable `with()` constraints like `stringContains`/`greaterThan`), `assertThat` constraints, `expectExceptionMessageMatches` (regex) | > The left column is mechanical; the right column is why **every** migration ends with an AI/human > pass — Rector alone leaves the test class still extending `TestCase`, so Testo will not discover it. @@ -44,7 +44,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->markTestSkipped('reason')` | `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`. | | `#[DoesNotPerformAssertions]` / `$this->expectNotToPerformAssertions()` | `#[ExpectNoAssertions]` from **`Testo\Assert`**, on a method or function (not a class) — no method-call form. Two-way contract: a marked test that *does* assert is `Status::Risky`. | -| `$this->createMock(Foo::class)` | Testo core ships no mocking; the doubling library is `testo/bridge-double`. `$this->createMock`/`createStub` → `\JMac\Testing\Double::for(Foo::class)` and the `expects()->method()->willReturn()` chain → `expects('m')->times(1)->returns(...)` (the `CreateMockToDoubleRector` Rector rule does this automatically; `willReturnMap`/`willReturnSelf`, `getMockBuilder`, `prophesize` and `with()` constraint objects stay manual). Prefer keeping Mockery instead? Add `testo/bridge-mockery` — like the Double bridge, it verifies and isolates mocks after every test (drops the `tearDown()` / `MockeryPHPUnitIntegration` boilerplate) and counts a fulfilled expectation as an assertion, so a mock-only test stays out of `Status::Risky`. **Never** mock `final` classes or enums. | +| `$this->createMock(Foo::class)` | Testo core ships no mocking; the doubling library is `testo/bridge-double`. `$this->createMock`/`createStub` → `\JMac\Testing\Double::for(Foo::class)` and the `expects()->method()->willReturn()` chain → `expects('m')->times(1)->returns(...)`, with `with()` constraints mapped onto `Argument::*` (`anything`→`any`, `isInstanceOf`→`type`, `callback`→`satisfies`, …). The `CreateMockToDoubleRector` Rector rule does all this automatically; only `willReturnMap`/`willReturnSelf`, `getMockBuilder`, `prophesize` and `with()` constraints with no `Argument` form (`stringContains`, `greaterThan`, `logicalOr`) stay manual. Prefer keeping Mockery instead? Add `testo/bridge-mockery` — like the Double bridge, it verifies and isolates mocks after every test (drops the `tearDown()` / `MockeryPHPUnitIntegration` boilerplate) and counts a fulfilled expectation as an assertion, so a mock-only test stays out of `Status::Risky`. **Never** mock `final` classes or enums. | | `assertThat($v, $constraint)` | No constraint objects. Decompose into concrete `Assert::*` calls. | | `@group slow` / `#[Group('slow')]` | `#[Group('slow')]` from **`Testo\Filter\Group`**. Not repeatable — merge: `#[Group('slow','db')]`. Class-level groups are inherited (union with the method's). Select `--group=slow`, exclude `--group=!slow`. | | `#[Repeat($times, $threshold)]` (PHPUnit 13.3+) | `#[\Testo\Repeat(times: $times, maxFailures: $threshold - 1)]` — `failureThreshold` (aborting count) → `maxFailures` (tolerated count), off by one; default `1` → omitted. | From 8fc2f3071c2b3d4ac293031fb2b07d20b945a8ee Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 12 Sep 2026 13:40:11 +0400 Subject: [PATCH 3/3] feat(bridge-rector): convert getMockBuilder and willReturnSelf onto the Double bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends CreateMockToDoubleRector to the two remaining faithfully-convertible mock forms. `getMockBuilder(X)->disableOriginalConstructor()->getMock()` → `Double::for(X)`: `Double::for()` never runs the target's real constructor, so the constructor-disabling builder chain is exactly equivalent and `disableOriginalConstructor()` drops away. A bare `getMockBuilder(X)->getMock()` (which does call the real constructor) and any builder step that changes what is doubled (`onlyMethods`, `setConstructorArgs`, `getMockForAbstractClass`, …) are left untouched. `willReturnSelf()` → `returns()`: PHPUnit returns the mock object, Double returns whatever value it is handed, so the chain root is cloned into `returns()` to reproduce the fluent self-return. Only a local-variable or `$this->prop` root is rebuilt; anything else aborts the chain rather than aliasing a node. Docs (FEATURE_PARITY, TODO, the MockToTestoRector stub, the migrate skill) narrowed to the residual manual forms: `prophesize`, `willReturnMap`, a builder step beyond `disableOriginalConstructor`, and `with()` constraints with no `Argument` form. Assisted-By: Claude Opus 4.8 --- bridge/rector/FEATURE_PARITY.md | 9 +- .../CreateMockToDoubleRector.php | 102 ++++++++++++++++-- .../get_mock_builder.php.inc | 19 ++++ .../get_mock_builder_unsupported.php.inc | 10 ++ .../unsupported_left_unchanged.php.inc | 1 - .../willreturn_self.php.inc | 19 ++++ .../src/PhpunitToTesto/MockToTestoRector.php | 20 ++-- bridge/rector/src/PhpunitToTesto/TODO.md | 12 ++- .../references/migrate-with-rector.md | 2 +- .../references/phpunit-to-testo-map.md | 4 +- 10 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_self.php.inc diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index 28be55c1..f05b92c0 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -28,7 +28,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **Data providers** (`#[DataProvider]`/`#[DataSet]` ↔ `->with`) | ✅ *`DataProviderToPhpUnitRector` renames `#[\Testo\Data\DataProvider]` → `#[DataProvider]` and `#[\Testo\Data\DataSet([…], 'label')]` → `#[TestWith([…], 'label')]` (both repeatable, args verbatim)* | ✅ *both `@dataProvider` annotation **and** `#[DataProvider]` attribute → `#[\Testo\Data\DataProvider]`; cross-class external form left as TODO* | 🟡 *inline `->with([ rows ])` → one repeated `#[\Testo\Data\DataSet]` per row; a named `->with('x')` / `dataset()` definition needs a provider — TODO* | | **Groups** (`#[Group]`) | ✅ *`GroupToPhpUnitRector` expands variadic → repeated `#[Group]`; `GroupInheritanceToPhpUnitRector` flattens both the class-level inheritance union (parents + traits) and the method-level prototype chain (a leaf method inherits the groups of the same-named parent-class method). Residual: traits are intentionally not consulted at method level — matches Testo, whose prototype walk skips them* | ✅ *`GroupToTestoRector` collapses `@group` annotations **and** repeated `#[Group]` into one variadic `#[\Testo\Filter\Group]`* | ✅ *`->group('a','b')` → `#[\Testo\Filter\Group('a','b')]`* | | **ExpectNoAssertions** (`#[\Testo\Assert\ExpectNoAssertions]` ↔ `#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]`) | ✅ *`ExpectNoAssertionsToPhpUnitRector` (attribute rename; both sides method/function-level only — no fan-out)* | ✅ *`DoesNotPerformAssertionsToTestoRector` (attribute rename)* | ➖ | -| **Mocks** (`createMock`/`createStub` + `expects`/`method`/`will*`/`with`) | ➖ | 🟡 *`CreateMockToDoubleRector` converts onto the Double bridge (`testo/bridge-double`): `createMock`/`createStub` → `Double::for`, `createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)`, and the configuration chain onto `expects`/`allows`/`with`/`returns`/`throws`/`resolves` — the invocation matcher moves onto the verb (`once`→`times(1)`, `exactly`→`times`, `never`→`never`, `atLeastOnce`/`atLeast`/`atMost`→`times(minimum:/maximum:)`, `any`→`allows`), the method name off `->method()` onto `expects('m')`, the returns (incl. `willReturnArgument($n)`→`resolves(fn (...$a) => $a[$n])` and legacy `will(...)`), and `with()` constraints onto `Argument::*` (`anything`→`any`, `identicalTo`→`same`, `isInstanceOf`/`isType`→`type`, `callback`→`satisfies`, `contains`→`contains`, `matchesRegularExpression`→`matches`; `equalTo($x)`→bare `$x`). All-or-nothing per chain: `willReturnMap`/`willReturnSelf`, a variable matcher, `getMockBuilder`, `prophesize`, and `with()` constraints with no `Argument` form (`stringContains`, `greaterThan`, `logicalOr`, …) leave the statement untouched — see `MockToTestoRector` (stub) and TODO.md* | ➖ | +| **Mocks** (`createMock`/`createStub` + `expects`/`method`/`will*`/`with`) | ➖ | 🟡 *`CreateMockToDoubleRector` converts onto the Double bridge (`testo/bridge-double`): `createMock`/`createStub` → `Double::for`, `createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)`, and the configuration chain onto `expects`/`allows`/`with`/`returns`/`throws`/`resolves` — the invocation matcher moves onto the verb (`once`→`times(1)`, `exactly`→`times`, `never`→`never`, `atLeastOnce`/`atLeast`/`atMost`→`times(minimum:/maximum:)`, `any`→`allows`), the method name off `->method()` onto `expects('m')`, the returns (incl. `willReturnArgument($n)`→`resolves(fn (...$a) => $a[$n])`, `willReturnSelf()`→`returns()` and legacy `will(...)`), the builder chain `getMockBuilder(X)->disableOriginalConstructor()->getMock()`→`Double::for(X)`, and `with()` constraints onto `Argument::*` (`anything`→`any`, `identicalTo`→`same`, `isInstanceOf`/`isType`→`type`, `callback`→`satisfies`, `contains`→`contains`, `matchesRegularExpression`→`matches`; `equalTo($x)`→bare `$x`). All-or-nothing per chain: `willReturnMap`, a variable matcher, a builder step beyond `disableOriginalConstructor` (or the bare constructor-calling `getMockBuilder(X)->getMock()`), `prophesize`, and `with()` constraints with no `Argument` form (`stringContains`, `greaterThan`, `logicalOr`, …) leave the statement untouched — see `MockToTestoRector` (stub) and TODO.md* | ➖ | | **Memory-leak expectations** | ⛔ *no PHPUnit equivalent* | ➖ | ➖ | | **Retry / Repeat** (`#[Retry]`/`#[Repeat]`) | 🟡 *`RepeatRetryRector` converts `#[\Testo\Repeat]`/`#[\Testo\Retry]` → PHPUnit `#[Repeat]`/`#[Retry]` (PHPUnit 13.3+): `maxFailures`→`failureThreshold` (+1), Testo defaults made explicit. PHPUnit's are `TARGET_METHOD` only, so a class-level Testo attribute is fanned out onto each test method (a method's own attribute overrides it, not doubled); `markFlaky` is dropped (no PHPUnit equivalent)* | 🟡 *`RepeatRetryToTestoRector` converts `#[Repeat]`/`#[Retry]` → Testo's attributes: `failureThreshold`→`maxFailures` (−1; the default 1 folds to Testo's default 0 and is omitted)* | ➖ | | **Fiber** (`#[RunInFiber]`, `Coroutine::spawn/await/concurrently`) | ⛔ *no PHPUnit/Pest equivalent — neither has a fiber/coroutine test attribute or an in-test coroutine scope* | ➖ | ➖ | @@ -106,9 +106,10 @@ memory-leak, PHPUnit `assertThat` constraints), the substring-vs-regex exception-message mismatch, or Pest `uses()` (a function has no base class / traits / `$this`). Mocks moved off this list: with the Double bridge there is now a target API, so `createMock`/ `createStub` (and intersection mocks), their `expects`/`method`/`will*` chains, and `with()` -constraints convert as a documented 🟡 (`CreateMockToDoubleRector`); only the unmappable links -(`willReturnMap`/`willReturnSelf`, `getMockBuilder`, `prophesize`, and `with()` constraints with no -`Argument` form) stay manual. +constraints convert as a documented 🟡 (`CreateMockToDoubleRector`), including +`getMockBuilder(X)->disableOriginalConstructor()->getMock()` and `willReturnSelf()`; only the +unmappable links (`willReturnMap`, a builder step beyond `disableOriginalConstructor`, `prophesize`, +and `with()` constraints with no `Argument` form) stay manual. Retry/Repeat moved off this list: PHPUnit 13.3 added `#[Repeat]`/`#[Retry]`, so both directions now convert as a documented 🟡 (`RepeatRetryRector` / `RepeatRetryToTestoRector`). PHPUnit's `markTestIncomplete` moved off this list — it now converts to a Skipped throw with an diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php index b140af96..c880d6df 100644 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php @@ -34,15 +34,17 @@ * * Two transforms cooperate over Rector's fix-point passes: * - * - `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`, and - * `createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)`. + * - `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`, + * `createMockForIntersectionOfInterfaces([A, B])` → `Double::for(A, B)`, and the constructor-disabling + * builder chain `getMockBuilder(X)->disableOriginalConstructor()->getMock()` → `Double::for(X)`. * - a configuration chain is rebuilt from its outermost call: PHPUnit's invocation matcher moves * off `expects()` and onto the verb — `$this->any()` picks `allows()` (optional), every other * matcher keeps `expects()` (required) and folds into a trailing `times()`/`never()`; the method * name moves from `->method('m')` onto `expects('m')`/`allows('m')`; and the return verbs map * `willReturn`/`willReturnOnConsecutiveCalls` → `returns`, `willThrowException` → `throws`, * `willReturnCallback` → `resolves`, `willReturnArgument($n)` → `resolves(fn (...$a) => $a[$n])`, - * plus the legacy `will($this->returnValue()/throwException()/returnCallback())` wrappers. + * `willReturnSelf()` → `returns()`, plus the legacy + * `will($this->returnValue()/throwException()/returnCallback())` wrappers. * - `->with()` argument constraints become `Argument::*` matchers — `anything()` → `any()`, * `identicalTo()` → `same()`, `isInstanceOf()`/`isType()` → `type()`, `callback()` → `satisfies()`, * `contains()` → `contains()`, `matchesRegularExpression()` → `matches()`; `equalTo($x)` unwraps to @@ -55,10 +57,11 @@ * Conservative by design: a chain is rewritten only when it carries a PHPUnit mock signal — an * `expects()` with a recognised matcher, or one of the `will*` return verbs — so an unrelated * fluent chain is left alone. Any link with no faithful counterpart aborts the whole chain rather - * than converting it in part: `willReturnMap`/`willReturnSelf`, a variable matcher, `getMockBuilder()`, - * `prophesize()`, or a `with()` constraint that has no `Argument::*` form (`stringContains`, - * `greaterThan`, `logicalOr`, …) — leaving a raw `$this->…()` constraint would break once the test - * loses its TestCase base. Those stay for manual migration (see {@see MockToTestoRector} and TODO.md). + * than converting it in part: `willReturnMap`, a variable matcher, `prophesize()`, a builder step + * beyond `disableOriginalConstructor()` (or the bare constructor-calling `getMockBuilder(X)->getMock()`), + * or a `with()` constraint that has no `Argument::*` form (`stringContains`, `greaterThan`, + * `logicalOr`, …) — leaving a raw `$this->…()` constraint would break once the test loses its + * TestCase base. Those stay for manual migration (see {@see MockToTestoRector} and TODO.md). */ #[TestRectorFixtures('CreateMockToDoubleRector')] final class CreateMockToDoubleRector extends AbstractRector @@ -126,6 +129,12 @@ public function refactor(Node $node): ?Node */ private function matchMockFactory(MethodCall $node): ?StaticCall { + # A builder chain (`$this->getMockBuilder(X)->…->getMock()`) roots on the builder, not `$this`, + # so it is matched before the `$this->…` factory forms below. + if ($this->isName($node->name, 'getMock')) { + return $this->builderDouble($node); + } + if (!$this->isName($node->var, 'this')) { return null; } @@ -141,6 +150,43 @@ private function matchMockFactory(MethodCall $node): ?StaticCall return null; } + /** + * `$this->getMockBuilder(X)->disableOriginalConstructor()->getMock()` → `Double::for(X)`. + * + * Only the constructor-disabling builder chain converts. `Double::for()` never runs the target's + * real constructor (it instantiates without it), so `disableOriginalConstructor()` merely restates + * the Double default and drops away — while a *bare* `getMockBuilder(X)->getMock()` does call the + * real constructor, so it is deliberately left alone rather than silently changed. Any other builder + * step (`onlyMethods`, `setConstructorArgs`, `getMockForAbstractClass`, …) changes what is doubled + * and has no single-call Double form, so the whole chain is left for manual migration. + */ + private function builderDouble(MethodCall $getMock): ?StaticCall + { + if ($getMock->args !== []) { + return null; + } + + $sawDisableConstructor = false; + $cursor = $getMock->var; + while ($cursor instanceof MethodCall) { + $name = $this->segmentName($cursor); + + if ($name === 'disableOriginalConstructor' && $cursor->args === []) { + $sawDisableConstructor = true; + $cursor = $cursor->var; + continue; + } + + if ($name === 'getMockBuilder' && $this->isName($cursor->var, 'this')) { + return $sawDisableConstructor ? $this->doubleFor($cursor->args) : null; + } + + return null; + } + + return null; + } + /** * @param list $args */ @@ -181,7 +227,8 @@ private function rebuildMockChain(MethodCall $node): ?MethodCall } $segments = \array_reverse($segments); - $result = $segments[0]->var; + $root = $segments[0]->var; + $result = $root; $isMock = false; $count = \count($segments); @@ -191,6 +238,21 @@ private function rebuildMockChain(MethodCall $node): ?MethodCall return null; } + # `willReturnSelf()` → `returns()`: PHPUnit returns the mock object, Double + # returns whatever value it is handed, so handing it the chain root reproduces the fluent + # self-return. Needs the root expression, which only this scope has, so it is not folded + # into rewriteSegment(); an over-complex root that can't be safely cloned aborts the chain. + if ($name === 'willReturnSelf') { + $self = $this->cloneDoubleRoot($root); + if ($self === null) { + return null; + } + + $result = new MethodCall($result, new Identifier('returns'), [new Arg($self)]); + $isMock = true; + continue; + } + if ($name === 'expects') { $matcher = $this->analyzeMatcher($segments[$i]->args[0] ?? null); $methodSegment = $segments[$i + 1] ?? null; @@ -323,6 +385,30 @@ private function mapReturnArgument(array $args): ?array return ['resolves', [new Arg($resolver)], true]; } + /** + * A fresh copy of the double's root expression, for reuse as the `returns()` argument of a + * converted `willReturnSelf()`. Only the two shapes a mock is realistically held in — a local + * variable (`$mock`) and a `$this->mock` property — are rebuilt; anything else returns null so the + * chain is left for manual migration rather than aliasing a node into two positions of the tree. + */ + private function cloneDoubleRoot(Node\Expr $root): ?Node\Expr + { + if ($root instanceof Variable && \is_string($root->name)) { + return new Variable($root->name); + } + + if ( + $root instanceof Node\Expr\PropertyFetch + && $root->var instanceof Variable + && \is_string($root->var->name) + && $root->name instanceof Identifier + ) { + return new Node\Expr\PropertyFetch(new Variable($root->var->name), new Identifier($root->name->toString())); + } + + return null; + } + /** * @param list $args */ diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder.php.inc new file mode 100644 index 00000000..d9b8eadb --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder.php.inc @@ -0,0 +1,19 @@ +getMockBuilder(Dependency::class)->disableOriginalConstructor()->getMock(); + } +} +----- +getMockBuilder(Dependency::class)->getMock(); + $partial = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods(['run'])->getMock(); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc index 65c9fbd6..0cd42a1a 100644 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc @@ -5,7 +5,6 @@ class SomeTest public function test() { $dep->method('run')->willReturnMap([['a', 1], ['b', 2]]); - $dep->expects($this->once())->method('self')->willReturnSelf(); $dep->method('c')->with($this->greaterThan(5))->willReturn('u'); } } diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_self.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_self.php.inc new file mode 100644 index 00000000..621bbe74 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_self.php.inc @@ -0,0 +1,19 @@ +expects($this->once())->method('chain')->willReturnSelf(); + } +} +----- +expects('chain')->times(1)->returns($dep); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php index ad07aa40..3721b4fd 100644 --- a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php @@ -16,12 +16,14 @@ * are handled by the registered {@see CreateMockToDoubleRector}: `createMock()`/`createStub()` and * their `expects()`/`method()`/`will*()` chains. This stub documents only what stays out of reach. * - * @todo No faithful automatic conversion for the residual forms: `getMockBuilder()->...->getMock()` - * and Prophecy's `prophesize()` (a different creation/expectation model), the return shapes - * `willReturnMap()`/`willReturnSelf()`, a variable invocation matcher, and the `with()` constraints - * that have no `Argument::*` equivalent (`stringContains()` — substring, vs Double's iterable-only - * `contains`; `greaterThan()`/`lessThan()`, and `logicalOr()`/`logicalAnd()`/`logicalNot()` - * composites — the same gap as `assertThat`). Migrate these by hand: the matching + * @todo No faithful automatic conversion for the residual forms: Prophecy's `prophesize()` (a + * different creation/expectation model); a `getMockBuilder()` chain carrying a builder step beyond + * `disableOriginalConstructor()` (`onlyMethods`, `setConstructorArgs`, `getMockForAbstractClass`, …) + * or the bare constructor-calling `getMockBuilder(X)->getMock()`, all of which change what is + * doubled; the return shape `willReturnMap()`; a variable invocation matcher; and the `with()` + * constraints that have no `Argument::*` equivalent (`stringContains()` — substring, vs Double's + * iterable-only `contains`; `greaterThan()`/`lessThan()`, and `logicalOr()`/`logicalAnd()`/ + * `logicalNot()` composites — the same gap as `assertThat`). Migrate these by hand: the matching * `\JMac\Testing\Double` / `Argument::*` form, a standalone mocking library (Mockery, * phpspec/prophecy), or a hand-written fake. This rule exists only to document the gap; it never * modifies code. @@ -31,15 +33,15 @@ final class MockToTestoRector extends AbstractRector public function getRuleDefinition(): RuleDefinition { return new RuleDefinition( - 'STUB: mock forms with no faithful Double target (getMockBuilder/prophesize/willReturnMap/with-constraints) — manual migration required (see @todo)', + 'STUB: mock forms with no faithful Double target (prophesize/willReturnMap/builder-with-extra-steps/with-constraints) — manual migration required (see @todo)', [ new CodeSample( <<<'PHP' - $dep = $this->getMockBuilder(Dependency::class)->getMock(); + $dep = $this->getMockBuilder(Dependency::class)->onlyMethods(['run'])->getMock(); PHP, <<<'PHP' // No faithful Double target: migrate by hand (see CreateMockToDoubleRector for the forms that do convert). - $dep = $this->getMockBuilder(Dependency::class)->getMock(); + $dep = $this->getMockBuilder(Dependency::class)->onlyMethods(['run'])->getMock(); PHP, ), ], diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index c52335e3..37d57970 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -8,8 +8,10 @@ exist for each so the intent and blockers are discoverable in code. ## Stubbed (not registered) - **MockToTestoRector** — the mock forms `CreateMockToDoubleRector` (registered, see below) - cannot faithfully convert: `getMockBuilder()->...->getMock()` and `prophesize()` (a different - creation/expectation model), `willReturnMap`/`willReturnSelf`, a variable invocation matcher, and + cannot faithfully convert: `prophesize()` (a different creation/expectation model), a + `getMockBuilder()` chain with a builder step beyond `disableOriginalConstructor()` (`onlyMethods`, + `setConstructorArgs`, `getMockForAbstractClass`, …) or the bare constructor-calling + `getMockBuilder(X)->getMock()`, `willReturnMap`, a variable invocation matcher, and `with()` constraints with no `Argument::*` equivalent (`stringContains` — substring, whereas Double's `contains` is iterable-only; `greaterThan`/`lessThan`, `logicalOr`/`logicalAnd`/`logicalNot` composites — the same gap as `AssertThatConstraintRector`). Replace manually with the matching Double @@ -30,8 +32,10 @@ exist for each so the intent and blockers are discoverable in code. `atLeastOnce`→`times(minimum: 1)`, `atLeast`/`atMost`→`times(minimum:/maximum:)`), the method name moves off `->method('m')` onto `expects('m')`/`allows('m')`, the returns map `willReturn`/`willReturnOnConsecutiveCalls`→`returns`, `willThrowException`→`throws`, - `willReturnCallback`→`resolves`, `willReturnArgument($n)`→`resolves(fn (...$a) => $a[$n])` (plus the - legacy `will($this->returnValue()/throwException()/returnCallback())` wrappers), and `with()` + `willReturnCallback`→`resolves`, `willReturnArgument($n)`→`resolves(fn (...$a) => $a[$n])`, + `willReturnSelf()`→`returns()` (plus the + legacy `will($this->returnValue()/throwException()/returnCallback())` wrappers), the builder chain + `getMockBuilder(X)->disableOriginalConstructor()->getMock()`→`Double::for(X)`, and `with()` constraints map onto `Argument::*` (`anything`→`any`, `identicalTo`→`same`, `isInstanceOf`/`isType` →`type`, `callback`→`satisfies`, `contains`→`contains`, `matchesRegularExpression`→`matches`; `equalTo($x)`→bare `$x`). A chain carrying an unmappable link — including a `with()` constraint with diff --git a/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md b/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md index 21fbd152..375ef3ac 100644 --- a/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md +++ b/skills/testo-migrate-from-phpunit/references/migrate-with-rector.md @@ -4,7 +4,7 @@ Use Rector (via `testo/bridge-rector`) to do the **mechanical** bulk of the conv whole scope in one deterministic pass, then finish with an AI/human **structural** pass. This is the recommended approach for any non-trivial suite: Rector rewrites hundreds of assert calls (with the correct argument-order swap), lifecycle methods, data providers and groups in seconds and never makes -a typo, and converts the common `createMock`/`createStub` mock chains (including `with()` constraints → `Argument::*`) onto the Double bridge — but the mock forms with no faithful Double target (`getMockBuilder`, `prophesize`, `willReturnMap`/`willReturnSelf`, unmappable constraints like `stringContains`) are left for a finishing pass, so one is mandatory. +a typo, and converts the common `createMock`/`createStub` mock chains (including `with()` constraints → `Argument::*`, `willReturnSelf`, and `getMockBuilder(X)->disableOriginalConstructor()->getMock()`) onto the Double bridge — but the mock forms with no faithful Double target (`prophesize`, `willReturnMap`, a builder step beyond `disableOriginalConstructor`, unmappable constraints like `stringContains`) are left for a finishing pass, so one is mandatory. Prerequisite: you have a **restore point** (skill Phase 1) and an agreed **scope** (skill Phase 2). All commands run from the project root. `` is the binary resolved in the skill (`php -r "echo PHP_BINARY;"`). 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 b8e1b9fa..f6e13edc 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 @@ -11,7 +11,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | Handled automatically by the `phpunit-to-testo` Rector set | Needs AI/human work (no faithful rule) | |---|---| -| assert calls (+ arg-order swap), bare `expectException`, `markTestSkipped`, `setUp`/`tearDown` → attributes, `@dataProvider`/`#[DataProvider]`, `@group`/`#[Group]`, `#[CoversClass]` → `#[Covers]`, `#[DoesNotPerformAssertions]`, `createMock`/`createStub` (+ intersection) + `expects`/`method`/`will*`/`with` constraints → Double (`testo/bridge-double`) | **remove `extends TestCase` + reconcile discovery**, the mock forms with no Double target (`getMockBuilder`, `prophesize`, `willReturnMap`/`willReturnSelf`, unmappable `with()` constraints like `stringContains`/`greaterThan`), `assertThat` constraints, `expectExceptionMessageMatches` (regex) | +| assert calls (+ arg-order swap), bare `expectException`, `markTestSkipped`, `setUp`/`tearDown` → attributes, `@dataProvider`/`#[DataProvider]`, `@group`/`#[Group]`, `#[CoversClass]` → `#[Covers]`, `#[DoesNotPerformAssertions]`, `createMock`/`createStub` (+ intersection, `getMockBuilder(…)->disableOriginalConstructor()->getMock()`) + `expects`/`method`/`will*` (incl. `willReturnSelf`)/`with` constraints → Double (`testo/bridge-double`) | **remove `extends TestCase` + reconcile discovery**, the mock forms with no Double target (`prophesize`, `willReturnMap`, a `getMockBuilder` step beyond `disableOriginalConstructor`, unmappable `with()` constraints like `stringContains`/`greaterThan`), `assertThat` constraints, `expectExceptionMessageMatches` (regex) | > The left column is mechanical; the right column is why **every** migration ends with an AI/human > pass — Rector alone leaves the test class still extending `TestCase`, so Testo will not discover it. @@ -44,7 +44,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->markTestSkipped('reason')` | `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`. | | `#[DoesNotPerformAssertions]` / `$this->expectNotToPerformAssertions()` | `#[ExpectNoAssertions]` from **`Testo\Assert`**, on a method or function (not a class) — no method-call form. Two-way contract: a marked test that *does* assert is `Status::Risky`. | -| `$this->createMock(Foo::class)` | Testo core ships no mocking; the doubling library is `testo/bridge-double`. `$this->createMock`/`createStub` → `\JMac\Testing\Double::for(Foo::class)` and the `expects()->method()->willReturn()` chain → `expects('m')->times(1)->returns(...)`, with `with()` constraints mapped onto `Argument::*` (`anything`→`any`, `isInstanceOf`→`type`, `callback`→`satisfies`, …). The `CreateMockToDoubleRector` Rector rule does all this automatically; only `willReturnMap`/`willReturnSelf`, `getMockBuilder`, `prophesize` and `with()` constraints with no `Argument` form (`stringContains`, `greaterThan`, `logicalOr`) stay manual. Prefer keeping Mockery instead? Add `testo/bridge-mockery` — like the Double bridge, it verifies and isolates mocks after every test (drops the `tearDown()` / `MockeryPHPUnitIntegration` boilerplate) and counts a fulfilled expectation as an assertion, so a mock-only test stays out of `Status::Risky`. **Never** mock `final` classes or enums. | +| `$this->createMock(Foo::class)` | Testo core ships no mocking; the doubling library is `testo/bridge-double`. `$this->createMock`/`createStub` → `\JMac\Testing\Double::for(Foo::class)` and the `expects()->method()->willReturn()` chain → `expects('m')->times(1)->returns(...)`, with `with()` constraints mapped onto `Argument::*` (`anything`→`any`, `isInstanceOf`→`type`, `callback`→`satisfies`, …). The `CreateMockToDoubleRector` Rector rule does all this automatically (also `willReturnSelf` → `returns()` and `getMockBuilder(X)->disableOriginalConstructor()->getMock()` → `Double::for(X)`); only `willReturnMap`, `prophesize`, a `getMockBuilder` step beyond `disableOriginalConstructor`, and `with()` constraints with no `Argument` form (`stringContains`, `greaterThan`, `logicalOr`) stay manual. Prefer keeping Mockery instead? Add `testo/bridge-mockery` — like the Double bridge, it verifies and isolates mocks after every test (drops the `tearDown()` / `MockeryPHPUnitIntegration` boilerplate) and counts a fulfilled expectation as an assertion, so a mock-only test stays out of `Status::Risky`. **Never** mock `final` classes or enums. | | `assertThat($v, $constraint)` | No constraint objects. Decompose into concrete `Assert::*` calls. | | `@group slow` / `#[Group('slow')]` | `#[Group('slow')]` from **`Testo\Filter\Group`**. Not repeatable — merge: `#[Group('slow','db')]`. Class-level groups are inherited (union with the method's). Select `--group=slow`, exclude `--group=!slow`. | | `#[Repeat($times, $threshold)]` (PHPUnit 13.3+) | `#[\Testo\Repeat(times: $times, maxFailures: $threshold - 1)]` — `failureThreshold` (aborting count) → `maxFailures` (tolerated count), off by one; default `1` → omitted. |