diff --git a/bridge/double/composer.json b/bridge/double/composer.json index 1ed2c8aa..8e27d1d6 100644 --- a/bridge/double/composer.json +++ b/bridge/double/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.3", - "jasonmccreary/double": "^0.7", + "jasonmccreary/double": "^0.8", "testo/testo": "0.10.39 - 1" }, "require-dev": { diff --git a/bridge/double/tests/Acceptance/DoubleBridgeTest.php b/bridge/double/tests/Acceptance/DoubleBridgeTest.php index 1c9c24f9..d6f4dd3a 100644 --- a/bridge/double/tests/Acceptance/DoubleBridgeTest.php +++ b/bridge/double/tests/Acceptance/DoubleBridgeTest.php @@ -6,10 +6,14 @@ use JMac\Testing\Double; use JMac\Testing\DoubleInterface; +use JMac\Testing\Matching\Argument; use Testo\Assert; use Testo\Bridge\Double\DoublePlugin; use Testo\Codecov\CoversNothing; use Testo\Test; +use Tests\Bridge\Double\Fixture\Adder; +use Tests\Bridge\Double\Fixture\Greeter; +use Tests\Bridge\Double\Fixture\Permissions; /** * Acceptance tests for {@see DoublePlugin}. The suite registers the plugin @@ -50,4 +54,50 @@ public function spyRecordsCallsWithReceived(): void $spy->received('count')->times(1); } + + public function jointArgumentMatchingWithArgumentAll(): void + { + // Argument::all() weighs the whole argument list at once: the call matches + // only because 2 < 7. The plugin verifies the expectation on teardown. + /** @var DoubleInterface&Adder $double */ + $double = Double::for(Adder::class); + $double->expects('add')->with(Argument::all(fn(int $a, int $b): bool => $a < $b))->returns(9); + + Assert::same($double->add(2, 7), 9); + } + + public function overrideDoublesATargetWithAReservedNameCollision(): void + { + // Permissions::allows() collides with a Double control verb; override: true + // hands back an OverriddenDouble carrying the verbs, instance() the target-shaped double. + $permissions = Double::for(Permissions::class, override: true); + $permissions->expects('allows')->with('edit')->returns(true); + + Assert::true($permissions->instance()->allows('edit')); + } + + public function passthruSelfCallReachesAStub(): void + { + // greet()'s real body runs and its $this->normalize() self-call re-enters + // the double, so the stubbed normalize() answers instead of the real one. + /** @var DoubleInterface&Greeter $greeter */ + $greeter = Double::for(Greeter::class); + $greeter->passthru(); + $greeter->allows('normalize')->returns('WORLD'); + + Assert::same($greeter->greet('world'), 'Hello, WORLD'); + } + + public function aClonedDoubleSharesStateWithItsOriginal(): void + { + // The clone resolves to the same expectation state, so calling count() on it + // fulfills the expectation set on the original. + /** @var DoubleInterface&\Countable $double */ + $double = Double::for(\Countable::class); + $double->expects('count')->returns(3); + + $clone = clone $double; + + Assert::same($clone->count(), 3); + } } diff --git a/bridge/double/tests/Fixture/Adder.php b/bridge/double/tests/Fixture/Adder.php new file mode 100644 index 00000000..767bcc88 --- /dev/null +++ b/bridge/double/tests/Fixture/Adder.php @@ -0,0 +1,15 @@ +normalize($name); + } + + public function normalize(string $name): string + { + return $name; + } +} diff --git a/bridge/double/tests/Fixture/Permissions.php b/bridge/double/tests/Fixture/Permissions.php new file mode 100644 index 00000000..8539476f --- /dev/null +++ b/bridge/double/tests/Fixture/Permissions.php @@ -0,0 +1,14 @@ +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*`/`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* | ➖ | ➖ | @@ -101,9 +101,15 @@ 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 intersection mocks), their `expects`/`method`/`will*` chains, and `with()` +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/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..c880d6df --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php @@ -0,0 +1,489 @@ +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)`, + * `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])`, + * `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 + * 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)`, + * `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 link with no faithful counterpart aborts the whole chain rather + * 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 +{ + 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)`, 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 + { + # 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; + } + + 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; + } + + /** + * `$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 + */ + private function intersectionDouble(array $args): ?StaticCall + { + $first = $args[0] ?? null; + if (!$first instanceof Arg || !$first->value instanceof Array_) { + return null; + } + + $targets = []; + foreach ($first->value->items as $item) { + if ($item === null) { + return null; + } + + $targets[] = new Arg($item->value); + } + + return $targets === [] ? null : $this->doubleFor($targets); + } + + /** + * 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); + + $root = $segments[0]->var; + $result = $root; + $isMock = false; + $count = \count($segments); + + for ($i = 0; $i < $count; ++$i) { + $name = $this->segmentName($segments[$i]); + if ($name === null) { + 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; + 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' => $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]; + } + + /** + * 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 + */ + 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. + * + * @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/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/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/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]); + } +} +----- +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..0cd42a1a --- /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->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/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/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 96ad00df..3721b4fd 100644 --- a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php @@ -12,32 +12,36 @@ /** * 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: 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. */ 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 (prophesize/willReturnMap/builder-with-extra-steps/with-constraints) — manual migration required (see @todo)', [ new CodeSample( <<<'PHP' - $dep = $this->createMock(Dependency::class); + $dep = $this->getMockBuilder(Dependency::class)->onlyMethods(['run'])->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)->onlyMethods(['run'])->getMock(); PHP, ), ], diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 573c9a51..37d57970 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -7,9 +7,15 @@ 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: `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 + 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 +23,24 @@ 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)`, `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])`, + `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 + 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/README.md b/skills/README.md index 75d0d392..49b653ca 100644 --- a/skills/README.md +++ b/skills/README.md @@ -11,6 +11,7 @@ that an AI coding agent can load on demand. | [`testo-data-driven`](testo-data-driven/SKILL.md) | Parameterizing a test — `#[DataSet]`, `#[DataProvider]`, `#[DataZip]`, `#[DataCross]`. | | [`testo-flaky-tests`](testo-flaky-tests/SKILL.md) | Stabilizing flaky tests with `#[Retry]` or stress-testing with `#[Repeat]`. | | [`testo-async`](testo-async/SKILL.md) | Async tests — `#[RunInFiber]` (plain fibers, deterministic interleaving, `Coroutine::spawn`) and `#[RunInRevolt]` (real async I/O on the Revolt event loop, `testo/bridge-revolt`). | +| [`testo-test-doubles`](testo-test-doubles/SKILL.md) | Isolating a collaborator — choosing between dummy/stub/spy/mock/fake, then building it with Double (`testo/bridge-double`), Mockery (`testo/bridge-mockery`), or a hand-written fake class. | | [`testo-inline-tests`](testo-inline-tests/SKILL.md) | Attaching `#[TestInline]` examples directly to production methods. | | [`testo-benchmarks`](testo-benchmarks/SKILL.md) | Writing or tuning `#[Bench]` benchmarks. | | [`testo-coverage`](testo-coverage/SKILL.md) | Configuring `CodecovPlugin`, reports, and `#[Covers]`. | 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..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 — 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 (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 a3009148..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]` | **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` (+ 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. 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(...)`, 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. | diff --git a/skills/testo-test-doubles/SKILL.md b/skills/testo-test-doubles/SKILL.md new file mode 100644 index 00000000..6b3892c4 --- /dev/null +++ b/skills/testo-test-doubles/SKILL.md @@ -0,0 +1,104 @@ +--- +name: testo-test-doubles +description: 'Isolate a collaborator in a Testo test with a test double — pick the right kind (dummy, stub, spy, mock, fake, partial) and build it with Double (testo/bridge-double), Mockery (testo/bridge-mockery), or a hand-written fake class. Use when the user says "mock", "stub", "spy", "fake", "test double", "in-memory repository", "isolate the dependency", "createMock", "partial mock", "verify it was called", "should receive", or when a test needs a collaborator that touches I/O, time, randomness, or a third-party service.' +--- + +# Test doubles in Testo + +Testo core ships **no doubling facility**. A test isolates a collaborator through one of three routes, +each with its own reference next to this file: + +| Route | Reference | Reach for it when | +|---|---|---| +| **Double** (`testo/bridge-double`) | `references/double.md` | The project already uses Double, or is choosing a library fresh on PHP 8.3+. One API for stub/spy/mock/partial. | +| **Mockery** (`testo/bridge-mockery`) | `references/mockery.md` | The project already uses Mockery, or must stay on PHP 8.2. | +| **Hand-written** fake/stub/spy class | `references/handwritten.md` | The collaborator is a port you own, the double is reused across tests, it holds state, or no library is installed. Always available. | + +Fetch `https://php-testo.github.io/llms.txt` before writing tests. Run every command from the project root. + +## Vocabulary + +A **test double** is any stand-in for a real collaborator. Mocks are one kind, not a synonym. The ladder, +from least to most knowledge about the interaction: + +| Kind | Does | Verifies interaction? | Reach for it when | +|---|---|---|---| +| **Dummy** | Fills a parameter, is never called | No | The SUT needs *an* instance and nothing else | +| **Stub** | Returns canned answers | No — you assert on the SUT's result | You only need to feed a value in | +| **Spy** | Stub that records calls; you inspect them **after** the act | Yes, post hoc | "Was it called, with what?" matters and the test should read Arrange → Act → Assert | +| **Mock** | Expectations declared **before** the act, checked at teardown | Yes, up front | Call count or order *is* the contract | +| **Fake** | A working simplified implementation (in-memory repository, fixed clock) | No — behaves for real | The collaborator is stateful or used by many tests | + +Orthogonal axes, independent of the ladder: + +- **Loose vs strict** — a loose double answers unconfigured calls with a safe default; a strict one throws + on them. Default loose; go strict when an unexpected call is itself the bug you are hunting. +- **Full vs partial** — a partial replaces some methods and runs the real code for the rest. Rare and a + smell: it usually means the class under test is doing two jobs. +- **Verification point** — both bridges verify at teardown for every test and turn an unmet expectation + into `Status::Failed` (not `Aborted`). There is no `Mockery::close()` / `verify()` boilerplate to write. + +## Step 1 — Pick the kind + +Choose the lowest rung that expresses the contract: + +1. **Real object first.** Value objects, DTOs, enums, `final` classes, pure functions: instantiate them. + Never double these. +2. **Stub** when the test asserts on what the SUT returns or does with the value. +3. **Spy** when the test asserts on how the collaborator was used. Prefer it over a mock: the check sits + in the Assert phase where the reader expects it. +4. **Mock** only when the number or order of calls is the behaviour under test (`save` called exactly once, + `open` before `write`). +5. **Fake** when the collaborator is stateful, a port you own, or when three or more tests would otherwise + configure the same stub. Reference: `references/handwritten.md`. + +Double only *collaborators*, never the system under test. A test that doubles the class it is testing +tests the double. + +## Step 2 — Pick the tool + +Run the pre-flight. It reads only `composer.json`, `vendor/` and `testo.php`, so it needs no confirmation: + +```bash +php /scripts/precheck.php # add --root=PATH when not at the project root +``` + +`` is this skill's own directory (the folder holding `SKILL.md`). It prints, per library, whether +the library and its Testo bridge are installed and whether the plugin is registered in `testo.php`, then a +verdict: + +- **DOUBLE: READY** → follow `references/double.md`. +- **MOCKERY: READY** → follow `references/mockery.md`. +- Both READY → use the one the surrounding tests already use; for a fresh file prefer Double. +- Neither → hand-written (`references/handwritten.md`) is the default. Offer to install a bridge only when + the user asks for a mocking library or the test would need three or more behaviour-verifying doubles; the + install steps live in the matching reference. A project stays on **one** library — never add a second. + +A library installed **without its bridge plugin registered** is the trap the pre-flight exists for: +expectations then silently go unverified and a mock-only test comes out `Status::Risky`. Fix registration +before writing the test (steps in the reference). + +## Rules shared by every route + +- **Type the variable as an intersection** so static analysis sees both the real contract and the double's + verbs: `/** @var DoubleInterface&Repository $repo */` (Double) or `/** @var MockInterface&Repository $repo */` + (Mockery). +- **A double-only test still counts as asserting.** Both bridges mirror verified expectations into the + Assert history, so a test whose only checks are `expects()` / `shouldHaveReceived()` passes, not Risky. + A *hand-written* spy has no such hook: assert on its recorded calls with `Assert::*`. +- **Argument matching is strict.** Double compares scalars with `===`; Mockery's `with()` is loose (`==`) + but its object matching is by identity. When porting between the two, re-check every `with()`. +- **No static / magic-method doubling.** Neither library doubles static methods; Double allows only + `__invoke`, `__toString`, `__serialize`, `__unserialize`, `__clone`. Wrap the static call in an + instance you can double. +- **Fibers are safe.** Both bridges park their process-global state on every suspension, so doubles work + under `#[RunInFiber]` / `#[RunInRevolt]` (see `testo-async`). +- **Migrating from PHPUnit?** `createMock()` chains convert to Double mechanically via `testo/bridge-rector`; + the rest of the flow is in `testo-migrate-from-phpunit`. + +## Related skills + +- `testo-write-tests` — `#[Test]`, `Assert`, `Expect`, lifecycle hooks the double sits inside. +- `testo-configure` — where `plugins:` live in `testo.php` when registering a bridge. +- `testo-async` — fiber-driven tests that hold doubles across suspensions. +- `testo-migrate-from-phpunit` — Rector-assisted `createMock()` → `Double::for()` conversion. diff --git a/skills/testo-test-doubles/references/double.md b/skills/testo-test-doubles/references/double.md new file mode 100644 index 00000000..c4de7d62 --- /dev/null +++ b/skills/testo-test-doubles/references/double.md @@ -0,0 +1,198 @@ +# Doubles with Double (`testo/bridge-double`) + +Reference for the Double route of `testo-test-doubles`. [Double](https://github.com/jasonmccreary/double) +is one object type that acts as stub, spy, mock or partial depending on which verbs you call; the bridge +verifies every double at teardown. Full library docs: . + +## 1. Pre-flight + +Read the `DOUBLE` block of `scripts/precheck.php` (run from `SKILL.md` Step 2). It reports three facts: + +| Row | Meaning when `NO` | +|---|---| +| `jasonmccreary/double` | Library missing → §2 | +| `testo/bridge-double` | Bridge missing → §2. The library alone never verifies under Testo. | +| `DoublePlugin registered` | `testo.php` does not mention `DoublePlugin` → §2.2. Expectations go unverified; mock-only tests come out `Risky`. | + +Also check the PHP row: Double needs **PHP 8.3+**. On 8.2 this route is closed — use Mockery or +hand-written fakes. + +## 2. Install + +### 2.1 Packages + +```bash +composer require --dev testo/bridge-double +``` + +The bridge pulls `jasonmccreary/double` in; nothing else to require. + +### 2.2 Register the plugin + +Application-wide (every suite): + +```php +// testo.php +use Testo\Application\Config\ApplicationConfig; +use Testo\Application\Config\SuiteConfig; +use Testo\Bridge\Double\DoublePlugin; + +return new ApplicationConfig( + plugins: [new DoublePlugin()], + suites: [new SuiteConfig(name: 'Unit', location: ['tests/Unit'])], +); +``` + +Per suite, when only some suites use doubles: + +```php +use Testo\Application\Config\Plugin\SuitePlugins; + +new SuiteConfig( + name: 'Unit', + location: ['tests/Unit'], + plugins: SuitePlugins::with(new DoublePlugin()), +), +``` + +Once registered, `Double::verifyAll()` runs after every test: unmet `expects()` and deferred `received()` +checks fail the test, the pending set is cleared, and each resolved check is mirrored into the Assert +history. Re-run the pre-flight; the `DOUBLE` verdict must read `READY`. + +## 3. Usage + +`Double::for(Target::class)` returns a real instance of `Target` (passes `instanceof`) that also implements +`DoubleInterface`. Type it as the intersection so analysers see both: + +```php +use JMac\Testing\Double; +use JMac\Testing\DoubleInterface; + +/** @var DoubleInterface&BookRepository $repo */ +$repo = Double::for(BookRepository::class); +``` + +Two verbs configure calls. **`allows('m')`** — may be called any number of times, including zero (stub). +**`expects('m')`** — must be called, exactly once unless `times()` says otherwise (mock). Everything else +chains off them. + +### Dummy + +```php +$logger = Double::for(LoggerInterface::class); // loose by default: every call returns a safe default +$service = new Checkout($gateway, $logger); +``` + +### Stub + +```php +/** @var DoubleInterface&BookRepository $repo */ +$repo = Double::for(BookRepository::class); +$repo->allows('find')->with(123)->returns($book); +$repo->allows('find')->with(999)->throws(new NotFound()); +$repo->allows('next')->returns($first, $second); // consecutive calls; last value repeats +$repo->allows('price')->resolves(fn(int $id) => $id * 10); + +$service = new Catalog($repo); + +Assert::same($service->title(123), 'Dune'); +``` + +Argument matching for `with()` — literal scalars/arrays compare with `===`, objects with `==`, or use +`JMac\Testing\Matching\Argument`: + +| Matcher | Matches | +|---|---| +| `Argument::any()` / `Argument::any(1, 2)` | anything / one of the listed values | +| `Argument::none()` | a call with zero arguments | +| `Argument::type(Book::class)` / `type('int')` | by class or scalar type | +| `Argument::same($obj)` | identical instance | +| `Argument::matches('/^\d+$/')` | regex on a string or `Stringable` | +| `Argument::contains($needle)` | iterable holding the needle (needle may itself be a matcher or closure) | +| `Argument::satisfies(fn($v) => $v > 100)` | closure on one argument | +| `Argument::all(fn(...$args) => ...)` | closure on the whole argument list | +| `Argument::capture($var)` | anything; stores the actual value into `$var` by reference | +| `Argument::remaining()` | any trailing arguments (must be last) | +| `Argument::not(5)` / `Argument::not()->type('int')` | negation | + +### Spy + +Configure with `allows()`, act, then inspect with **`received()`**. The check runs when the statement +ends, so a `received()` line is itself the assertion: + +```php +/** @var DoubleInterface&Mailer $mailer */ +$mailer = Double::for(Mailer::class); +$service = new Signup($mailer); + +$service->register('alice@example.com'); + +$mailer->received('send')->with(Argument::type(WelcomeMail::class))->times(1); +$mailer->received('sendSms')->never(); +``` + +`$double->unused()` asserts no method was called on the double at all — the strongest spy check, useful +for "this branch must not touch the gateway". + +### Mock + +Declare with `expects()` before the act; the bridge verifies at teardown: + +```php +/** @var DoubleInterface&Connection $conn */ +$conn = Double::for(Connection::class); +$conn->expects('open')->ordered(); +$conn->expects('write')->with('payload')->ordered(); +$conn->expects('close')->ordered(); + +(new Exporter($conn))->run('payload'); +``` + +Counts: `times(3)` exact, `times(1, 3)` range, `times(minimum: 2)`, `times(maximum: 5)`, `never()`. +`ordered()` enforces sequence among the marked expectations of **one** double; unordered ones are +unaffected. + +### Strict double + +```php +$repo = Double::for(BookRepository::class)->strict(); // any unconfigured call throws immediately +``` + +### Partial (passthru) + +Unconfigured calls run the real code. Wrap an instance so its state is copied in: + +```php +/** @var DoubleInterface&PriceCalculator $calc */ +$calc = Double::for(new PriceCalculator($taxTable))->passthru(); +$calc->allows('now')->returns(new \DateTimeImmutable('2030-01-01')); +``` + +`passthru()` on an interface target needs a real instance: `Double::for(Iface::class)->passthru($real)`. +Internal `$this->now()` calls inside the real method do reach the stub. + +### Multiple interfaces + +```php +$logger = Double::for(LoggerInterface::class, FlushableInterface::class); // all but the first must be interfaces +``` + +## 4. Pitfalls + +- **`expects()` means exactly once.** For "at least once" write `expects('m')->times(minimum: 1)`; for + "any number, verify nothing" use `allows()`. +- **`final` targets** need `Double::bypassFinals()` in the bootstrap before the class is autoloaded. Prefer + extracting an interface; a `final` class is a design signal, not an obstacle. Enums cannot be doubled. +- **Reserved names.** `expects`, `allows`, `strict`, `passthru`, `received`, `unused`, `verify` on the + target collide with the configuration verbs. Pass `override: true` and hand `$double->instance()` to the + SUT: `$gate = Double::for(Authorizer::class, override: true); new Checker($gate->instance());`. +- **`received()` is deferred to the end of its statement** — never wrap it in a condition or store the + chain in a variable without completing it. +- **Comparison is `===`.** A stub configured `with(1)` does not match a call with `'1'`. When migrating + from Mockery (loose `==`) this surfaces hidden type bugs; fix the test's expected value, not the SUT. +- **No `byDefault()`, no global ordering, no aliases.** Each concept has one verb. Multiple `allows()` on + the same method with different `with()` coexist; the matching one answers. +- **Statics and magic methods** are not doubled (except `__invoke`, `__toString`, `__serialize`, + `__unserialize`, `__clone`). +- **Never call `Double::verifyAll()` or `->verify()` yourself** under the bridge — teardown does it, and a + manual call empties the pending set early. diff --git a/skills/testo-test-doubles/references/handwritten.md b/skills/testo-test-doubles/references/handwritten.md new file mode 100644 index 00000000..f6fe37b5 --- /dev/null +++ b/skills/testo-test-doubles/references/handwritten.md @@ -0,0 +1,182 @@ +# Hand-written fakes, stubs and spies + +Reference for the library-free route of `testo-test-doubles`. A hand-written double is an ordinary +`final class` implementing the collaborator's interface, checked in under `tests/`. It costs a file, and +buys a readable test, a reusable fixture, and zero dependencies. It is the right default for ports you own. + +## When to write one + +- The collaborator is an **interface in your codebase** (repository, clock, mailer, gateway port). +- The double **holds state** the test reads back (saved entities, sent messages). +- **Three or more tests** would configure the same stub — one fake replaces repeated `allows()` chains. +- **No mocking library** is installed and the test needs one or two simple doubles. + +Reach for a library instead when the target is a third-party interface with many methods you would have +to implement, or when call order is the contract (a hand-rolled ordered mock is more code than it is worth). + +## Kinds and naming + +Name states the kind, prefix first, then the contract it implements: + +| Kind | Prefix | Shape | +|---|---|---| +| Fake | `Fake`, `InMemory` | Working implementation with a simplified backend: `InMemoryUserRepository`, `FakeClock` | +| Stub | `Stub`, `Fixed` | Returns what the constructor was given: `FixedRateProvider`, `StubTokenGenerator` | +| Spy | `Spy`, `Recording` | Records every call into public typed lists: `SpyMailer`, `RecordingDispatcher` | +| Dummy | `Null` | Every method a no-op returning the type's neutral value: `NullLogger` | +| Throwing | `Throwing` | Every method throws; for "must not be reached" branches: `ThrowingGateway` | + +A fake that also records (an in-memory repository exposing `saved` calls) is still a `Fake`/`InMemory` +— the recording is a convenience, the behaviour is the point. + +## Where it lives + +- Directory: **`tests//Stub/`** next to the tests that use it (`tests/Unit/Stub/`, or per module + `tests/Billing/Stub/`). Namespace mirrors the path: `Tests\Unit\Stub\SpyMailer`. Testo's own suites use + exactly this layout (`tests/Application/Stub/SpyDispatcher.php`, `plugin/codecov/tests/Stub/SpyDriver.php`). +- Make sure `autoload-dev` maps the `Tests\` prefix onto `tests/`; a fake that is not autoloadable fails + with a class-not-found inside the test. +- Discovery is attribute-based, so a class under a suite's `location` without `#[Test]` is never run as a + test. Keep `#[Test]`, `#[Group]` and other test attributes off the fake. +- One fake per file, one collaborator per fake. A fake for a second interface is a second class, even + when the two are always used together. + +## Shape + +```php + */ + public array $sent = []; + + #[\Override] + public function send(Message $message): void + { + $this->sent[] = $message; + } + + /** + * @return list + */ + public function recipients(): array + { + return \array_map(static fn(Message $m): string => $m->to, $this->sent); + } +} +``` + +```php +final class InMemoryUserRepository implements UserRepository +{ + /** @var array */ + private array $users = []; + + public function __construct(User ...$seed) + { + foreach ($seed as $user) { + $this->users[$user->id] = $user; + } + } + + #[\Override] + public function find(int $id): ?User + { + return $this->users[$id] ?? null; + } + + #[\Override] + public function save(User $user): void + { + $this->users[$user->id] = $user; + } + + #[\Override] + public function findByEmail(string $email): ?User + { + foreach ($this->users as $user) { + if ($user->email === $email) { + return $user; + } + } + + return null; + } +} +``` + +Rules the two examples follow: + +- `final class`, **implements the interface** — never `extends` the production class. Extending drags real + behaviour into the test and breaks the moment the parent gains a constructor dependency. +- `#[\Override]` on every interface method, so a renamed interface method fails at load time rather than + silently leaving a dead method on the fake. +- **Canned data enters through the constructor**, with defaults so `new FakeX()` works bare. +- **Recorded calls are public, typed lists** (`/** @var list */ public array $sent`). A getter per + list adds nothing; a *derived* query (`recipients()`) that saves the test a `array_map` earns its place. +- **No logic the interface does not demand.** A fake repository stores and finds; it does not validate, + paginate or emit events unless the port's contract says so. +- **Methods the tests never exercise throw**, they do not return `null` silently: + + ```php + #[\Override] + public function stream(): iterable + { + throw new \LogicException(self::class . '::stream() is not expected in tests'); + } + ``` + + A silent `null` becomes a passing test that exercised nothing. + +## Using it in a test + +```php +#[Test] +#[Covers(Signup::class)] +final class SignupTest +{ + public function sendsWelcomeMailToNewUser(): void + { + $mailer = new SpyMailer(); + $users = new InMemoryUserRepository(); + $signup = new Signup($users, $mailer); + + $signup->register('alice@example.com'); + + Assert::same($mailer->recipients(), ['alice@example.com']); + Assert::instanceOf($users->findByEmail('alice@example.com'), User::class); + } +} +``` + +- `#[Covers]` names the **SUT**, never the fake. +- A hand-written spy records nothing into Testo's assertion history. Every check on it goes through + `Assert::*`, or the test is `Status::Risky` for having asserted nothing. +- Build fakes per test (inline or in `#[BeforeTest]`), not in `#[BeforeClass]`: shared mutable state + across tests is how order-dependent failures start. + +## What to cover — and when the fake needs its own test + +The fake must honour the **contract of the interface**, not the behaviour of the production adapter. A +fake clock returns the configured instant; it does not tick. A fake repository returns what was saved; it +does not enforce database constraints. + +Write a test **for the fake itself** only when it carries behaviour a test depends on: an in-memory +repository with filtering (`findByEmail`), a fake queue with ordering, a fake clock that advances. Place it +beside the fake's users (`tests/Unit/Stub/InMemoryUserRepositoryTest.php`) and keep it to the contract +methods. A pure recording spy or a fixed-value stub needs no test — its correctness is visible on the page. + +When several adapters implement the same port (real Doctrine repository, in-memory fake), a shared +**contract test** run against both is the strongest arrangement: one test class, one `#[DataProvider]` +yielding each implementation. See `testo-data-driven`. diff --git a/skills/testo-test-doubles/references/mockery.md b/skills/testo-test-doubles/references/mockery.md new file mode 100644 index 00000000..5ef73041 --- /dev/null +++ b/skills/testo-test-doubles/references/mockery.md @@ -0,0 +1,182 @@ +# Doubles with Mockery (`testo/bridge-mockery`) + +Reference for the Mockery route of `testo-test-doubles`. [Mockery](https://docs.mockery.io) is the +established PHP mocking library; the bridge calls `\Mockery::close()` in a `finally` after every test, so +expectations are verified and the container is reset without teardown code. + +## 1. Pre-flight + +Read the `MOCKERY` block of `scripts/precheck.php` (run from `SKILL.md` Step 2): + +| Row | Meaning when `NO` | +|---|---| +| `mockery/mockery` | Library missing → §2 | +| `testo/bridge-mockery` | Bridge missing → §2. Without it nothing calls `Mockery::close()`, so expectations never verify. | +| `MockeryPlugin registered` | `testo.php` does not mention `MockeryPlugin` → §2.2. Same effect: silent non-verification, mock-only tests `Risky`. | + +Mockery runs on Testo's PHP floor (8.2). + +## 2. Install + +### 2.1 Packages + +```bash +composer require --dev testo/bridge-mockery +``` + +The bridge pulls `mockery/mockery` in. Do **not** add `mockery/mockery`'s PHPUnit integration trait or a +`tearDown()` — Testo has neither. + +### 2.2 Register the plugin + +Application-wide: + +```php +// testo.php +use Testo\Application\Config\ApplicationConfig; +use Testo\Application\Config\SuiteConfig; +use Testo\Bridge\Mockery\MockeryPlugin; + +return new ApplicationConfig( + plugins: [new MockeryPlugin()], + suites: [new SuiteConfig(name: 'Unit', location: ['tests/Unit'])], +); +``` + +Per suite: `plugins: SuitePlugins::with(new MockeryPlugin())` on the `SuiteConfig` +(`Testo\Application\Config\Plugin\SuitePlugins`). + +Once registered, every test ends with `\Mockery::close()`: unmet expectations fail the test +(`Status::Failed`), verified ones are recorded as one fulfilled assertion, the container is cleared. +Re-run the pre-flight; the `MOCKERY` verdict must read `READY`. + +## 3. Usage + +Type the variable as the intersection of Mockery's interface and the real contract: + +```php +use Mockery\MockInterface; + +/** @var MockInterface&BookRepository $repo */ +$repo = \Mockery::mock(BookRepository::class); +``` + +Mockery 1.6 has two expectation verbs mirroring Double's: **`allows('m')`** (stub, any count) and +**`expects('m')`** (mock, once by default). `shouldReceive('m')` is the older spelling of `allows()` that +needs an explicit count to become a mock. Prefer `allows`/`expects` in new code. + +### Dummy + +```php +$logger = \Mockery::spy(LoggerInterface::class); // spy: every call returns null, nothing to configure +$service = new Checkout($gateway, $logger); +``` + +A plain `\Mockery::mock()` throws on any unconfigured call, so for a pure dummy use `spy()` or +`mock()->shouldIgnoreMissing()`. + +### Stub + +```php +/** @var MockInterface&BookRepository $repo */ +$repo = \Mockery::mock(BookRepository::class); +$repo->allows('find')->with(123)->andReturn($book); +$repo->allows('find')->with(999)->andThrow(new NotFound()); +$repo->allows('next')->andReturn($first, $second); // consecutive; last value repeats +$repo->allows('price')->andReturnUsing(fn(int $id) => $id * 10); +$repo->allows('self')->andReturnSelf(); +$repo->allows('echo')->andReturnArg(0); + +$service = new Catalog($repo); + +Assert::same($service->title(123), 'Dune'); +``` + +Argument matching for `with()` — literals compare loosely (`==`), objects by identity, or use matchers: + +| Matcher | Matches | +|---|---| +| `\Mockery::any()` | anything | +| `->withAnyArgs()` / `->withNoArgs()` | any argument list / an empty one | +| `\Mockery::type(Book::class)` / `type('int')` | by class or scalar type | +| `\Mockery::isSame($obj)` | identical instance | +| `\Mockery::pattern('/^\d+$/')` | regex | +| `\Mockery::on(fn($v) => $v > 100)` | closure on one argument | +| `->withArgs(fn(...$args) => ...)` | closure on the whole argument list | +| `\Mockery::capture($var)` | anything; stores the value into `$var` | +| `\Mockery::hasKey('id')` / `\Mockery::contains(1, 2)` / `\Mockery::subset([...])` | array shape checks | +| `\Mockery::not(5)` / `\Mockery::anyOf(1, 2)` | negation / alternatives | +| `->andAnyOtherArgs()` | trailing arguments (must be last) | + +### Spy + +```php +/** @var MockInterface&Mailer $mailer */ +$mailer = \Mockery::spy(Mailer::class); +$service = new Signup($mailer); + +$service->register('alice@example.com'); + +$mailer->shouldHaveReceived('send')->with(\Mockery::type(WelcomeMail::class))->once(); +$mailer->shouldNotHaveReceived('sendSms'); +``` + +A spy returns `null` from unconfigured calls; add `allows()->andReturn()` where the SUT needs a value. +`$spy->shouldNotHaveBeenCalled()` asserts the whole double was untouched. + +### Mock + +```php +/** @var MockInterface&Connection $conn */ +$conn = \Mockery::mock(Connection::class); +$conn->expects('open')->ordered(); +$conn->expects('write')->with('payload')->ordered(); +$conn->expects('close')->ordered(); + +(new Exporter($conn))->run('payload'); +``` + +Counts: `once()`, `twice()`, `times(3)`, `never()`, `atLeast()->once()`, `atMost()->times(5)`, +`between(1, 3)`, `zeroOrMoreTimes()`. `ordered()` sequences the marked expectations of one mock; +`ordered()->globally()` sequences across mocks. + +### Strict vs loose + +`\Mockery::mock()` is strict: an unconfigured call throws. `->shouldIgnoreMissing()` makes it loose +(returns `null`), `->shouldIgnoreMissing()->asUndefined()` returns a null object you can keep chaining on. +`byDefault()` marks an expectation as a fallback a later, more specific one may replace. + +### Partial + +```php +$calc = \Mockery::mock(PriceCalculator::class, [$taxTable])->makePartial(); // ctor args, real code for the rest +$calc->allows('now')->andReturn(new \DateTimeImmutable('2030-01-01')); + +$calc = \Mockery::mock(new PriceCalculator($taxTable)); // proxied partial: wraps an instance, works for final classes +$calc = \Mockery::mock('PriceCalculator[now]'); // generated partial: only `now` is mockable +``` + +`passthru()` on an expectation runs the real method while still counting the call. + +### Multiple interfaces + +```php +$logger = \Mockery::mock(LoggerInterface::class, FlushableInterface::class); +``` + +## 4. Pitfalls + +- **No `Mockery::close()`, no `tearDown()`, no `MockeryPHPUnitIntegration`.** The plugin does all of it. + A manual `close()` inside the test verifies early and the teardown then sees an empty container. +- **`shouldReceive()` without a count is a stub, not a mock.** It never fails on zero calls. Use + `expects()` or add `once()`. +- **`alias:` and `overload:` mocks** replace a class for the whole process and Mockery requires process + isolation for them. Testo runs tests in one process — avoid them; wrap the static dependency instead. +- **`final` classes** can only be doubled as a proxied partial (`\Mockery::mock(new Foo)`), which does not + pass `instanceof Foo` type checks on the SUT's parameter. Extract an interface. Enums cannot be doubled. +- **Loose comparison.** `with(1)` matches a call with `'1'`. Use `\Mockery::isSame()` or a typed matcher + when the type matters. +- **Spies return `null`.** A SUT that needs a value from the spied collaborator gets a `TypeError` unless + you `allows()->andReturn()` that method. +- **Expectations reset per test.** Doubles built in `#[BeforeTest]` live for one test; doubles built in + `#[BeforeClass]` are cleared by the first test's teardown and misbehave afterwards — build them per test. diff --git a/skills/testo-test-doubles/scripts/precheck.php b/skills/testo-test-doubles/scripts/precheck.php new file mode 100644 index 00000000..9fa70a22 --- /dev/null +++ b/skills/testo-test-doubles/scripts/precheck.php @@ -0,0 +1,131 @@ + $root . '/' . \implode('/', $p); +$exists = static fn(string ...$p): bool => \file_exists($root . '/' . \implode('/', $p)); + +if (!$exists('composer.json')) { + \fwrite(\STDERR, "No composer.json under {$root} — not a Composer project. Pass --root=PATH.\n"); + exit(2); +} + +// --- Packages ------------------------------------------------------------------------------------ + +$composer = \json_decode((string) \file_get_contents($path('composer.json')), true) ?: []; +$declared = \array_merge($composer['require'] ?? [], $composer['require-dev'] ?? []); + +$testoInstalled = $exists('vendor', 'testo', 'testo') || $exists('vendor', 'bin', 'testo') || $exists('vendor', 'bin', 'testo.bat'); + +// Plugin registration is read from the config source, not resolved at runtime: loading testo.php would +// execute project code. Any of the usual config file names counts. +$configSource = ''; +foreach (['testo.php', 'testo.php.dist', 'testo.dist.php'] as $candidate) { + if ($exists($candidate)) { + $configSource .= (string) \file_get_contents($path($candidate)); + } +} +$configFound = $configSource !== ''; + +$libraries = [ + 'DOUBLE' => [ + 'library' => 'jasonmccreary/double', + 'bridge' => 'testo/bridge-double', + 'plugin' => 'DoublePlugin', + 'reference' => 'references/double.md', + 'phpMin' => 80300, + ], + 'MOCKERY' => [ + 'library' => 'mockery/mockery', + 'bridge' => 'testo/bridge-mockery', + 'plugin' => 'MockeryPlugin', + 'reference' => 'references/mockery.md', + 'phpMin' => 80200, + ], +]; + +$yn = static fn(bool $b): string => $b ? 'yes' : 'NO'; + +echo "# Test doubles pre-flight\n\n"; +echo "Project root: `{$root}`\n"; +echo 'PHP (this binary): ' . \PHP_VERSION . "\n"; +echo 'testo/testo installed: ' . $yn($testoInstalled) . "\n"; +echo 'testo.php found: ' . $yn($configFound) . "\n\n"; + +$anyReady = false; + +foreach ($libraries as $label => $lib) { + [$vendor, $package] = \explode('/', $lib['library']); + [$bridgeVendor, $bridgePackage] = \explode('/', $lib['bridge']); + + $libraryInstalled = $exists('vendor', $vendor, $package); + $bridgeInstalled = $exists('vendor', $bridgeVendor, $bridgePackage); + $pluginRegistered = $configFound && \str_contains($configSource, $lib['plugin']); + $phpOk = \PHP_VERSION_ID >= $lib['phpMin']; + + echo "## {$label}\n\n"; + echo "| Component | Present | Source |\n|---|:---:|---|\n"; + echo "| {$lib['library']} | {$yn($libraryInstalled)} | " . ($declared[$lib['library']] ?? '—') . " |\n"; + echo "| {$lib['bridge']} | {$yn($bridgeInstalled)} | " . ($declared[$lib['bridge']] ?? '—') . " |\n"; + echo "| {$lib['plugin']} registered | {$yn($pluginRegistered)} | " . ($configFound ? 'testo.php `plugins:`' : 'no testo.php') . " |\n"; + echo '| PHP >= ' . \sprintf('%d.%d', \intdiv($lib['phpMin'], 10000), \intdiv($lib['phpMin'] % 10000, 100)) . " | {$yn($phpOk)} | this binary |\n\n"; + + $ready = $libraryInstalled && $bridgeInstalled && $pluginRegistered && $phpOk; + $anyReady = $anyReady || $ready; + + if ($ready) { + echo "**{$label}: READY.** Follow `{$lib['reference']}`.\n\n"; + continue; + } + + if (!$libraryInstalled && !$bridgeInstalled) { + $verdict = $phpOk ? 'NOT INSTALLED' : 'UNAVAILABLE (PHP too old)'; + echo "**{$label}: {$verdict}.**"; + $phpOk and print " Install: `composer require --dev {$lib['bridge']}` then register `{$lib['plugin']}` (see `{$lib['reference']}` §2)."; + echo "\n\n"; + continue; + } + + echo "**{$label}: INSTALLED BUT NOT WIRED.** Expectations will go unverified until fixed:\n\n"; + $bridgeInstalled or print "- `composer require --dev {$lib['bridge']}`\n"; + ($bridgeInstalled && !$libraryInstalled) and print "- Bridge present but `{$lib['library']}` missing from vendor/ — run `composer install`.\n"; + $pluginRegistered or print "- Register `{$lib['plugin']}` in `testo.php` `plugins:` (see `{$lib['reference']}` §2.2).\n"; + $phpOk or print "- {$lib['library']} needs PHP >= " . \sprintf('%d.%d', \intdiv($lib['phpMin'], 10000), \intdiv($lib['phpMin'] % 10000, 100)) . "; this binary is " . \PHP_VERSION . ".\n"; + echo "\n"; +} + +echo "## HAND-WRITTEN\n\n**HAND-WRITTEN: READY.** Always available — `references/handwritten.md`.\n\n"; + +if ($anyReady) { + echo "Use the library route the surrounding tests already use; keep the project on one library.\n"; + exit(0); +} + +echo "No library route is ready. Default to hand-written fakes; offer an install only when the user asks for a mocking library.\n"; +exit(1); diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index c76bd286..51f8bc19 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -239,7 +239,7 @@ semantics are covered by the `testo-run-tests` skill — escalate there before a ## Pitfalls -- Do not mock `enum`s or `final` classes — instantiate real ones. +- Do not mock `enum`s or `final` classes — instantiate real ones. For stubs, spies, mocks and fakes (Double, Mockery, hand-written), escalate to the `testo-test-doubles` skill. - Do not invent attributes. If you need behaviour you haven't seen in `llms.txt`, escalate to `llms-full.txt` before guessing. - Do not write `setUp`/`tearDown` — use the lifecycle attributes above. - For parameterized tests, escalate to the `testo-data-driven` skill.