From 6a78a9f2e4e9f59c23bf18f019072a890e92230a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 24 Sep 2026 23:57:54 +0400 Subject: [PATCH] =?UTF-8?q?feat(bridge-rector)!:=20split=20mock=20conversi?= =?UTF-8?q?ons=20into=20PHPUnit=E2=86=92Double,=20PHPUnit=E2=86=92Mockery?= =?UTF-8?q?=20and=20Mockery=E2=86=92Double=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(bridge-rector): convert PHPUnit mocks onto Mockery feat(bridge-rector): convert Mockery doubles onto Double feat(bridge-rector): convert configured and partial mocks, `willReturnMap` and every PHPUnit constraint via predicates fix(bridge-rector): normalise type names Double's `Argument::type()` would read as classes fix(skills): let the Rector scaffolder take several sets and find them in the flattened `config/` docs(skills): document the mock sets BREAKING CHANGE: `phpunit-to-testo` no longer converts mocks, since the target library is a choice; add `TestoRectorSetList::PHPUNIT_TO_DOUBLE` or `PHPUNIT_TO_MOCKERY` next to it. `CreateMockToDoubleRector` moved to the `Testo\Bridge\Rector\PhpunitToDouble` namespace. Constraint predicates reproduce PHPUnit's own evaluate(); PhpunitConstraintTest pins verdicts taken from PHPUnit on the edge values. Assisted-By: Claude Opus 5.5 --- bridge/rector/FEATURE_PARITY.md | 27 +- bridge/rector/README.md | 25 +- bridge/rector/config/mockery-to-double.php | 23 + bridge/rector/config/phpunit-to-double.php | 18 + bridge/rector/config/phpunit-to-mockery.php | 18 + bridge/rector/config/phpunit-to-testo.php | 6 +- .../rector/src/Internal/PhpunitConstraint.php | 646 +++++++ .../src/Internal/PhpunitMockFactory.php | 265 +++ .../rector/src/Internal/PredicateVariable.php | 40 + bridge/rector/src/Internal/ReturnValueMap.php | 77 + .../MockeryIntegrationToDoubleRector.php | 124 ++ .../integration_trait.php.inc | 29 + .../mockery_test_case.php.inc | 22 + .../unchanged.php.inc | 22 + .../MockeryToDouble/MockeryToDoubleRector.php | 1642 +++++++++++++++++ .../allows_expects.php.inc | 29 + .../MockeryToDoubleRector/arguments.php.inc | 33 + .../by_default_and_get_mock.php.inc | 25 + .../MockeryToDoubleRector/creation.php.inc | 45 + .../creation_unsupported.php.inc | 20 + .../double_chain_unchanged.php.inc | 13 + .../expectations.php.inc | 41 + .../extended_matchers.php.inc | 33 + .../MockeryToDoubleRector/matchers.php.inc | 37 + .../multiple_methods.php.inc | 39 + .../predicate_variable_clash.php.inc | 21 + .../MockeryToDoubleRector/returns.php.inc | 51 + .../split_assignments.php.inc | 37 + .../unconvertible_chain_keeps_factory.php.inc | 11 + .../unsupported_left_unchanged.php.inc | 35 + .../verification.php.inc | 49 + bridge/rector/src/MockeryToDouble/TODO.md | 53 + .../CreateMockToDoubleRector.php | 435 ++--- .../comparison_constraints.php.inc | 0 .../configured_and_partial.php.inc | 43 + .../create_mock.php.inc | 0 .../create_stub.php.inc | 0 .../create_stub_intersection.php.inc | 0 .../expects_once_with_return.php.inc | 0 .../extended_constraints.php.inc | 48 + .../factories_unsupported.php.inc | 18 + .../get_mock_builder.php.inc | 0 .../get_mock_builder_steps.php.inc | 38 + .../get_mock_builder_unsupported.php.inc | 17 + .../inline_factory_chain.php.inc | 0 .../intersection.php.inc | 0 .../legacy_will.php.inc | 0 .../legacy_will_extras.php.inc | 0 .../logical_constraints.php.inc | 0 .../CreateMockToDoubleRector/matchers.php.inc | 0 .../non_mock_chain_unchanged.php.inc | 0 .../phpunit12_constraints.php.inc | 25 + .../predicate_variable_clash.php.inc | 21 + .../return_map.php.inc | 23 + .../string_constraints.php.inc | 19 + .../stub_method.php.inc | 0 .../type_constraints.php.inc | 25 + .../unsupported_left_unchanged.php.inc | 23 + .../value_constraints.php.inc | 2 +- .../willreturn_argument.php.inc | 0 .../willreturn_self.php.inc | 0 .../with_any_parameters.php.inc | 0 .../with_constraints.php.inc | 0 bridge/rector/src/PhpunitToDouble/TODO.md | 52 + .../UnconvertibleMockToDoubleRector.php | 66 + .../CreateMockToMockeryRector.php | 478 +++++ .../CreateMockToMockeryRector/builder.php.inc | 35 + .../dedicated_constraints.php.inc | 31 + .../extended_constraints.php.inc | 44 + .../factories.php.inc | 43 + .../factories_unsupported.php.inc | 25 + .../matchers.php.inc | 35 + .../phpunit12_constraints.php.inc | 25 + .../predicate_constraints.php.inc | 35 + .../predicate_variable_clash.php.inc | 21 + .../return_map.php.inc | 23 + .../CreateMockToMockeryRector/returns.php.inc | 41 + .../unsupported_left_unchanged.php.inc | 24 + bridge/rector/src/PhpunitToMockery/TODO.md | 35 + .../get_mock_builder_unsupported.php.inc | 10 - .../string_constraints.php.inc | 19 - .../unsupported_left_unchanged.php.inc | 12 - .../src/PhpunitToTesto/MockToTestoRector.php | 66 - bridge/rector/src/PhpunitToTesto/TODO.md | 33 +- bridge/rector/src/Set/TestoRectorSetList.php | 21 + .../tests/Unit/PhpunitConstraintTest.php | 204 ++ .../rector/tests/Unit/ReturnValueMapTest.php | 58 + .../references/migrate-with-rector.md | 6 +- .../references/phpunit-to-testo-map.md | 4 +- .../scripts/precheck.php | 2 +- .../scripts/scaffold-rector-config.php | 68 +- skills/testo-test-doubles/SKILL.md | 8 +- 92 files changed, 5423 insertions(+), 394 deletions(-) create mode 100644 bridge/rector/config/mockery-to-double.php create mode 100644 bridge/rector/config/phpunit-to-double.php create mode 100644 bridge/rector/config/phpunit-to-mockery.php create mode 100644 bridge/rector/src/Internal/PhpunitConstraint.php create mode 100644 bridge/rector/src/Internal/PhpunitMockFactory.php create mode 100644 bridge/rector/src/Internal/PredicateVariable.php create mode 100644 bridge/rector/src/Internal/ReturnValueMap.php create mode 100644 bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector.php create mode 100644 bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector/integration_trait.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector/mockery_test_case.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector/unchanged.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector.php create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/allows_expects.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/arguments.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/by_default_and_get_mock.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation_unsupported.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/double_chain_unchanged.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/expectations.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/extended_matchers.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/matchers.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/multiple_methods.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/predicate_variable_clash.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/returns.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/split_assignments.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unconvertible_chain_keeps_factory.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unsupported_left_unchanged.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/verification.php.inc create mode 100644 bridge/rector/src/MockeryToDouble/TODO.md rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector.php (54%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/comparison_constraints.php.inc (100%) create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/configured_and_partial.php.inc rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/create_mock.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/create_stub.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/create_stub_intersection.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/expects_once_with_return.php.inc (100%) create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/extended_constraints.php.inc create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/factories_unsupported.php.inc rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/get_mock_builder.php.inc (100%) create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_steps.php.inc create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/inline_factory_chain.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/intersection.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/legacy_will.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/legacy_will_extras.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/logical_constraints.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/matchers.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc (100%) create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/phpunit12_constraints.php.inc create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/predicate_variable_clash.php.inc create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/return_map.php.inc create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/string_constraints.php.inc rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/stub_method.php.inc (100%) create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/type_constraints.php.inc create mode 100644 bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/value_constraints.php.inc (71%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/willreturn_argument.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/willreturn_self.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/with_any_parameters.php.inc (100%) rename bridge/rector/src/{PhpunitToTesto => PhpunitToDouble}/CreateMockToDoubleRector/with_constraints.php.inc (100%) create mode 100644 bridge/rector/src/PhpunitToDouble/TODO.md create mode 100644 bridge/rector/src/PhpunitToDouble/UnconvertibleMockToDoubleRector.php create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector.php create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/builder.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/dedicated_constraints.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/extended_constraints.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories_unsupported.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/matchers.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/phpunit12_constraints.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_constraints.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_variable_clash.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/return_map.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/returns.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/unsupported_left_unchanged.php.inc create mode 100644 bridge/rector/src/PhpunitToMockery/TODO.md delete mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc delete mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/string_constraints.php.inc delete mode 100644 bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc delete mode 100644 bridge/rector/src/PhpunitToTesto/MockToTestoRector.php create mode 100644 bridge/rector/tests/Unit/PhpunitConstraintTest.php create mode 100644 bridge/rector/tests/Unit/ReturnValueMapTest.php diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index fe19e23a..f5c687ba 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -29,7 +29,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`, `create{Mock,Stub}ForIntersectionOfInterfaces([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')`, `withAnyParameters()` drops away, the returns (incl. `willReturnArgument($n)`→`resolves(fn (...$a) => $a[$n])`, `willReturnSelf()`→`returns()`, and legacy `will(...)` incl. `onConsecutiveCalls`/`returnArgument`/`returnSelf`), 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`, `isNull`/`isTrue`/`isFalse`→`null`/`true`/`false`; `greaterThan`/`lessThan`/`…OrEqual`, `isEmpty`, `stringContains`, `stringStartsWith`/`EndsWith`, `arrayHasKey`→`satisfies(fn ($value) => …)`; `logicalNot`→`Argument::not(...)`, `logicalOr`→`Argument::any(...)`). 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 faithful form (`logicalAnd`, `equalToWithDelta`/`equalToCanonicalizing`, case-insensitive `stringContains`) leave the statement untouched — see `MockToTestoRector` (stub) and TODO.md* | ➖ | +| **Mocks** (`createMock`/`createStub` + `expects`/`method`/`will*`/`with`) | ➖ | ➖ *not part of this set: Testo core ships no mocking, so doubles convert through the mock sets below (Double or Mockery)* | ➖ | | **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* | ➖ | ➖ | @@ -37,6 +37,23 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **`uses()`** (Pest) | ➖ | ➖ | ⛔ *a converted function has no base class, traits or `$this` to attach to; closures that capture `$this`-shared state are left untouched* | | **`arch()` tests** (Pest) | ➖ | ➖ | ⛔ *Testo has no arch-assertion subsystem* | +## Mock sets + +Test doubles convert through their own sets, one per target library (`PHPUNIT_TO_DOUBLE`, +`PHPUNIT_TO_MOCKERY`, `MOCKERY_TO_DOUBLE`). Each rule rewrites a configuration statement whole or +leaves it untouched; the per-folder `TODO.md` lists the residuals. + +| Feature / concept | PHPUnit → Double | PHPUnit → Mockery | Mockery → Double | +|---|:---:|:---:|:---:| +| **Creation** | 🟡 *`CreateMockToDoubleRector`: `createMock`/`createStub`/intersections/`disableOriginalConstructor()` builder → `Double::for`, `disableAutoReturnValueGeneration` → `->strict()`; `createConfiguredMock` → `Double::for` + `allows()->returns()` per entry; `createPartialMock`/`onlyMethods` → `->passthru()` (+ `passthru(new X(...))` when the constructor runs) + `allows()` per doubled method. Left: `prophesize`, `getMockForAbstractClass`, `addMethods`, the bare constructor-calling builder, strict partials* | 🟡 *`CreateMockToMockeryRector`: the same forms → `\Mockery::mock(...)->shouldIgnoreMissing()`, configured → quick definitions, partial → `\Mockery::mock('X[a]', $ctorArgs)`, all-real partial → `->makePartial()`. Same residuals* | 🟡 *`MockeryToDoubleRector`: `mock` → `->strict()`, `spy`/`shouldIgnoreMissing` → loose, `makePartial` → `->passthru()` (with ctor args → `passthru(new X(...))`), `'A, B'`/several targets → intersection, typed computed targets, `mock($object)` → proxied passthru, quick definitions and inline `->getMock()` split into statements. Left: bare `mock()`, full mocks with ctor args, `alias:`/`overload:`/`X[m]`* | +| **Call counts** | ✅ *matcher → `times()`/`never()`, `any` → `allows`* | ✅ *matcher → `once`/`times`/`never`/`atLeast()`/`atMost()`* | ✅ *`once` (the `expects` default), `twice`/`times`, `never`, `atLeast()`/`atMost()`, `between`, `zeroOrMoreTimes`, `shouldNotReceive`; `ordered()` kept, `globally()`/groups left manual* | +| **Returns** | ✅ *`willReturn*`, `willThrowException`, `willReturnCallback`/`Argument`/`Self`/`Map`, legacy `will(...)`; `willReturnReference` left manual* | ✅ *same set → `andReturn*`/`andThrow`/`andReturnUsing()`* | ✅ *`andReturn(s)`/`Values` (literal or array-typed)/`Null`/`True`/`False`/`Self`/`Arg`/`Using`, `andThrow(s)` (object or class-string), `andThrowExceptions`; `andReturnUndefined`/`andSet`/`passthru` left manual* | +| **Argument matching** | ✅ *dedicated `Argument::*` matchers, every other constraint (incl. `logicalAnd`/`Xor`, delta/case/canonicalizing equality, `countOf`, `isJson`, `containsOnly*`, PHPUnit 12 type factories) → `satisfies()` over its PHP expression, verdicts pinned against PHPUnit in `PhpunitConstraintTest`. Left: computed case flag / type, `matches()` formats* | ✅ *dedicated Mockery matchers, the rest → `Mockery::on()` over the same expressions* | 🟡 *`with`/`withArgs` (literal, array-typed, closure)/`withNoArgs`/`withAnyArgs`/`withSomeOfArgs`, magic `allows()->m($x)`, Mockery matchers → `Argument::*` per the migration table; `hasKey`/multi-`contains`/`subset`/`ducktype` → `satisfies()`. Left: nested matchers, Hamcrest. Residual by design: Mockery compares loosely, Double strictly* | +| **Several expectations in one call** | ➖ | ➖ | ✅ *`shouldReceive('a', 'b')`, `shouldReceive(['a' => 1])`, `allows(['a' => 1])` → one statement per method (link arguments must be side-effect free)* | +| **Default expectations** (`byDefault`) | ➖ | ➖ | 🟡 *dropped when the expectation has no arguments and no count (Double tries the newest expectation first); otherwise left manual* | +| **Spy verification** | ➖ | ➖ | ✅ *`shouldHaveReceived`/`shouldNotHaveReceived` (plain, array-args, closure and magic forms) → `received()`/`->never()`, `shouldHaveBeenCalled`/`shouldNotHaveBeenCalled` → `received('__invoke')`; `Mockery::close()` → `Double::verifyAll()`* | +| **PHPUnit integration** | ➖ | ➖ | ✅ *`MockeryIntegrationToDoubleRector`: `MockeryPHPUnitIntegration` → `VerifiesDoubles`, `extends MockeryTestCase` → `extends TestCase` + the trait* | + ## Remaining work (🧩 — actually tractable) Pest → Testo opened up once the direction stopped chasing a *class* and targeted **free functions** @@ -105,13 +122,7 @@ a `use (...)`-capturing closure, or any unrecognised modifier — see `src/PestT 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 intersection stubs, -`getMockBuilder(X)->disableOriginalConstructor()->getMock()`, `willReturnSelf()`, `withAnyParameters()`, -and the comparison/string/`logicalNot`/`logicalOr` constraints (via `satisfies`/`not`/`any`); only the -unmappable links (`willReturnMap`, a builder step beyond `disableOriginalConstructor`, `prophesize`, -`logicalAnd`, delta/canonicalizing equality) stay manual. +Mocks moved off this list: they convert through the dedicated mock sets (see "Mock sets" above). 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/README.md b/bridge/rector/README.md index 8bc55e21..63220190 100644 --- a/bridge/rector/README.md +++ b/bridge/rector/README.md @@ -17,8 +17,29 @@ Each direction ships a Rector set. Reference it with the typed handle from The set files live under `config/`; the constants are absolute paths to those files, so they also work with `$rectorConfig->import(...)`. -Conversions that have no faithful counterpart in the target framework (PHPUnit mocks, -constraints, memory-leak / retry / repeat, Pest higher-order & `arch()` tests, etc.) are +### Mock sets + +Testo core ships no mocking, so test doubles convert through their own sets, one per target library. +Add one next to `PHPUNIT_TO_TESTO`, or run it on its own: + +| Direction | Set constant | Target | +|--------------------|------------------------------------------|--------| +| PHPUnit → Double | `TestoRectorSetList::PHPUNIT_TO_DOUBLE` | `createMock`/`createStub` and their `expects`/`method`/`will*`/`with` chains → `\JMac\Testing\Double` (`testo/bridge-double`). See `src/PhpunitToDouble/TODO.md`. | +| PHPUnit → Mockery | `TestoRectorSetList::PHPUNIT_TO_MOCKERY` | The same chains → `\Mockery::mock()` + `shouldReceive()` (`testo/bridge-mockery`). See `src/PhpunitToMockery/TODO.md`. | +| Mockery → Double | `TestoRectorSetList::MOCKERY_TO_DOUBLE` | `mock`/`spy`, `shouldReceive`/`allows`/`expects`, `shouldHaveReceived`, `Mockery::close()` → Double, after Double's [migration table](https://testdoublephp.com/migrating-from-mockery). See `src/MockeryToDouble/TODO.md`. | + +```php +return RectorConfig::configure() + ->withPaths([__DIR__ . '/tests']) + ->withSets([TestoRectorSetList::PHPUNIT_TO_TESTO, TestoRectorSetList::PHPUNIT_TO_DOUBLE]); +``` + +Each mock rule rewrites a whole configuration statement or none of it. The rules do not track a +double across statements, though: when one statement on a double is left for manual work while the +double's factory and its other statements convert, the leftover one is what the finishing pass fixes. + +Conversions that have no faithful counterpart in the target framework (constraints, +memory-leak / retry / repeat, Pest higher-order & `arch()` tests, etc.) are **not silently dropped**: each is a documented stub rule plus an entry in the direction's `TODO.md`. diff --git a/bridge/rector/config/mockery-to-double.php b/bridge/rector/config/mockery-to-double.php new file mode 100644 index 00000000..a14088bf --- /dev/null +++ b/bridge/rector/config/mockery-to-double.php @@ -0,0 +1,23 @@ + Double conversion set. + * + * Moves Mockery doubles — `mock()`/`spy()`, `shouldReceive()`/`allows()`/`expects()` chains, + * `shouldHaveReceived()` verification and `Mockery::close()` — onto Double (`testo/bridge-double`), + * following Double's Mockery migration table. Independent of the test framework, so it runs on PHPUnit + * and Testo suites alike. The forms with no faithful Double target are left in place — see + * bridge/rector/src/MockeryToDouble/TODO.md. + */ +return static function (RectorConfig $rectorConfig): void { + $rectorConfig->rule(MockeryToDoubleRector::class); + + # A PHPUnit class verifying through Mockery's integration verifies through Double's instead. + $rectorConfig->rule(MockeryIntegrationToDoubleRector::class); +}; diff --git a/bridge/rector/config/phpunit-to-double.php b/bridge/rector/config/phpunit-to-double.php new file mode 100644 index 00000000..8e3fdd5a --- /dev/null +++ b/bridge/rector/config/phpunit-to-double.php @@ -0,0 +1,18 @@ + Double conversion set. + * + * Moves PHPUnit's `createMock()`/`createStub()` doubles and their `expects()/method()/will*()/with()` + * chains onto Double (`testo/bridge-double`). Pair it with `phpunit-to-testo.php` when migrating a + * suite, or run it alone. The forms with no faithful Double target are left in place — see + * bridge/rector/src/PhpunitToDouble/TODO.md. + */ +return static function (RectorConfig $rectorConfig): void { + $rectorConfig->rule(CreateMockToDoubleRector::class); +}; diff --git a/bridge/rector/config/phpunit-to-mockery.php b/bridge/rector/config/phpunit-to-mockery.php new file mode 100644 index 00000000..6db5bbc6 --- /dev/null +++ b/bridge/rector/config/phpunit-to-mockery.php @@ -0,0 +1,18 @@ + Mockery conversion set. + * + * Moves PHPUnit's `createMock()`/`createStub()` doubles and their `expects()/method()/will*()/with()` + * chains onto Mockery, verified after every test by `testo/bridge-mockery`. Pair it with + * `phpunit-to-testo.php` when migrating a suite, or run it alone. The forms with no faithful Mockery + * target are left in place — see bridge/rector/src/PhpunitToMockery/TODO.md. + */ +return static function (RectorConfig $rectorConfig): void { + $rectorConfig->rule(CreateMockToMockeryRector::class); +}; diff --git a/bridge/rector/config/phpunit-to-testo.php b/bridge/rector/config/phpunit-to-testo.php index 6776b2e0..9ee2b9df 100644 --- a/bridge/rector/config/phpunit-to-testo.php +++ b/bridge/rector/config/phpunit-to-testo.php @@ -5,7 +5,6 @@ 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; @@ -67,7 +66,6 @@ # 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); + # Mocks are not converted here: Testo core ships no mocking, so the target library is a choice. + # Add `phpunit-to-double.php` or `phpunit-to-mockery.php` next to this set. }; diff --git a/bridge/rector/src/Internal/PhpunitConstraint.php b/bridge/rector/src/Internal/PhpunitConstraint.php new file mode 100644 index 00000000..34d8f3b7 --- /dev/null +++ b/bridge/rector/src/Internal/PhpunitConstraint.php @@ -0,0 +1,646 @@ +greaterThan(5)`, `self::logicalOr(...)`, …) into + * plain PHP boolean expressions over one variable, for the mock rules that target a library with no + * dedicated matcher for the constraint: Double's `Argument::satisfies()`, Mockery's `Mockery::on()`. + * + * Each expression reproduces the constraint's own evaluation (`IsEqual` compares with `==`, + * `ArrayHasKey` also accepts `ArrayAccess`, `IsEmpty` counts a `Countable`, …). A constraint with no + * faithful expression yields null, and the caller leaves the whole chain for manual migration. + * + * @internal + */ +final class PhpunitConstraint +{ + /** + * PHPUnit `isType()` names → the canonical PHP type they check. `resource (closed)` has no `is_*()` + * function and is checked through `gettype()`. + */ + private const NATIVE_TYPES = [ + 'array' => 'array', + 'bool' => 'bool', + 'boolean' => 'bool', + 'callable' => 'callable', + 'double' => 'float', + 'float' => 'float', + 'int' => 'int', + 'integer' => 'int', + 'iterable' => 'iterable', + 'null' => 'null', + 'numeric' => 'numeric', + 'object' => 'object', + 'real' => 'float', + 'resource' => 'resource', + 'resource (closed)' => 'resource (closed)', + 'closed resource' => 'resource (closed)', + 'scalar' => 'scalar', + 'string' => 'string', + ]; + + /** + * The typed factories PHPUnit 12 added in place of `isType()` and `containsOnly()`, by the PHP type + * they check. + */ + private const TYPE_FACTORIES = [ + 'Array' => 'array', + 'Bool' => 'bool', + 'Callable' => 'callable', + 'Float' => 'float', + 'Int' => 'int', + 'Iterable' => 'iterable', + 'Null' => 'null', + 'Numeric' => 'numeric', + 'Object' => 'object', + 'Resource' => 'resource', + 'ClosedResource' => 'resource (closed)', + 'Scalar' => 'scalar', + 'String' => 'string', + ]; + + /** + * Constraints that are a plain `is_*()`-style function applied to the value. + */ + private const VALUE_FUNCTIONS = [ + 'isNan' => 'is_nan', + 'isFinite' => 'is_finite', + 'isInfinite' => 'is_infinite', + 'isReadable' => 'is_readable', + 'isWritable' => 'is_writable', + 'fileExists' => 'file_exists', + 'directoryExists' => 'is_dir', + ]; + + /** + * @param non-empty-string $variable The predicate parameter the expressions are written over. + */ + public function __construct( + private readonly string $variable, + ) {} + + /** + * True for a PHPUnit constraint factory call — `$this->equalTo(...)` or the `self::`/`static::` + * forms — as opposed to a plain value. + */ + public static function isConstraintCall(Expr $value): bool + { + if ($value instanceof MethodCall) { + return $value->var instanceof Variable && $value->var->name === 'this' && $value->name instanceof Identifier; + } + + return $value instanceof StaticCall + && $value->class instanceof Name + && \in_array(\strtolower($value->class->toString()), ['self', 'static'], true) + && $value->name instanceof Identifier; + } + + /** + * The constraint's factory name (`greaterThan`), or null for anything that is not a constraint call. + */ + public static function name(Expr $value): ?string + { + if (!self::isConstraintCall($value)) { + return null; + } + + \assert($value instanceof MethodCall || $value instanceof StaticCall); + \assert($value->name instanceof Identifier); + + return $value->name->toString(); + } + + /** + * The constraint's arguments when all are plain and positional, null otherwise. + * + * @return list|null + */ + public static function arguments(Expr $value): ?array + { + if (!$value instanceof MethodCall && !$value instanceof StaticCall) { + return null; + } + + $args = []; + foreach ($value->args as $arg) { + if (!$arg instanceof Arg || $arg->name !== null || $arg->unpack) { + return null; + } + $args[] = $arg->value; + } + + return $args; + } + + /** + * Canonical PHP type for a PHPUnit `isType()` name: `int`, `float`, `bool`, `string`, `array`, + * `object`, `callable`, `iterable`, `null`, `numeric`, `scalar`, `resource` or `resource (closed)`. + */ + public static function nativeType(string $name): ?string + { + return self::NATIVE_TYPES[\strtolower($name)] ?? null; + } + + /** + * The PHP type a type-checking constraint checks — `isType('integer')`, or PHPUnit 12's `isInt()` — + * or null when $constraint is not one with a literal type. + */ + public static function checkedType(Expr $constraint): ?string + { + $name = self::name($constraint); + $args = self::arguments($constraint); + if ($name === null || $args === null) { + return null; + } + + if ($name === 'isType') { + return \count($args) === 1 && $args[0] instanceof String_ ? self::nativeType($args[0]->value) : null; + } + + # `isNull()` is its own constraint (`IsNull`), not a type factory. + return $args === [] && $name !== 'isNull' && \str_starts_with($name, 'is') ? self::TYPE_FACTORIES[\substr($name, 2)] ?? null : null; + } + + /** + * A deep copy of $expr, for an expression that has to appear twice in the output: one node object + * in two tree positions confuses the printer's and Rector's per-node bookkeeping. The copy carries no + * attributes — an inherited position or original-node link would make the format-preserving printer + * try to reuse source text the copy does not own. + * + * @template T of Expr + * @param T $expr + * @return T + */ + public static function copy(Expr $expr): Expr + { + $cloner = new class extends NodeVisitorAbstract { + public function enterNode(Node $node): Node + { + $copy = clone $node; + $copy->setAttributes([]); + + return $copy; + } + }; + + [$copy] = (new NodeTraverser($cloner))->traverse([$expr]); + \assert($copy instanceof $expr); + + return $copy; + } + + /** + * The constraint as a boolean expression over the predicate variable, or null when it has none. A + * plain value is PHPUnit's implicit `equalTo()`. + */ + public function predicate(Expr $constraint): ?Expr + { + $value = $this->value(); + $name = self::name($constraint); + if ($name === null) { + return new BinaryOp\Equal($value, $constraint); + } + + $args = self::arguments($constraint); + if ($args === null) { + return null; + } + + $count = \count($args); + $first = $args[0] ?? null; + + if (isset(self::VALUE_FUNCTIONS[$name])) { + return $count === 0 ? $this->valueFunction($name) : null; + } + + $checkedType = self::checkedType($constraint); + if ($checkedType !== null) { + return $this->nativeCheck($value, $checkedType); + } + + if (\str_starts_with($name, 'containsOnly') && isset(self::TYPE_FACTORIES[\substr($name, 12)])) { + $item = new Variable($this->variable . 'Item'); + + return $count === 0 ? $this->allItems($item, $this->nativeCheck($item, self::TYPE_FACTORIES[\substr($name, 12)])) : null; + } + + return match ($name) { + 'anything' => $count === 0 ? $this->constant('true') : null, + 'equalTo' => $count === 1 ? new BinaryOp\Equal($value, $first) : null, + 'identicalTo' => $count === 1 ? new BinaryOp\Identical($value, $first) : null, + 'isNull' => $count === 0 ? new BinaryOp\Identical($value, $this->constant('null')) : null, + 'isTrue' => $count === 0 ? new BinaryOp\Identical($value, $this->constant('true')) : null, + 'isFalse' => $count === 0 ? new BinaryOp\Identical($value, $this->constant('false')) : null, + 'greaterThan' => $count === 1 ? new BinaryOp\Greater($value, $first) : null, + 'lessThan' => $count === 1 ? new BinaryOp\Smaller($value, $first) : null, + 'greaterThanOrEqual' => $count === 1 ? new BinaryOp\GreaterOrEqual($value, $first) : null, + 'lessThanOrEqual' => $count === 1 ? new BinaryOp\SmallerOrEqual($value, $first) : null, + 'equalToWithDelta' => $count === 2 ? $this->withinDelta($first, $args[1]) : null, + 'equalToIgnoringCase' => $count === 1 ? $this->equalIgnoringCase($first) : null, + 'equalToCanonicalizing' => $count === 1 ? $this->equalCanonicalizing($first) : null, + 'isEmpty' => $count === 0 ? $this->isEmpty() : null, + 'isList' => $count === 0 ? new BinaryOp\BooleanAnd($this->func('is_array', [$value]), $this->func('array_is_list', [$value])) : null, + 'isJson' => $count === 0 ? $this->isJson() : null, + 'countOf' => $count === 1 ? new BinaryOp\BooleanAnd($this->func('is_countable', [$value]), new BinaryOp\Identical($this->func('count', [$value]), $first)) : null, + 'stringContains' => $this->stringContains($args), + 'stringStartsWith' => $count === 1 ? $this->onString($this->func('str_starts_with', [$value, $first])) : null, + 'stringEndsWith' => $count === 1 ? $this->onString($this->func('str_ends_with', [$value, $first])) : null, + 'matchesRegularExpression' => $count === 1 ? $this->onString(new BinaryOp\Greater($this->func('preg_match', [$first, $value]), new Int_(0))) : null, + 'arrayHasKey' => $count === 1 ? $this->hasKey($first) : null, + 'contains', 'containsEqual' => $count === 1 ? $this->inIterable($first, false) : null, + 'containsIdentical' => $count === 1 ? $this->inIterable($first, true) : null, + 'containsOnly' => $count === 1 ? $this->containsOnly($first) : null, + 'containsOnlyInstancesOf' => $count === 1 ? $this->containsOnlyInstancesOf($first) : null, + 'isInstanceOf' => $count === 1 ? $this->instanceOf($value, $first) : null, + 'isType' => $count === 1 ? $this->typeCheck($value, $first) : null, + 'objectEquals' => $this->objectEquals($args), + 'callback' => $count === 1 ? new FuncCall($first, [new Arg($value)]) : null, + 'logicalNot' => $count === 1 ? $this->negate($first) : null, + 'logicalOr' => $this->combine($args, BinaryOp\BooleanOr::class), + 'logicalAnd' => $this->combine($args, BinaryOp\BooleanAnd::class), + 'logicalXor' => $this->combine($args, BinaryOp\LogicalXor::class), + default => null, + }; + } + + /** + * `fn ($value) => `, the closure both target libraries take. + */ + public function closure(Expr $predicate): ArrowFunction + { + return new ArrowFunction(['params' => [new Param($this->value())], 'expr' => $predicate]); + } + + /** + * `isType('int')` / `isInstanceOf(X::class)` / `containsOnly(...)` as a check on $subject: an + * `is_*()` call for a PHP type, `instanceof` for a class. A computed name could be either and has + * no single form. + */ + public function typeCheck(Expr $subject, Expr $type): ?Expr + { + if (!$type instanceof String_) { + return null; + } + + $native = self::nativeType($type->value); + + return $native === null ? null : $this->nativeCheck($subject, $native); + } + + /** + * `is_($subject)`. A resource is checked through `gettype()`: PHPUnit's `resource` also + * accepts a closed one, which `is_resource()` rejects, and `resource (closed)` has no `is_*()`. + */ + private function nativeCheck(Expr $subject, string $native): Expr + { + $closed = new BinaryOp\Identical($this->func('gettype', [$subject]), new String_('resource (closed)')); + + return match ($native) { + 'resource (closed)' => $closed, + 'resource' => new BinaryOp\BooleanOr($this->func('is_resource', [$subject]), $closed), + default => $this->func('is_' . $native, [$subject]), + }; + } + + /** + * The math and filesystem checks, behind the type guard PHPUnit applies first: a number for + * `isNan()`/`isFinite()`/`isInfinite()`, a string path for the file constraints. + */ + private function valueFunction(string $name): Expr + { + $check = $this->func(self::VALUE_FUNCTIONS[$name], [$this->value()]); + + if (\in_array($name, ['isNan', 'isFinite', 'isInfinite'], true)) { + return new BinaryOp\BooleanAnd( + new BinaryOp\BooleanOr($this->func('is_float', [$this->value()]), $this->func('is_int', [$this->value()])), + $check, + ); + } + + return $this->onString($check); + } + + private function instanceOf(Expr $subject, Expr $class): Instanceof_ + { + $target = $class instanceof ClassConstFetch && $class->class instanceof Name + && $class->name instanceof Identifier && \strtolower($class->name->toString()) === 'class' + ? $class->class + : $class; + + return new Instanceof_($subject, $target); + } + + /** + * `IsEqualWithDelta`: a number within `$delta` of the expected one; any other value is compared + * with plain `==`, as PHPUnit's comparator falls back to. + */ + private function withinDelta(Expr $expected, Expr $delta): Expr + { + return new Ternary( + $this->func('is_numeric', [$this->value()]), + new BinaryOp\SmallerOrEqual( + $this->func('abs', [new BinaryOp\Minus($this->value(), $expected)]), + $delta, + ), + new BinaryOp\Equal($this->value(), self::copy($expected)), + ); + } + + /** + * `IsEqualIgnoringCase`: a boolean compares with plain `==`; a scalar or null is lowercased as a + * string on both sides first, as PHPUnit's scalar comparator does. + */ + private function equalIgnoringCase(Expr $expected): Expr + { + return new Ternary( + $this->func('is_bool', [$this->value()]), + new BinaryOp\Equal($this->value(), $expected), + new BinaryOp\BooleanAnd( + new BinaryOp\BooleanOr( + $this->func('is_scalar', [$this->value()]), + new BinaryOp\Identical($this->value(), $this->constant('null')), + ), + new BinaryOp\Equal( + $this->func('mb_strtolower', [new Cast\String_($this->value())]), + $this->func('mb_strtolower', [new Cast\String_(self::copy($expected))]), + ), + ), + ); + } + + /** + * `IsEqualCanonicalizing`: two arrays are sorted before the `==` comparison, an array never equals a + * non-array, and two scalars compare as is. The sort needs statements, so it runs in an immediately + * invoked static closure. + */ + private function equalCanonicalizing(Expr $expected): Expr + { + $a = new Variable('actual'); + $b = new Variable('expected'); + $sorter = new Closure([ + 'static' => true, + 'params' => [new Param($a), new Param($b)], + 'returnType' => new Identifier('bool'), + 'stmts' => [ + new If_(new BinaryOp\NotIdentical($this->func('is_array', [$a]), $this->func('is_array', [$b])), [ + 'stmts' => [new Return_($this->constant('false'))], + ]), + new If_($this->func('is_array', [$a]), [ + 'stmts' => [ + new Expression($this->func('sort', [$a])), + new Expression($this->func('sort', [$b])), + ], + ]), + new Return_(new BinaryOp\Equal($a, $b)), + ], + ]); + + return new FuncCall($sorter, [new Arg($this->value()), new Arg($expected)]); + } + + /** + * `IsEmpty`: a `Countable` counts, anything else goes through `empty()`. + */ + private function isEmpty(): Expr + { + return new Ternary( + new Instanceof_($this->value(), new FullyQualified('Countable')), + new BinaryOp\Identical($this->func('count', [$this->value()]), new Int_(0)), + new Empty_($this->value()), + ); + } + + /** + * `IsJson`: a string that decodes without error (`'null'` included, `''` excluded). + */ + private function isJson(): Expr + { + return new BinaryOp\BooleanAnd( + $this->func('is_string', [$this->value()]), + new BinaryOp\BooleanOr( + new BinaryOp\NotIdentical($this->func('json_decode', [$this->value()]), $this->constant('null')), + new BinaryOp\Identical($this->func('json_last_error', []), new ConstFetch(new Name('JSON_ERROR_NONE'))), + ), + ); + } + + /** + * `stringContains($needle)` → `str_contains()`; with a literal `true` case flag → + * `mb_stripos() !== false`, which is what PHPUnit runs. A computed flag or the line-ending flag + * has no fixed form. + * + * @param list $args + */ + private function stringContains(array $args): ?Expr + { + $needle = $args[0] ?? null; + if ($needle === null || \count($args) > 3) { + return null; + } + + if (isset($args[2]) && !$this->isConstant($args[2], 'false')) { + return null; + } + + # An empty needle is found in anything, string or not. + if ($needle instanceof String_ && $needle->value === '') { + return $this->constant('true'); + } + + $ignoreCase = $args[1] ?? $this->constant('false'); + if ($this->isConstant($ignoreCase, 'false')) { + return $this->onString($this->func('str_contains', [$this->value(), $needle])); + } + + if ($this->isConstant($ignoreCase, 'true')) { + return $this->onString(new BinaryOp\NotIdentical($this->func('mb_stripos', [$this->value(), $needle]), $this->constant('false'))); + } + + return null; + } + + /** + * `is_string($value) && `: PHPUnit's string constraints reject any other value up front. + */ + private function onString(Expr $check): Expr + { + return new BinaryOp\BooleanAnd($this->func('is_string', [$this->value()]), $check); + } + + /** + * `ArrayHasKey`: `array_key_exists()` on an array, `offsetExists()` on an `ArrayAccess`. + */ + private function hasKey(Expr $key): Expr + { + return new Ternary( + $this->func('is_array', [$this->value()]), + $this->func('array_key_exists', [$key, $this->value()]), + new BinaryOp\BooleanAnd( + new Instanceof_($this->value(), new FullyQualified('ArrayAccess')), + new MethodCall($this->value(), new Identifier('offsetExists'), [new Arg(self::copy($key))]), + ), + ); + } + + /** + * `TraversableContainsEqual` / `…Identical`: `in_array()` over the iterable's values. + */ + private function inIterable(Expr $needle, bool $strict): Expr + { + $args = [new Arg($needle), new Arg($this->spread())]; + if ($strict) { + $args[] = new Arg($this->constant('true')); + } + + return new BinaryOp\BooleanAnd( + $this->func('is_iterable', [$this->value()]), + new FuncCall(new Name('in_array'), $args), + ); + } + + /** + * `containsOnly('int')` → every item passes `is_int()`; a literal class name → every item is an + * instance. A computed name could be either. + */ + private function containsOnly(Expr $type): ?Expr + { + $item = new Variable($this->variable . 'Item'); + $check = match (true) { + $type instanceof String_ && self::nativeType($type->value) !== null => $this->typeCheck($item, $type), + $type instanceof String_, $type instanceof ClassConstFetch => $this->instanceOf($item, $type), + default => null, + }; + + return $check === null ? null : $this->allItems($item, $check); + } + + private function containsOnlyInstancesOf(Expr $class): Expr + { + $item = new Variable($this->variable . 'Item'); + + return $this->allItems($item, $this->instanceOf($item, $class)); + } + + /** + * `is_iterable($value) && array_filter([...$value], fn ($item) => !) === []`. + */ + private function allItems(Variable $item, Expr $check): Expr + { + $rejects = new ArrowFunction(['params' => [new Param($item)], 'expr' => new BooleanNot($check)]); + + return new BinaryOp\BooleanAnd( + $this->func('is_iterable', [$this->value()]), + new BinaryOp\Identical( + new FuncCall(new Name('array_filter'), [new Arg($this->spread()), new Arg($rejects)]), + new Array_([]), + ), + ); + } + + /** + * `objectEquals($expected, 'method')`: the value's own comparison method, `equals` by default. + * + * @param list $args + */ + private function objectEquals(array $args): ?Expr + { + $expected = $args[0] ?? null; + $method = $args[1] ?? new String_('equals'); + if ($expected === null || \count($args) > 2 || !$method instanceof String_) { + return null; + } + + return new BinaryOp\BooleanAnd( + $this->func('is_object', [$this->value()]), + new MethodCall($this->value(), new Identifier($method->value), [new Arg($expected)]), + ); + } + + private function negate(Expr $inner): ?BooleanNot + { + $predicate = $this->predicate($inner); + + return $predicate === null ? null : new BooleanNot($predicate); + } + + /** + * @param list $args + * @param class-string $operator + */ + private function combine(array $args, string $operator): ?Expr + { + $combined = null; + foreach ($args as $arg) { + $predicate = $this->predicate($arg); + if ($predicate === null) { + return null; + } + + $combined = $combined === null ? $predicate : new $operator($combined, $predicate); + } + + return $combined; + } + + private function spread(): Array_ + { + return new Array_([new ArrayItem($this->value(), unpack: true)]); + } + + private function value(): Variable + { + return new Variable($this->variable); + } + + private function constant(string $name): ConstFetch + { + return new ConstFetch(new Name($name)); + } + + private function isConstant(Expr $expr, string $name): bool + { + return $expr instanceof ConstFetch && \strtolower($expr->name->toString()) === $name; + } + + /** + * @param list $args + */ + private function func(string $name, array $args): FuncCall + { + return new FuncCall(new Name($name), \array_map(static fn(Expr $arg): Arg => new Arg($arg), $args)); + } +} diff --git a/bridge/rector/src/Internal/PhpunitMockFactory.php b/bridge/rector/src/Internal/PhpunitMockFactory.php new file mode 100644 index 00000000..349d16df --- /dev/null +++ b/bridge/rector/src/Internal/PhpunitMockFactory.php @@ -0,0 +1,265 @@ +createMock(X)` / `createStub(X)` — a double of X answering unconfigured calls with defaults; + * - `$this->create{Mock,Stub}ForIntersectionOfInterfaces([A, B])` — the same for several interfaces; + * - `$this->createConfiguredMock(X, ['m' => $v])` / `createConfiguredStub` — plus a stubbed return per method; + * - `$this->createPartialMock(X, ['m'])` — only the listed methods doubled, the rest run for real; + * - `$this->getMockBuilder(X)->…->getMock()` with the steps `disableOriginalConstructor()`, + * `setConstructorArgs()`, `onlyMethods()`, `disableAutoReturnValueGeneration()`, + * `disableOriginalClone()`, `disableArgumentCloning()`. + * + * A form whose double no single target call reproduces is rejected: a non-partial double whose real + * constructor runs (the bare `getMockBuilder(X)->getMock()`), `getMockForAbstractClass()`, `addMethods()`, + * a builder step not listed above, or a computed method list / configuration map. + * + * @internal + */ +final class PhpunitMockFactory +{ + /** + * Builder steps that change nothing a target library would reproduce differently: neither Double + * nor Mockery clones a double or its arguments. + */ + private const NEUTRAL_STEPS = ['disableOriginalClone', 'disableArgumentCloning']; + + /** + * @param non-empty-list $targets The doubled types, as the factory received them. + * @param Array_|null $configuration `createConfiguredMock()`'s method → return map (string keys). + * @param list|null $partialMethods The doubled methods of a partial mock; null when every method + * is doubled, an empty list when none is. + * @param Expr|null $constructorArgs The argument list the real constructor runs with (an array + * expression), or null when the constructor does not run. Set only for a partial mock. + * @param bool $autoReturn False after `disableAutoReturnValueGeneration()`: an unconfigured call fails + * instead of returning a default. + */ + private function __construct( + public readonly array $targets, + public readonly ?Array_ $configuration = null, + public readonly ?array $partialMethods = null, + public readonly ?Expr $constructorArgs = null, + public readonly bool $autoReturn = true, + ) {} + + public static function parse(MethodCall $call): ?self + { + $name = self::name($call); + + if ($name === 'getMock') { + return $call->args === [] ? self::parseBuilder($call->var) : null; + } + + if (!self::isThis($call->var)) { + return null; + } + + $args = self::positional($call); + if ($args === null) { + return null; + } + + return match ($name) { + 'createMock', 'createStub' => \count($args) === 1 ? new self([new Arg($args[0])]) : null, + 'createMockForIntersectionOfInterfaces', 'createStubForIntersectionOfInterfaces' => self::parseIntersection($args), + 'createConfiguredMock', 'createConfiguredStub' => self::parseConfigured($args), + 'createPartialMock' => self::parsePartial($args), + default => null, + }; + } + + /** + * The class a partial mock doubles — the one target a partial or configured mock has. + */ + public function target(): Expr + { + return $this->targets[0]->value; + } + + /** + * @param list $args + */ + private static function parseIntersection(array $args): ?self + { + $list = \count($args) === 1 ? self::listItems($args[0]) : null; + + return $list === null || $list === [] ? null : new self(\array_map(static fn(Expr $item): Arg => new Arg($item), $list)); + } + + /** + * @param list $args + */ + private static function parseConfigured(array $args): ?self + { + if (\count($args) !== 2 || !$args[1] instanceof Array_ || $args[1]->items === []) { + return null; + } + + foreach ($args[1]->items as $item) { + if ($item === null || !$item->key instanceof String_ || $item->unpack || $item->byRef) { + return null; + } + } + + return new self([new Arg($args[0])], configuration: $args[1]); + } + + /** + * @param list $args + */ + private static function parsePartial(array $args): ?self + { + $methods = \count($args) === 2 ? self::listItems($args[1]) : null; + + return $methods === null ? null : new self([new Arg($args[0])], partialMethods: $methods); + } + + /** + * Walks a builder chain down from its `getMock()` to `$this->getMockBuilder(X)`. + */ + private static function parseBuilder(Expr $cursor): ?self + { + $steps = []; + while ($cursor instanceof MethodCall) { + $name = self::name($cursor); + if ($name === null || isset($steps[$name])) { + return null; + } + + if ($name === 'getMockBuilder') { + if (!self::isThis($cursor->var)) { + return null; + } + + $target = self::positional($cursor); + + return $target !== null && \count($target) === 1 ? self::fromSteps(new Arg($target[0]), $steps) : null; + } + + $args = self::positional($cursor); + if ($args === null) { + return null; + } + + $steps[$name] = $args; + $cursor = $cursor->var; + } + + return null; + } + + /** + * @param array> $steps + */ + private static function fromSteps(Arg $target, array $steps): ?self + { + $disabled = false; + $methods = null; + $constructorArgs = null; + $autoReturn = true; + + foreach ($steps as $name => $args) { + switch ($name) { + case 'disableOriginalConstructor': + $disabled = true; + $valid = $args === []; + break; + case 'disableAutoReturnValueGeneration': + $autoReturn = false; + $valid = $args === []; + break; + case 'onlyMethods': + $methods = \count($args) === 1 ? self::listItems($args[0]) : null; + $valid = $methods !== null; + break; + case 'setConstructorArgs': + $constructorArgs = $args[0] ?? null; + $valid = \count($args) === 1; + break; + default: + $valid = $args === [] && \in_array($name, self::NEUTRAL_STEPS, true); + } + + if (!$valid) { + return null; + } + } + + if ($disabled && $constructorArgs !== null) { + return null; + } + + # A builder that keeps the constructor runs it — with no arguments unless told otherwise. + $constructorArgs ??= $disabled ? null : new Array_([]); + + # Only a partial double keeps real behaviour a constructor's state could feed; a full double + # whose constructor runs has no single-call equivalent. + if ($methods === null && $constructorArgs !== null) { + return null; + } + + return new self([$target], partialMethods: $methods, constructorArgs: $constructorArgs, autoReturn: $autoReturn); + } + + /** + * The values of a plain list literal, or null for anything else. + * + * @return list|null + */ + private static function listItems(Expr $value): ?array + { + if (!$value instanceof Array_) { + return null; + } + + $items = []; + foreach ($value->items as $item) { + if ($item === null || $item->key !== null || $item->unpack || $item->byRef) { + return null; + } + $items[] = $item->value; + } + + return $items; + } + + /** + * @return list|null + */ + private static function positional(MethodCall $call): ?array + { + $args = []; + foreach ($call->args as $arg) { + if (!$arg instanceof Arg || $arg->name !== null || $arg->unpack) { + return null; + } + $args[] = $arg->value; + } + + return $args; + } + + private static function isThis(Expr $expr): bool + { + return $expr instanceof Variable && $expr->name === 'this'; + } + + private static function name(MethodCall $call): ?string + { + return $call->name instanceof Identifier ? $call->name->toString() : null; + } +} diff --git a/bridge/rector/src/Internal/PredicateVariable.php b/bridge/rector/src/Internal/PredicateVariable.php new file mode 100644 index 00000000..d93e6f28 --- /dev/null +++ b/bridge/rector/src/Internal/PredicateVariable.php @@ -0,0 +1,40 @@ + $value > $limit`): `value`, or the first `valueN` that neither the statement's scope + * nor the statement itself already uses, so the parameter never shadows a variable the predicate reads. + * + * @internal + */ +final class PredicateVariable +{ + public static function nameFor(Expression $statement): string + { + $used = []; + foreach ((new NodeFinder())->findInstanceOf($statement->expr, Variable::class) as $variable) { + if (\is_string($variable->name)) { + $used[$variable->name] = true; + } + } + + $scope = $statement->getAttribute(AttributeKey::SCOPE); + $name = 'value'; + $suffix = 1; + while (isset($used[$name]) || ($scope instanceof Scope && $scope->hasVariableType($name)->yes())) { + $name = 'value' . (++$suffix); + } + + return $name; + } +} diff --git a/bridge/rector/src/Internal/ReturnValueMap.php b/bridge/rector/src/Internal/ReturnValueMap.php new file mode 100644 index 00000000..d11b743c --- /dev/null +++ b/bridge/rector/src/Internal/ReturnValueMap.php @@ -0,0 +1,77 @@ + array_column(array_filter($map, fn ($row) => array_slice($row, 0, -1) === $args), count($args))[0] ?? null + * + * That is `ReturnValueMap`'s own lookup: a row matches when everything but its last element is + * identical (`===`) to the call's arguments, the first matching row's last element is returned, and no + * match returns null. The map is re-read on every call, as a literal or an unchanged variable is. + * + * @internal + */ +final class ReturnValueMap +{ + /** + * The resolver for $map, or null when $map reads a variable named like one of the resolver's own + * parameters (`$args`, `$row`) — the closure would shadow it. + */ + public static function resolver(Expr $map): ?ArrowFunction + { + foreach ((new NodeFinder())->findInstanceOf($map, Variable::class) as $variable) { + if ($variable->name === 'args' || $variable->name === 'row') { + return null; + } + } + + $args = new Variable('args'); + $row = new Variable('row'); + $matches = new ArrowFunction([ + 'params' => [new Param($row)], + 'expr' => new BinaryOp\Identical( + self::func('array_slice', [new Variable('row'), new Int_(0), new UnaryMinus(new Int_(1))]), + new Variable('args'), + ), + ]); + + $lookup = new ArrayDimFetch( + self::func('array_column', [ + self::func('array_filter', [$map, $matches]), + self::func('count', [new Variable('args')]), + ]), + new Int_(0), + ); + + return new ArrowFunction([ + 'params' => [new Param($args, variadic: true)], + 'expr' => new BinaryOp\Coalesce($lookup, new ConstFetch(new Name('null'))), + ]); + } + + /** + * @param list $args + */ + private static function func(string $name, array $args): FuncCall + { + return new FuncCall(new Name($name), \array_map(static fn(Expr $arg): Arg => new Arg($arg), $args)); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector.php b/bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector.php new file mode 100644 index 00000000..34f002b4 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector.php @@ -0,0 +1,124 @@ +> + */ + #[\Override] + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + #[\Override] + public function refactor(Node $node): ?Node + { + $changed = false; + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof TraitUse || $stmt->adaptations !== []) { + continue; + } + + foreach ($stmt->traits as $index => $trait) { + if ($this->isName($trait, self::INTEGRATION_TRAIT)) { + $stmt->traits[$index] = new FullyQualified(self::VERIFIES_DOUBLES); + $changed = true; + } + } + } + + if ($node->extends !== null && $this->isName($node->extends, self::TEST_CASE) && !$this->overridesMockeryHooks($node)) { + $node->extends = new FullyQualified('PHPUnit\\Framework\\TestCase'); + if (!$this->usesVerifiesDoubles($node)) { + \array_unshift($node->stmts, new TraitUse([new FullyQualified(self::VERIFIES_DOUBLES)])); + } + $changed = true; + } + + return $changed ? $node : null; + } + + private function overridesMockeryHooks(Class_ $class): bool + { + foreach ($class->stmts as $stmt) { + if ($stmt instanceof ClassMethod && ($this->isName($stmt, 'mockeryTestSetUp') || $this->isName($stmt, 'mockeryTestTearDown'))) { + return true; + } + } + + return false; + } + + private function usesVerifiesDoubles(Class_ $class): bool + { + foreach ($class->stmts as $stmt) { + if ($stmt instanceof TraitUse) { + foreach ($stmt->traits as $trait) { + if ($this->isName($trait, self::VERIFIES_DOUBLES)) { + return true; + } + } + } + } + + return false; + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector/integration_trait.php.inc b/bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector/integration_trait.php.inc new file mode 100644 index 00000000..089d8526 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryIntegrationToDoubleRector/integration_trait.php.inc @@ -0,0 +1,29 @@ +shouldReceive('run')->once()->with('x')->andReturn('y'); + * // becomes + * $dep = \JMac\Testing\Double::for(Dependency::class)->strict(); + * $dep->expects('run')->with('x')->returns('y'); + * + * Creation: `Mockery::mock(X)` → `Double::for(X)->strict()` (a plain Mockery mock rejects an + * unconfigured call, which is Double's Strict mode), `Mockery::spy(X)` and + * `Mockery::mock(X)->shouldIgnoreMissing()` → `Double::for(X)` (Loose, the Double default), + * `Mockery::mock(X)->makePartial()` → `Double::for(X)->passthru()`, and with constructor arguments + * `Mockery::mock(X, [$a])->makePartial()` → `->passthru(new X($a))`. Several targets — `X::class` + * arguments or an `'A, B'` / `'A|B'` string — become an intersection double. A computed target converts + * by its type: a class-string like a literal one, an object as the proxied partial + * `Double::for($object)->passthru()`. Quick definitions `$m = Mockery::mock(X, ['run' => 1])` and the + * inline `$m = Mockery::mock(X)->shouldReceive(…)->…->getMock()` are split into the creation plus one + * statement per expectation. + * + * Expectations: `shouldReceive('m')` / `allows('m')` → `allows('m')`, and any call count turns it into + * `expects('m')` — `once()` is the `expects()` default, `twice()`/`times($n)` → `times($n)`, `never()` → + * `never()`, `atLeast()->once()/twice()/times($n)` → `times(minimum: …)`, `atMost()->…` → + * `times(maximum: …)`, `between($a, $b)` → `times($a, $b)`, `zeroOrMoreTimes()` → `allows()`; + * `shouldNotReceive('m')` → `expects('m')->never()`. The magic `allows()->m($x)` / `expects()->m($x)` / + * `shouldNotReceive()->m($x)` forms fold the call into `with($x)`, and several methods in one + * `shouldReceive('a', 'b')` or an `allows(['a' => 1])` / `shouldReceive(['a' => 1])` map become one + * statement per method. Arguments: `with()` keeps its values, `withArgs([...])` (or an array-typed + * value) unpacks, `withArgs($closure)` → `with(Argument::all(...))`, `withSomeOfArgs(...)` → + * `with(Argument::all())`, `withNoArgs()` → `with(Argument::none())`, + * `withAnyArgs()` drops away. Returns: `andReturn(s)` → `returns`, `andReturnValues(...)` → + * `returns(...)`, `andReturnNull/True/False()` → `returns(literal)`, `andReturnSelf()` → + * `returns()`, `andReturnArg($n)` → `resolves(fn (...$args) => $args[$n])`, + * `andReturnUsing($fn)` → `resolves($fn)`, `andThrow(s)($e)` → `throws($e)` (a class-string form builds + * the exception), `andThrowExceptions(...)` → `throws(...)`; `ordered()` carries over, a trailing + * `getMock()` on a statement drops, and `byDefault()` on an expectation with no arguments and no count + * drops too: Double tries the newest expectation first, so a later one overrides it. + * + * Verification: `shouldHaveReceived('m')` → `received('m')`, `shouldNotHaveReceived('m')` → + * `received('m')->never()`, `shouldHaveBeenCalled()` / `shouldNotHaveBeenCalled()` → the same on + * `__invoke`, the argument-list and magic `shouldHaveReceived()->m($x)` forms fold into `with()`, and the + * same count modifiers apply. `Mockery::close()` → `Double::verifyAll()`. + * + * Argument matchers become `Argument::*`: `any` → `any`, `type` → `type` (the names Double's `type()` + * does not know become an `is_*()` predicate, as Mockery checks them), `on` → `satisfies`, `capture` → + * `capture`, `pattern` → `matches`, `anyOf` → `any(…)`, `notAnyOf` → `not()->any(…)`, `not` → `not`, + * `isSame` → `same`, `hasValue` / a one-value `contains` → `contains`, `andAnyOtherArgs`/`andAnyOthers` + * → `remaining`; `mustBe($x)` and `isEqual($x)` unwrap to the bare `$x`; `hasKey`, several-value + * `contains`, `subset` and `ducktype` become the `satisfies()` predicate Mockery's matcher evaluates. + * + * All-or-nothing per statement: a chain carrying any link with no faithful Double form — `passthru()`, + * `andSet()`, `globally()`, a grouped `ordered()`, a demeter `shouldReceive('a->b')`, + * `andReturnUndefined()`, `byDefault()` with arguments or a count, a nested matcher, a Hamcrest matcher — + * is left whole, and so is the Mockery factory it roots on. So is a bare `Mockery::mock()`, the + * `alias:`/`overload:`/`X[m]` targets, a full mock with constructor arguments, and a computed target of + * unknown type. Those stay for manual migration (see TODO.md). + * + * Comparison is the residual by design: Mockery compares plain argument values loosely (`==`), Double + * strictly (`===` for scalars, `==` for objects), so a test that leaned on coercion — `'1'` against `1` — + * fails after conversion and needs its expected value fixed. + */ +#[TestRectorFixtures('MockeryToDoubleRector')] +final class MockeryToDoubleRector extends AbstractRector +{ + /** + * Set on a Mockery factory call whose configuration chain could not be converted, so the call-level + * visit leaves it as Mockery rather than handing a Double to a Mockery-only method. + */ + private const KEEP = 'testo_mockery_keep'; + + /** + * Set on the calls this rule builds, so a later visit never mistakes a rebuilt `allows()`/`expects()` + * rooted on the factory for an unconverted Mockery chain. + */ + private const REBUILT = 'testo_mockery_rebuilt'; + + /** + * Links that only Mockery spells this way: their presence tells a Mockery `allows('m')`/`expects('m')` + * chain apart from a Double one, which shares those two entry verbs. + */ + private const MOCKERY_ONLY_LINKS = [ + 'once', 'twice', 'atLeast', 'atMost', 'between', 'zeroOrMoreTimes', 'byDefault', + 'withArgs', 'withNoArgs', 'withAnyArgs', 'withSomeOfArgs', + 'andReturn', 'andReturns', 'andReturnValues', 'andReturnNull', 'andReturnTrue', 'andReturnFalse', + 'andReturnSelf', 'andReturnArg', 'andReturnUsing', 'andThrow', 'andThrows', 'andThrowExceptions', + ]; + + /** + * The type names Double's `Argument::type()` checks natively; any other name it treats as a class. + */ + private const DOUBLE_TYPES = ['int', 'float', 'string', 'bool', 'array', 'object', 'callable', 'iterable', 'null']; + + /** + * Mockery's `type()` checks `is_()` when such a function exists. These names reach a PHP type + * Double knows under another name… + */ + private const TYPE_ALIASES = ['integer' => 'int', 'long' => 'int', 'double' => 'float', 'real' => 'float']; + + /** + * …and these reach an `is_*()` check Double has no type name for. + */ + private const PREDICATE_TYPES = ['numeric', 'scalar', 'resource', 'countable', 'finite', 'infinite', 'nan']; + + /** + * The variable the predicates of the statement being rebuilt are written over. + */ + private string $predicateName = 'value'; + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Convert Mockery `mock()`/`spy()` doubles, their `shouldReceive()`/`allows()`/`expects()` chains and `shouldHaveReceived()` verification into `\JMac\Testing\Double` calls', + [ + new CodeSample( + <<<'PHP' + $dep = \Mockery::mock(Dependency::class); + $dep->shouldReceive('run')->once()->with('x')->andReturn('y'); + PHP, + <<<'PHP' + $dep = \JMac\Testing\Double::for(Dependency::class)->strict(); + $dep->expects('run')->with('x')->returns('y'); + PHP, + ), + ], + ); + } + + /** + * @return array> + */ + #[\Override] + public function getNodeTypes(): array + { + return [Expression::class, MethodCall::class, StaticCall::class]; + } + + /** + * Statement level rebuilds a whole expectation chain (and `Mockery::close()`), and splits the + * statements that set up several expectations at once; call level rewrites the factories. Rector + * visits a statement before the calls inside it, so an unconvertible chain can pin its factory before + * the factory's own visit. + * + * @param Expression|MethodCall|StaticCall $node + * @return Node|list|null + */ + #[\Override] + public function refactor(Node $node): Node|array|null + { + return match (true) { + $node instanceof Expression => $this->refactorStatement($node), + $node instanceof MethodCall => $this->refactorFactoryModifier($node), + default => $this->refactorFactory($node), + }; + } + + /** + * @return Expression|list|null + */ + private function refactorStatement(Expression $node): Expression|array|null + { + if ($node->expr instanceof StaticCall && $this->isMockeryCall($node->expr, 'close') && $node->expr->args === []) { + $node->expr = new StaticCall(new FullyQualified('JMac\\Testing\\Double'), new Identifier('verifyAll')); + + return $node; + } + + $this->predicateName = PredicateVariable::nameFor($node); + + if ($node->expr instanceof Assign) { + return $this->refactorAssignment($node->expr); + } + + if (!$node->expr instanceof MethodCall) { + return null; + } + + $segments = $this->segments($node->expr); + + # The result of a trailing `getMock()` goes nowhere in a plain statement, so it drops. + $last = $segments[\count($segments) - 1]; + if (\count($segments) > 1 && $this->callName($last) === 'getMock' && $last->args === []) { + \array_pop($segments); + } + + $chains = $this->rebuildChain($segments[0]->var, $segments); + if ($chains === null) { + return null; + } + + if (\count($chains) === 1) { + $node->expr = $chains[0]; + + return $node; + } + + return \array_map(static fn(MethodCall $chain): Expression => new Expression($chain), $chains); + } + + /** + * Splits the two assignment shapes that create a double and configure it in one expression: + * the quick definitions `$m = Mockery::mock(X, ['run' => 1])`, and the inline chain + * `$m = Mockery::mock(X)->shouldReceive('run')->andReturn(1)->getMock()`. Either becomes the Double + * creation followed by one statement per expectation, on the assigned variable. + * + * @return list|null + */ + private function refactorAssignment(Assign $assign): ?array + { + if ($this->cloneRoot($assign->var) === null) { + return null; + } + + $value = $assign->expr; + if ($value instanceof StaticCall) { + return $this->splitQuickDefinitions($assign->var, $value); + } + + if (!$value instanceof MethodCall || $this->callName($value) !== 'getMock' || $value->args !== []) { + return null; + } + + $segments = $this->segments($value); + \array_pop($segments); + if ($segments === []) { + return null; + } + + $factory = $segments[0]->var; + if (!$factory instanceof StaticCall || $this->factoryKind($factory) === null) { + return null; + } + + # A leading `shouldIgnoreMissing()` / `makePartial()` belongs to the creation, not to the chain. + $double = $this->modifiedDouble($factory, $segments[0]); + if ($double !== null) { + \array_shift($segments); + } else { + $double = $this->plainDouble($factory); + } + + if ($double === null || $segments === []) { + $factory->setAttribute(self::KEEP, true); + + return null; + } + + $root = $this->cloneRoot($assign->var); + \assert($root !== null); + $chains = $this->rebuildChain($root, $segments); + if ($chains === null) { + $factory->setAttribute(self::KEEP, true); + + return null; + } + + return [ + new Expression(new Assign($assign->var, $double)), + ...\array_map(static fn(MethodCall $chain): Expression => new Expression($chain), $chains), + ]; + } + + /** + * `$m = Mockery::mock(X, ['run' => 1])` → `$m = Double::for(X)->strict(); $m->allows('run')->returns(1);` + * + * @return list|null + */ + private function splitQuickDefinitions(Node\Expr $var, StaticCall $factory): ?array + { + $kind = $this->factoryKind($factory); + $map = ($factory->args[1] ?? null) instanceof Arg ? $factory->args[1]->value : null; + if ($kind === null || \count($factory->args) !== 2 || !$map instanceof Array_) { + return null; + } + + $entries = $this->mapEntries($map); + $targets = $this->factoryTargets(new StaticCall($factory->class, $factory->name, [$factory->args[0]])); + if ($entries === null || $entries === [] || $targets === null) { + return null; + } + + $statements = [new Expression(new Assign($var, $this->creation($kind, $targets)))]; + foreach ($entries as [$method, $return]) { + $root = $this->cloneRoot($var); + \assert($root !== null); + $call = $this->rebuilt(new MethodCall($root, new Identifier('allows'), [new Arg($method)])); + $statements[] = new Expression($this->rebuilt(new MethodCall($call, new Identifier('returns'), [new Arg($return)]))); + } + + return $statements; + } + + /** + * `Mockery::mock(X)->shouldIgnoreMissing()` → `Double::for(X)` and `->makePartial()` → + * `Double::for(X)->passthru()`. Any other Mockery-only call made straight on a factory — the inline + * `Mockery::mock(X)->shouldReceive(…)->getMock()` shape outside an assignment — pins the factory, + * since the chain it heads has no Double form. + */ + private function refactorFactoryModifier(MethodCall $node): ?Node + { + $factory = $node->var; + if (!$factory instanceof StaticCall || $node->getAttribute(self::REBUILT) === true || $this->factoryKind($factory) === null) { + return null; + } + + $double = $this->modifiedDouble($factory, $node); + if ($double !== null) { + return $double; + } + + $factory->setAttribute(self::KEEP, true); + + return null; + } + + /** + * `Mockery::mock(X)` → `Double::for(X)->strict()`, `Mockery::spy(X)` → `Double::for(X)`. + */ + private function refactorFactory(StaticCall $node): ?Node + { + return $node->getAttribute(self::KEEP) === true ? null : $this->plainDouble($node); + } + + /** + * The Double for a factory call on its own, or null when it has none. + */ + private function plainDouble(StaticCall $factory): ?Node\Expr + { + $kind = $this->factoryKind($factory); + if ($kind === null) { + return null; + } + + # `Mockery::mock($object)` is a proxied partial: the real object answers what is not configured. + $proxied = $kind === 'mock' ? $this->proxiedObject($factory) : null; + if ($proxied !== null) { + return new MethodCall($this->doubleFor([new Arg($proxied)]), new Identifier('passthru')); + } + + $targets = $this->factoryTargets($factory); + + return $targets === null ? null : $this->creation($kind, $targets); + } + + /** + * The Double for a factory followed by `shouldIgnoreMissing()` or `makePartial()`, or null for any + * other call on the factory. + */ + private function modifiedDouble(StaticCall $factory, MethodCall $modifier): ?Node\Expr + { + if ($this->factoryKind($factory) !== 'mock' || $modifier->args !== []) { + return null; + } + + $name = $this->callName($modifier); + + if ($name === 'shouldIgnoreMissing') { + $targets = $this->factoryTargets($factory); + + return $targets === null ? null : $this->doubleFor($targets); + } + + if ($name !== 'makePartial') { + return null; + } + + # `Mockery::mock(X, [$a, $b])->makePartial()` runs the real constructor with those arguments; a + # passthru double copies the state of a real instance built the same way. + $constructorArgs = ($factory->args[1] ?? null) instanceof Arg ? $factory->args[1]->value : null; + if ($constructorArgs !== null) { + $items = \count($factory->args) === 2 ? $this->listItems($constructorArgs) : null; + $targets = $this->factoryTargets(new StaticCall($factory->class, $factory->name, [$factory->args[0]])); + $class = $targets !== null && \count($targets) === 1 ? $this->className($targets[0]->value) : null; + if ($items === null || $class === null) { + return null; + } + + return new MethodCall( + $this->doubleFor($targets), + new Identifier('passthru'), + [new Arg(new New_($class, \array_map(static fn(Node\Expr $item): Arg => new Arg($item), $items)))], + ); + } + + $targets = $this->factoryTargets($factory); + + return $targets === null ? null : new MethodCall($this->doubleFor($targets), new Identifier('passthru')); + } + + /** + * @param 'mock'|'spy' $kind + * @param list $targets + */ + private function creation(string $kind, array $targets): Node\Expr + { + $double = $this->doubleFor($targets); + + return $kind === 'spy' ? $double : new MethodCall($double, new Identifier('strict')); + } + + /** + * `'mock'`/`'spy'` for a `Mockery::mock()`/`Mockery::spy()` call, null for anything else. + * + * @return 'mock'|'spy'|null + */ + private function factoryKind(StaticCall $node): ?string + { + return match (true) { + $this->isMockeryCall($node, 'mock') => 'mock', + $this->isMockeryCall($node, 'spy') => 'spy', + default => null, + }; + } + + /** + * The doubled types of a factory call when each argument names plain target types: an `X::class` + * fetch, a string literal of one type or of a `'A, B'` / `'A|B'` list, or a computed value PHPStan + * knows is a string. Null for no arguments (Double needs a type) or anything Mockery would read as + * configuration — an array, a closure, an object, the `alias:`/`overload:`/`X[m]` target syntax. + * + * @return list|null + */ + private function factoryTargets(StaticCall $node): ?array + { + if ($node->args === []) { + return null; + } + + $targets = []; + foreach ($node->args as $arg) { + if (!$arg instanceof Arg || $arg->name !== null || $arg->unpack) { + return null; + } + + $value = $arg->value; + if ($value instanceof ClassConstFetch && $this->isName($value->name, 'class')) { + $targets[] = new Arg($value); + continue; + } + + if ($value instanceof String_) { + foreach (\preg_split('/\s*[,|]\s*/', \trim($value->value)) ?: [] as $type) { + if (\preg_match('/^\\\\?[A-Za-z_][\w\\\\]*$/', $type) !== 1) { + return null; + } + $targets[] = new Arg(new String_($type)); + } + continue; + } + + if ($value instanceof Array_ || $value instanceof Closure || $value instanceof ArrowFunction || !$this->getType($value)->isString()->yes()) { + return null; + } + + $targets[] = new Arg($value); + } + + return $targets; + } + + /** + * The single object argument of a `Mockery::mock($object)` call, as PHPStan types it. + */ + private function proxiedObject(StaticCall $factory): ?Node\Expr + { + $arg = $factory->args[0] ?? null; + if (\count($factory->args) !== 1 || !$arg instanceof Arg || $arg->name !== null || $arg->unpack) { + return null; + } + + $value = $arg->value; + if ($value instanceof Closure || $value instanceof ArrowFunction || $value instanceof New_) { + return null; + } + + return $this->getType($value)->isObject()->yes() ? $value : null; + } + + /** + * The class named by a target argument, for building a real instance of it. + */ + private function className(Node\Expr $target): ?Name + { + return match (true) { + $target instanceof ClassConstFetch && $target->class instanceof Name => $target->class, + $target instanceof String_ => new FullyQualified(\ltrim($target->value, '\\')), + default => null, + }; + } + + /** + * The chain's calls, innermost first. + * + * @return non-empty-list + */ + private function segments(MethodCall $node): array + { + $segments = []; + $cursor = $node; + while ($cursor instanceof MethodCall) { + $segments[] = $cursor; + $cursor = $cursor->var; + } + + return \array_reverse($segments); + } + + /** + * Rebuilds an expectation or verification chain into its Double form — one chain per method it sets + * up — or returns null when the statement is not a Mockery chain or carries a link with no faithful + * counterpart. An unconvertible chain rooted on a Mockery factory pins that factory (see + * {@see self::KEEP}). + * + * @param non-empty-list $segments + * @return non-empty-list|null + */ + private function rebuildChain(Node\Expr $root, array $segments): ?array + { + $entry = $this->parseEntry($segments); + if ($entry === null) { + return null; + } + + $chains = $this->rebuildLinks($root, $entry, \array_slice($segments, $entry['consumed'])); + if ($chains === null && $entry['certain']) { + $this->pinFactory($root); + } + + return $chains; + } + + /** + * Reads the chain's opening call: the verb, the methods it targets (each with the return value an + * array form assigns it) and any arguments the entry itself carries (the magic `allows()->m($x)` + * form, the `shouldHaveReceived('m', [$x])` list). `certain` says whether the entry alone proves a + * Mockery chain — `allows`/`expects` with a method name are Double verbs too, so those still need a + * Mockery-only link further down. `final` marks an entry Mockery returns the mock from, so nothing may + * follow it. + * + * @param non-empty-list $segments + * @return array{verb: 'allows'|'expects'|'received', methods: non-empty-list, args: list|null, count: array{0: string, 1: list}|null, certain: bool, consumed: int, final: bool}|null + */ + private function parseEntry(array $segments): ?array + { + $first = $segments[0]; + $name = $this->callName($first); + $never = ['never', []]; + + # Magic form: `$m->allows()->run($x)`, `$m->shouldHaveReceived()->run($x)`, … — the second call + # names the method and carries its arguments (an empty list means "called with no arguments"). + $magicVerbs = ['allows', 'expects', 'shouldReceive', 'shouldNotReceive', 'shouldHaveReceived', 'shouldNotHaveReceived']; + if ($first->args === [] && \in_array($name, $magicVerbs, true)) { + $magic = $segments[1] ?? null; + $method = $magic !== null ? $this->callName($magic) : null; + $args = $magic !== null ? $this->plainArgs($magic->args) : null; + if ($method === null || $args === null) { + return null; + } + + return [ + 'verb' => match ($name) { + 'allows', 'shouldReceive', 'shouldNotReceive' => 'allows', + 'expects' => 'expects', + default => 'received', + }, + 'methods' => [[new String_($method), null]], + 'args' => $args, + 'count' => $name === 'shouldNotReceive' || $name === 'shouldNotHaveReceived' ? $never : null, + 'certain' => true, + 'consumed' => 2, + 'final' => false, + ]; + } + + $simple = static fn(string $verb, array $methods, ?array $count, bool $certain, bool $final = false): array => [ + 'verb' => $verb, 'methods' => $methods, 'args' => null, 'count' => $count, 'certain' => $certain, 'consumed' => 1, 'final' => $final, + ]; + + switch ($name) { + case 'shouldReceive': + case 'shouldNotReceive': + $methods = $this->entryMethods($first->args, $name === 'shouldReceive'); + + return $methods === null ? null : $simple('allows', $methods, $name === 'shouldNotReceive' ? $never : null, true); + case 'allows': + $arg = $this->singleArg($first->args); + if ($arg instanceof Array_) { + # `allows(['run' => 1])` sets up each method and returns the mock, not an expectation. + $methods = $this->mapEntries($arg); + + return $methods === null || $methods === [] ? null : $simple('allows', $methods, null, true, true); + } + // no break + case 'expects': + $methods = $this->entryMethods($first->args, false); + + return $methods === null || \count($first->args) !== 1 ? null : $simple($name, $methods, null, false); + case 'shouldHaveReceived': + case 'shouldNotHaveReceived': + return $this->parseReceivedEntry($first, $name === 'shouldNotHaveReceived'); + case 'shouldHaveBeenCalled': + case 'shouldNotHaveBeenCalled': + return $this->parseInvokeEntry($first, $name === 'shouldNotHaveBeenCalled'); + default: + return null; + } + } + + /** + * The methods of a `shouldReceive(...)` / `shouldNotReceive(...)` / `allows('m')` entry: each name, + * and — for `shouldReceive` — each `['m' => $return]` map. A demeter `'a->b'`, a computed map, a call + * or a named argument has no Double form. + * + * @param list $args + * @return non-empty-list|null + */ + private function entryMethods(array $args, bool $allowMaps): ?array + { + $methods = []; + foreach ($args as $arg) { + if (!$arg instanceof Arg || $arg->name !== null || $arg->unpack) { + return null; + } + + $value = $arg->value; + if ($value instanceof Array_) { + $entries = $allowMaps ? $this->mapEntries($value) : null; + if ($entries === null) { + return null; + } + \array_push($methods, ...$entries); + continue; + } + + # A computed name converts only when it is certainly a string: Mockery reads an array as a map. + if (!$value instanceof String_ && !$this->getType($value)->isString()->yes()) { + return null; + } + + if ($value instanceof String_ && \str_contains($value->value, '->')) { + return null; + } + + $methods[] = [$value, null]; + } + + return $methods === [] ? null : $methods; + } + + /** + * `shouldHaveReceived('m')` / `shouldHaveReceived('m', [$a, $b])` / `…('m', $closure)` and the + * `shouldNotHaveReceived` twins. + * + * @return array{verb: 'received', methods: non-empty-list, args: list|null, count: array{0: string, 1: list}|null, certain: bool, consumed: int, final: bool}|null + */ + private function parseReceivedEntry(MethodCall $entry, bool $never): ?array + { + $methods = \count($entry->args) <= 2 ? $this->entryMethods(\array_slice($entry->args, 0, 1), false) : null; + if ($methods === null) { + return null; + } + + $args = null; + $argList = $entry->args[1] ?? null; + if ($argList !== null) { + $args = $argList instanceof Arg ? $this->argumentList($argList->value) : null; + if ($args === null) { + return null; + } + } + + return [ + 'verb' => 'received', + 'methods' => $methods, + 'args' => $args, + 'count' => $never ? ['never', []] : null, + 'certain' => true, + 'consumed' => 1, + 'final' => false, + ]; + } + + /** + * `shouldHaveBeenCalled()` / `shouldNotHaveBeenCalled($args)`: Mockery's verification of a mocked + * callable, which is a call to `__invoke`. + * + * @return array{verb: 'received', methods: non-empty-list, args: list|null, count: array{0: string, 1: list}|null, certain: bool, consumed: int, final: bool}|null + */ + private function parseInvokeEntry(MethodCall $entry, bool $never): ?array + { + $args = null; + if ($entry->args !== []) { + $list = $never && \count($entry->args) === 1 && $entry->args[0] instanceof Arg ? $entry->args[0]->value : null; + $args = $list === null ? null : $this->argumentList($list); + if ($args === null) { + return null; + } + } + + return [ + 'verb' => 'received', + 'methods' => [[new String_('__invoke'), null]], + 'args' => $args, + 'count' => $never ? ['never', []] : null, + 'certain' => true, + 'consumed' => 1, + 'final' => false, + ]; + } + + /** + * An argument list given as one value — `withArgs($list)`, `shouldHaveReceived('m', $list)` — as the + * arguments of Double's `with()`: an array literal unpacks in place, an array-typed value spreads, + * and a closure (or other callable) checks the whole list through `Argument::all()`. + * + * @return list|null + */ + private function argumentList(Node\Expr $list): ?array + { + if ($list instanceof Array_) { + $items = $this->listItems($list); + + return $items === null ? null : \array_map(static fn(Node\Expr $item): Arg => new Arg($item), $items); + } + + if ($list instanceof Closure || $list instanceof ArrowFunction) { + return [new Arg($this->argument('all', [new Arg($list)]))]; + } + + $type = $this->getType($list); + if ($type->isArray()->yes()) { + return [new Arg($list, unpack: true)]; + } + + return $type->isCallable()->yes() ? [new Arg($this->argument('all', [new Arg($list)]))] : null; + } + + /** + * The `['method' => $return]` pairs of a literal map; null for a computed key, a spread or a list. + * + * @return list|null + */ + private function mapEntries(Array_ $map): ?array + { + $entries = []; + foreach ($map->items as $item) { + if ($item === null || !$item->key instanceof String_ || $item->unpack || $item->byRef) { + return null; + } + $entries[] = [$item->key, $item->value]; + } + + return $entries; + } + + /** + * Folds the chain's remaining links into one Double chain per method. Links are emitted in source + * order; a call count is emitted where its last link sits (`atLeast()->times(2)` is one count). A + * chain that sets up several methods repeats every link on each, so it needs a root and link + * arguments that are safe to evaluate once per method. + * + * @param array{verb: 'allows'|'expects'|'received', methods: non-empty-list, args: list|null, count: array{0: string, 1: list}|null, certain: bool, consumed: int, final: bool} $entry + * @param list $links + * @return non-empty-list|null + */ + private function rebuildLinks(Node\Expr $root, array $entry, array $links): ?array + { + if ($entry['final'] && $links !== []) { + return null; + } + + $isVerification = $entry['verb'] === 'received'; + $mockeryOnly = $entry['certain']; + $tail = []; + $count = $entry['count']; + $countPosition = null; + $bound = null; + $byDefault = false; + $narrowed = false; + + if ($entry['args'] !== null) { + $with = $this->mapArgumentList($entry['args']); + if ($with === null) { + return null; + } + $tail[] = ['with', $with]; + $narrowed = true; + } + + foreach ($links as $link) { + $name = $this->callName($link); + if ($name === null) { + return null; + } + + $mockeryOnly = $mockeryOnly || \in_array($name, self::MOCKERY_ONLY_LINKS, true); + + # `atLeast()` / `atMost()` only qualify the count that follows them. + if (($name === 'atLeast' || $name === 'atMost') && $link->args === [] && $bound === null) { + $bound = $name === 'atLeast' ? 'minimum' : 'maximum'; + continue; + } + + $parsedCount = $this->parseCount($name, $link->args, $bound); + if ($parsedCount !== false) { + if ($parsedCount === null || $count !== null && $count[0] !== 'allows') { + return null; + } + + $count = $parsedCount; + $countPosition = \count($tail); + $bound = null; + continue; + } + + if ($bound !== null) { + return null; + } + + if ($name === 'byDefault' && !$isVerification) { + if ($link->args !== []) { + return null; + } + $byDefault = true; + continue; + } + + $mapped = $isVerification + ? $this->mapVerificationLink($name, $link->args) + : $this->mapExpectationLink($name, $link->args, $root); + if ($mapped === false) { + return null; + } + + if ($mapped !== null) { + $narrowed = $narrowed || $mapped[0] === 'with'; + $tail[] = $mapped; + } + } + + if ($bound !== null || !$mockeryOnly) { + return null; + } + + # A default expectation is overridable only while it is the least specific one Double could pick. + if ($byDefault && ($narrowed || $count !== null)) { + return null; + } + + [$verb, $countCall] = $this->resolveVerb($entry['verb'], $count); + if ($countCall !== null) { + \array_splice($tail, $countPosition ?? \count($tail), 0, [$countCall]); + } + + $methods = $entry['methods']; + if (\count($methods) > 1 && ($this->cloneRoot($root) === null || !$this->isRepeatable($tail))) { + return null; + } + + $chains = []; + foreach ($methods as $index => [$method, $return]) { + $chainRoot = $index === 0 ? $root : $this->cloneRoot($root); + \assert($chainRoot !== null); + + $result = $this->rebuilt(new MethodCall($chainRoot, new Identifier($verb), [new Arg($method)])); + if ($return !== null) { + $result = $this->rebuilt(new MethodCall($result, new Identifier('returns'), [new Arg($return)])); + } + + foreach ($tail as [$call, $args]) { + $result = $this->rebuilt(new MethodCall($result, new Identifier($call), $index === 0 ? $args : $this->copyArgs($args))); + } + + $chains[] = $result; + } + + return $chains; + } + + /** + * Whether the tail's arguments can be evaluated once per method without changing what the test does: + * values, variables, constants and closures, and the pure `Argument::*` matcher calls — nothing that + * constructs, calls or assigns. + * + * @param list}> $tail + */ + private function isRepeatable(array $tail): bool + { + foreach ($tail as [, $args]) { + foreach ($args as $arg) { + if ($arg instanceof Arg && !$this->isPureExpr($arg->value)) { + return false; + } + } + } + + return true; + } + + private function isPureExpr(Node\Expr $expr): bool + { + return match (true) { + $expr instanceof Scalar, $expr instanceof ConstFetch, $expr instanceof ClassConstFetch, $expr instanceof Variable, + $expr instanceof Closure, $expr instanceof ArrowFunction => true, + $expr instanceof PropertyFetch => $this->isPureExpr($expr->var), + $expr instanceof UnaryMinus => $this->isPureExpr($expr->expr), + $expr instanceof Array_ => \array_reduce( + $expr->items, + fn(bool $pure, $item): bool => $pure && $item !== null && $this->isPureExpr($item->value) && ($item->key === null || $this->isPureExpr($item->key)), + true, + ), + $expr instanceof StaticCall => $this->isArgumentCall($expr) && $this->allPure($expr->args), + $expr instanceof MethodCall => $this->isArgumentCall($expr->var) && $this->allPure($expr->args), + default => false, + }; + } + + /** + * @param list $args + */ + private function allPure(array $args): bool + { + foreach ($args as $arg) { + if (!$arg instanceof Arg || !$this->isPureExpr($arg->value)) { + return false; + } + } + + return true; + } + + private function isArgumentCall(Node\Expr $expr): bool + { + return $expr instanceof StaticCall && $this->isName($expr->class, 'JMac\\Testing\\Matching\\Argument'); + } + + /** + * @param list $args + * @return list + */ + private function copyArgs(array $args): array + { + return \array_map( + static fn(Arg|VariadicPlaceholder $arg): Arg|VariadicPlaceholder => $arg instanceof Arg + ? new Arg(PhpunitConstraint::copy($arg->value), $arg->byRef, $arg->unpack, name: $arg->name) + : $arg, + $args, + ); + } + + /** + * A call-count link as `[kind, args]` — `exact`, `minimum`, `maximum`, `range`, `never` or `allows` + * (zero or more) — or false when the link is not a count at all, and null when it is a count with no + * Double form (a `between()` under `atLeast()`, named or spread arguments). + * + * @param list $args + * @return array{0: string, 1: list}|false|null + */ + private function parseCount(string $name, array $args, ?string $bound): array|false|null + { + $positional = $this->positionalValues($args); + + switch ($name) { + case 'never': + case 'zeroOrMoreTimes': + return $args === [] && $bound === null ? [$name === 'never' ? 'never' : 'allows', []] : null; + case 'between': + return $bound === null && $positional !== null && \count($positional) === 2 + ? ['range', [new Arg($positional[0]), new Arg($positional[1])]] + : null; + case 'once': + case 'twice': + $value = $args === [] ? new Int_($name === 'once' ? 1 : 2) : null; + break; + case 'times': + $value = $positional !== null && \count($positional) === 1 ? $positional[0] : null; + break; + default: + return false; + } + + if ($value === null) { + return null; + } + + return [$bound ?? 'exact', [$bound === null ? new Arg($value) : new Arg($value, name: new Identifier($bound))]]; + } + + /** + * The argument values of a call when every argument is plain and positional, null otherwise. + * + * @param list $args + * @return list|null + */ + private function positionalValues(array $args): ?array + { + $values = []; + foreach ($args as $arg) { + if (!$arg instanceof Arg || $arg->name !== null || $arg->unpack) { + return null; + } + $values[] = $arg->value; + } + + return $values; + } + + /** + * @param list $args + * @return list|null + */ + private function plainArgs(array $args): ?array + { + $values = $this->positionalValues($args); + + return $values === null ? null : \array_map(static fn(Node\Expr $value): Arg => new Arg($value), $values); + } + + /** + * The Double entry verb and trailing count call for a parsed count: an expectation with no count stays + * `allows()` (Mockery's `shouldReceive()`/`allows()` default is zero or more), any count makes it + * `expects()` — whose own default is exactly once, so `once()` needs no `times()`. A verification keeps + * `received()`, whose default is at least once. + * + * @param array{0: string, 1: list}|null $count + * @return array{0: string, 1: array{0: string, 1: list}|null} + */ + private function resolveVerb(string $entryVerb, ?array $count): array + { + if ($entryVerb === 'received') { + return ['received', $this->countCall($count)]; + } + + if ($count === null) { + return [$entryVerb, null]; + } + + if ($count[0] === 'allows') { + return ['allows', null]; + } + + $isOnce = $count[0] === 'exact' && $count[1][0]->value instanceof Int_ && $count[1][0]->value->value === 1; + + return ['expects', $isOnce ? null : $this->countCall($count)]; + } + + /** + * @param array{0: string, 1: list}|null $count + * @return array{0: string, 1: list}|null + */ + private function countCall(?array $count): ?array + { + return match ($count[0] ?? null) { + null, 'allows' => null, + 'never' => ['never', []], + default => ['times', $count[1]], + }; + } + + /** + * Maps an expectation link to `[method, args]`, null for a link that drops away, or false when it has + * no faithful Double form. + * + * @param list $args + * @return array{0: string, 1: list}|false|null + */ + private function mapExpectationLink(string $name, array $args, Node\Expr $root): array|false|null + { + return match ($name) { + 'with', 'withArgs', 'withNoArgs', 'withAnyArgs', 'withSomeOfArgs' => $this->mapArgumentLink($name, $args), + 'andReturn', 'andReturns' => ['returns', $args === [] ? [new Arg($this->literal('null'))] : $args], + 'andReturnValues' => $this->mapSpreadList('returns', $args), + 'andReturnNull' => $args === [] ? ['returns', [new Arg($this->literal('null'))]] : false, + 'andReturnTrue' => $args === [] ? ['returns', [new Arg($this->literal('true'))]] : false, + 'andReturnFalse' => $args === [] ? ['returns', [new Arg($this->literal('false'))]] : false, + 'andReturnSelf' => $args === [] ? $this->returnSelf($root) : false, + 'andReturnArg' => $this->mapReturnArg($args), + 'andReturnUsing' => \count($args) === 1 ? ['resolves', $args] : false, + 'andThrow', 'andThrows' => $this->mapThrow($args), + 'andThrowExceptions' => $this->mapSpreadList('throws', $args), + 'ordered' => $args === [] ? ['ordered', []] : false, + default => false, + }; + } + + /** + * A verification chain only narrows arguments and counts; a return verb on it has no meaning. + * + * @param list $args + * @return array{0: string, 1: list}|false|null + */ + private function mapVerificationLink(string $name, array $args): array|false|null + { + return match ($name) { + 'with', 'withArgs', 'withNoArgs', 'withAnyArgs', 'withSomeOfArgs' => $this->mapArgumentLink($name, $args), + default => false, + }; + } + + /** + * `with(...)` keeps its values (each matcher mapped), `withArgs($list)` goes through + * {@see argumentList()}, `withSomeOfArgs(...)` → a strict `in_array()` check per value over the whole + * argument list, `withNoArgs()` → `with(Argument::none())`, `withAnyArgs()` drops. + * + * @param list $args + * @return array{0: string, 1: list}|false|null + */ + private function mapArgumentLink(string $name, array $args): array|false|null + { + if ($name === 'withAnyArgs') { + return $args === [] ? null : false; + } + + if ($name === 'withNoArgs') { + return $args === [] ? ['with', [new Arg($this->argument('none'))]] : false; + } + + if ($name === 'withSomeOfArgs') { + $all = $this->someOfArgs($args); + + return $all === null ? false : ['with', [new Arg($all)]]; + } + + if ($name === 'withArgs') { + $list = \count($args) === 1 && $args[0] instanceof Arg ? $args[0]->value : null; + $listArgs = $list === null ? null : $this->argumentList($list); + if ($listArgs === null) { + return false; + } + + # An argument list checked by a closure, or spread at run time, has no matchers to map. + if (!$list instanceof Array_) { + return ['with', $listArgs]; + } + + $args = $listArgs; + } + + $plain = $this->plainArgs($args); + $mapped = $plain === null ? null : $this->mapArgumentList($plain); + + return $mapped === null ? false : ['with', $mapped]; + } + + /** + * `withSomeOfArgs($a, $b)` → `Argument::all(fn (...$args) => in_array($a, $args, true) && …)`, the + * check Mockery runs. A value reading a variable named `$args` would be shadowed by the parameter. + * + * @param list $args + */ + private function someOfArgs(array $args): ?StaticCall + { + $values = $this->positionalValues($args); + if ($values === null || $values === []) { + return null; + } + + $check = null; + foreach ($values as $value) { + if ($this->isMockeryMatcher($value) || $this->readsVariable($value, 'args')) { + return null; + } + + $inArray = new FuncCall(new Name('in_array'), [new Arg($value), new Arg(new Variable('args')), new Arg($this->literal('true'))]); + $check = $check === null ? $inArray : new BinaryOp\BooleanAnd($check, $inArray); + } + + return $this->argument('all', [new Arg(new ArrowFunction([ + 'params' => [new Param(new Variable('args'), variadic: true)], + 'expr' => $check, + ]))]); + } + + /** + * @param list $args + * @return list|null + */ + private function mapArgumentList(array $args): ?array + { + # The magic `allows()->m()` form with no arguments means "called with none". + if ($args === []) { + return [new Arg($this->argument('none'))]; + } + + $mapped = []; + foreach ($args as $arg) { + if ($arg->unpack) { + $mapped[] = $arg; + continue; + } + + $matcher = $this->mapMatcher($arg->value); + if ($matcher === null) { + return null; + } + + $mapped[] = new Arg($matcher); + } + + return $mapped; + } + + /** + * Maps one argument expectation: a plain value passes through, a `Mockery::*` matcher becomes its + * `Argument::*` counterpart (or a bare value, or a predicate), and a matcher with no faithful form + * returns null to abort the chain. A Hamcrest matcher (a global function call) aborts too. + */ + private function mapMatcher(Node\Expr $value): ?Node\Expr + { + if ($value instanceof FuncCall && $value->name instanceof Name && $this->isHamcrestMatcher($value->name->toString())) { + return null; + } + + if (!$this->isMockeryMatcher($value)) { + return $value; + } + + \assert($value instanceof StaticCall); + $name = $this->callName($value); + $values = $this->positionalValues($value->args); + if ($values === null) { + return null; + } + + $args = []; + foreach ($values as $argValue) { + # A matcher nested in another (`Mockery::not(Mockery::type(...))`) is compared as a plain + # object by Mockery and rejected outright by Double. + if ($this->isMockeryMatcher($argValue)) { + return null; + } + $args[] = new Arg($argValue); + } + $first = $values[0] ?? null; + $single = \count($args) === 1; + + return match ($name) { + 'any' => $args === [] ? $this->argument('any') : null, + 'type' => $single ? $this->typeMatcher($first) : null, + 'on' => $single ? $this->argument('satisfies', $args) : null, + 'capture' => $single ? $this->argument('capture', $args) : null, + 'pattern' => $single ? $this->argument('matches', $args) : null, + 'anyOf' => $args !== [] ? $this->argument('any', $args) : null, + 'notAnyOf' => $args !== [] ? new MethodCall($this->argument('not'), new Identifier('any'), $args) : null, + 'not' => $single ? $this->argument('not', $args) : null, + 'isSame' => $single ? $this->argument('same', $args) : null, + 'mustBe', 'isEqual' => $single ? $first : null, + 'hasValue' => $single ? $this->argument('contains', $args) : null, + 'contains' => match (true) { + $single => $this->argument('contains', $args), + $args !== [] => $this->containsAll($values), + default => null, + }, + 'hasKey' => $single ? $this->hasKeyPredicate($first) : null, + 'subset' => $this->subsetPredicate($values), + 'ducktype' => $args !== [] ? $this->ducktypePredicate($values) : null, + 'andAnyOtherArgs', 'andAnyOthers' => $args === [] ? $this->argument('remaining') : null, + default => null, + }; + } + + /** + * `Mockery::type('integer')` → `Argument::type('int')`. Mockery checks a name through `is_()` + * when that function exists, else as a class: the names Double spells differently are normalised, + * the `is_*()` checks Double has no name for become a predicate, and a class name passes through. + */ + private function typeMatcher(?Node\Expr $type): ?Node\Expr + { + if (!$type instanceof String_) { + return $type === null ? null : $this->argument('type', [new Arg($type)]); + } + + $lower = \strtolower($type->value); + $name = self::TYPE_ALIASES[$lower] ?? $lower; + + if (\in_array($name, self::DOUBLE_TYPES, true)) { + return $this->argument('type', [new Arg(new String_($name))]); + } + + if (\in_array($name, self::PREDICATE_TYPES, true)) { + return $this->satisfies($this->func('is_' . $name, [$this->value()])); + } + + return $this->argument('type', [new Arg($type)]); + } + + /** + * `Mockery::hasKey($k)` → `(is_array($value) || $value instanceof \ArrayAccess) && array_key_exists($k, (array) $value)`, + * as Mockery's `HasKey` checks it. + */ + private function hasKeyPredicate(?Node\Expr $key): ?StaticCall + { + if ($key === null) { + return null; + } + + return $this->satisfies(new BinaryOp\BooleanAnd( + new BinaryOp\BooleanOr( + $this->func('is_array', [$this->value()]), + new Instanceof_($this->value(), new FullyQualified('ArrayAccess')), + ), + $this->func('array_key_exists', [$key, new Cast\Array_($this->value())]), + )); + } + + /** + * `Mockery::contains($a, $b)` → every value is in the array, compared loosely as Mockery does. + * + * @param list $values + */ + private function containsAll(array $values): StaticCall + { + $check = $this->func('is_array', [$this->value()]); + foreach ($values as $value) { + $check = new BinaryOp\BooleanAnd($check, $this->func('in_array', [$value, $this->value()])); + } + + return $this->satisfies($check); + } + + /** + * `Mockery::subset($part, $strict)` → `is_array($value) && array_replace_recursive($value, $part) === $value`, + * Mockery's own check (`==` when not strict). The flag must be a literal. + * + * @param list $values + */ + private function subsetPredicate(array $values): ?StaticCall + { + $part = $values[0] ?? null; + $strict = $values[1] ?? $this->literal('true'); + if ($part === null || \count($values) > 2 || !$strict instanceof ConstFetch) { + return null; + } + + $flag = \strtolower($strict->name->toString()); + if ($flag !== 'true' && $flag !== 'false') { + return null; + } + + $replaced = $this->func('array_replace_recursive', [$this->value(), $part]); + $comparison = $flag === 'true' + ? new BinaryOp\Identical($replaced, $this->value()) + : new BinaryOp\Equal($replaced, $this->value()); + + return $this->satisfies(new BinaryOp\BooleanAnd($this->func('is_array', [$this->value()]), $comparison)); + } + + /** + * `Mockery::ducktype('a', 'b')` → an object that has each of those methods. + * + * @param list $methods + */ + private function ducktypePredicate(array $methods): StaticCall + { + $check = $this->func('is_object', [$this->value()]); + foreach ($methods as $method) { + $check = new BinaryOp\BooleanAnd($check, $this->func('method_exists', [$this->value(), $method])); + } + + return $this->satisfies($check); + } + + private function isHamcrestMatcher(string $function): bool + { + return \in_array(\strtolower(\ltrim($function, '\\')), [ + 'anything', 'equalto', 'identicalto', 'typeof', 'aninstanceof', 'containsstring', 'hasitem', + 'hasentry', 'haskey', 'hasvalue', 'greaterthan', 'lessthan', 'stringstartswith', 'stringendswith', + 'matchespattern', 'nullvalue', 'notnullvalue', 'not', 'anyof', 'allof', 'is', 'isnonemptystring', + 'emptyarray', 'arraywithsize', 'closeto', + ], true); + } + + /** + * `andReturnValues([$a, $b])` → `returns($a, $b)`, and a computed list spreads: `returns(...$list)`. + * The same for `andThrowExceptions()` → `throws()`. + * + * @param list $args + * @return array{0: string, 1: list}|false + */ + private function mapSpreadList(string $verb, array $args): array|false + { + $list = \count($args) === 1 && $args[0] instanceof Arg && !$args[0]->unpack ? $args[0]->value : null; + if ($list === null) { + return false; + } + + if ($list instanceof Array_) { + $items = $this->listItems($list); + + return $items === null || $items === [] ? false : [$verb, \array_map(static fn(Node\Expr $item): Arg => new Arg($item), $items)]; + } + + return $this->getType($list)->isArray()->yes() ? [$verb, [new Arg($list, unpack: true)]] : false; + } + + /** + * `andReturnArg($n)` → `resolves(fn (...$args) => $args[$n])`. + * + * @param list $args + * @return array{0: string, 1: list}|false + */ + private function mapReturnArg(array $args): array|false + { + if (\count($args) !== 1 || !$args[0] instanceof Arg) { + return false; + } + + $resolver = new ArrowFunction([ + 'params' => [new Param(new Variable('args'), null, null, false, true)], + 'expr' => new ArrayDimFetch(new Variable('args'), $args[0]->value), + ]); + + return ['resolves', [new Arg($resolver)]]; + } + + /** + * `andThrow($e)` → `throws($e)`; Mockery ignores a message or code passed next to an exception + * object, so literal ones drop. The class-string form `andThrow(X::class, $message, $code)` builds the + * exception Mockery would have built, `throws(new X($message, $code))`. + * + * @param list $args + * @return array{0: string, 1: list}|false + */ + private function mapThrow(array $args): array|false + { + $values = $this->positionalValues($args); + if ($values === null || $values === [] || \count($values) > 4) { + return false; + } + + $exception = \array_shift($values); + $class = match (true) { + $exception instanceof ClassConstFetch && $this->isName($exception->name, 'class') && $exception->class instanceof Name => $exception->class, + $exception instanceof String_ => new FullyQualified(\ltrim($exception->value, '\\')), + default => null, + }; + + if ($class !== null) { + return ['throws', [new Arg(new New_($class, \array_map(static fn(Node\Expr $value): Arg => new Arg($value), $values)))]]; + } + + foreach ($values as $ignored) { + if (!$ignored instanceof Scalar && !$ignored instanceof ConstFetch) { + return false; + } + } + + return ['throws', [new Arg($exception)]]; + } + + /** + * `andReturnSelf()` → `returns()`: only a local variable or a `$this->prop` root can be + * repeated safely; anything else aborts. + * + * @return array{0: string, 1: list}|false + */ + private function returnSelf(Node\Expr $root): array|false + { + $self = $this->cloneRoot($root); + + return $self === null ? false : ['returns', [new Arg($self)]]; + } + + /** + * A fresh copy of the double's root: a local variable or a `$this->prop` property. Null for anything + * else, which cannot be repeated without evaluating it again. + */ + private function cloneRoot(Node\Expr $root): ?Node\Expr + { + if ($root instanceof Variable && \is_string($root->name)) { + return new Variable($root->name); + } + + if ( + $root instanceof PropertyFetch + && $root->var instanceof Variable + && \is_string($root->var->name) + && $root->name instanceof Identifier + ) { + return new PropertyFetch(new Variable($root->var->name), new Identifier($root->name->toString())); + } + + return null; + } + + /** + * The values of a plain list literal, or null when the expression is not one (keys, spreads or holes + * would change what the call receives). + * + * @return list|null + */ + private function listItems(Node\Expr $value): ?array + { + if (!$value instanceof Array_) { + return null; + } + + $items = []; + foreach ($value->items as $item) { + if ($item === null || $item->key !== null || $item->unpack || $item->byRef) { + return null; + } + + $items[] = $item->value; + } + + return $items; + } + + /** + * Pins the Mockery factory an unconvertible chain roots on — `Mockery::mock(X)->shouldReceive(...)` + * used inline — so it stays Mockery. + */ + private function pinFactory(Node\Expr $root): void + { + if ($root instanceof StaticCall && $this->factoryKind($root) !== null) { + $root->setAttribute(self::KEEP, true); + } + } + + private function rebuilt(MethodCall $call): MethodCall + { + $call->setAttribute(self::REBUILT, true); + + return $call; + } + + private function isMockeryCall(StaticCall $node, string $method): bool + { + return $this->isName($node->class, 'Mockery') && $this->isName($node->name, $method); + } + + private function isMockeryMatcher(Node\Expr $value): bool + { + return $value instanceof StaticCall && $this->isName($value->class, 'Mockery'); + } + + private function readsVariable(Node\Expr $expr, string $name): bool + { + foreach ((new NodeFinder())->findInstanceOf($expr, Variable::class) as $variable) { + if ($variable->name === $name) { + return true; + } + } + + return false; + } + + private function singleArg(array $args): ?Node\Expr + { + return \count($args) === 1 && $args[0] instanceof Arg && $args[0]->name === null && !$args[0]->unpack ? $args[0]->value : null; + } + + private function callName(MethodCall|StaticCall $call): ?string + { + return $call->name instanceof Identifier ? $call->name->toString() : null; + } + + private function satisfies(Node\Expr $predicate): StaticCall + { + return $this->argument('satisfies', [new Arg(new ArrowFunction([ + 'params' => [new Param($this->value())], + 'expr' => $predicate, + ]))]); + } + + private function value(): Variable + { + return new Variable($this->predicateName); + } + + private function literal(string $name): ConstFetch + { + return new ConstFetch(new Name($name)); + } + + /** + * @param list $args + */ + private function func(string $name, array $args): FuncCall + { + return new FuncCall(new Name($name), \array_map(static fn(Node\Expr $arg): Arg => new Arg($arg), $args)); + } + + /** + * @param list $args + */ + private function argument(string $method, array $args = []): StaticCall + { + return new StaticCall(new FullyQualified('JMac\\Testing\\Matching\\Argument'), new Identifier($method), $args); + } + + /** + * @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/MockeryToDouble/MockeryToDoubleRector/allows_expects.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/allows_expects.php.inc new file mode 100644 index 00000000..58909b2c --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/allows_expects.php.inc @@ -0,0 +1,29 @@ +allows('run')->andReturn(1); + $mock->expects('run')->andReturn(1); + $mock->expects('run')->twice()->andReturn(1); + $mock->allows()->run('x')->andReturn(1); + $mock->expects()->run('x', 2)->andReturn(1); + $mock->allows()->run()->andReturn(1); + } +} +----- +allows('run')->returns(1); + $mock->expects('run')->returns(1); + $mock->expects('run')->times(2)->returns(1); + $mock->allows('run')->with('x')->returns(1); + $mock->expects('run')->with('x', 2)->returns(1); + $mock->allows('run')->with(\JMac\Testing\Matching\Argument::none())->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/arguments.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/arguments.php.inc new file mode 100644 index 00000000..85d381d4 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/arguments.php.inc @@ -0,0 +1,33 @@ +shouldReceive('a')->withArgs([1, 'x'])->andReturn(1); + $mock->shouldReceive('b')->withArgs(fn ($x, $y) => $x > $y)->andReturn(1); + $mock->shouldReceive('c')->withNoArgs()->andReturn(1); + $mock->shouldReceive('d')->withAnyArgs()->andReturn(1); + $mock->shouldReceive('e')->withArgs([\Mockery::type('int'), 2])->andReturn(1); + $mock->shouldReceive('f')->withArgs($list)->andReturn(1); + $mock->shouldReceive('g')->withArgs($check)->andReturn(1); + $mock->shouldReceive('h')->withSomeOfArgs(1, 'x')->andReturn(1); + } +} +----- +allows('a')->with(1, 'x')->returns(1); + $mock->allows('b')->with(\JMac\Testing\Matching\Argument::all(fn ($x, $y) => $x > $y))->returns(1); + $mock->allows('c')->with(\JMac\Testing\Matching\Argument::none())->returns(1); + $mock->allows('d')->returns(1); + $mock->allows('e')->with(\JMac\Testing\Matching\Argument::type('int'), 2)->returns(1); + $mock->allows('f')->with(...$list)->returns(1); + $mock->allows('g')->with(\JMac\Testing\Matching\Argument::all($check))->returns(1); + $mock->allows('h')->with(\JMac\Testing\Matching\Argument::all(fn(...$args) => in_array(1, $args, true) && in_array('x', $args, true)))->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/by_default_and_get_mock.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/by_default_and_get_mock.php.inc new file mode 100644 index 00000000..c71e520a --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/by_default_and_get_mock.php.inc @@ -0,0 +1,25 @@ +shouldReceive('run')->andReturn(1)->byDefault(); + $mock->shouldReceive('run')->byDefault()->andReturnNull(); + $mock->shouldReceive('run')->andReturn(1)->getMock(); + \Mockery::mock(Dependency::class)->shouldReceive('run')->andReturn(1)->byDefault(); + } +} +----- +allows('run')->returns(1); + $mock->allows('run')->returns(null); + $mock->allows('run')->returns(1); + \JMac\Testing\Double::for(Dependency::class)->strict()->allows('run')->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation.php.inc new file mode 100644 index 00000000..390847ad --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation.php.inc @@ -0,0 +1,45 @@ +shouldIgnoreMissing(); + $partial = \Mockery::mock(Dependency::class)->makePartial(); + $constructed = \Mockery::mock(Dependency::class, [1, 'x'])->makePartial(); + $both = \Mockery::mock(First::class, Second::class); + $named = \Mockery::mock('App\Dependency'); + $list = \Mockery::mock('App\First, App\Second'); + $union = \Mockery::spy('App\First|App\Second'); + $computed = \Mockery::mock($className); + $proxy = \Mockery::mock($real); + } +} +----- +strict(); + $alias = \JMac\Testing\Double::for(Dependency::class)->strict(); + $spy = \JMac\Testing\Double::for(Dependency::class); + $loose = \JMac\Testing\Double::for(Dependency::class); + $partial = \JMac\Testing\Double::for(Dependency::class)->passthru(); + $constructed = \JMac\Testing\Double::for(Dependency::class)->passthru(new Dependency(1, 'x')); + $both = \JMac\Testing\Double::for(First::class, Second::class)->strict(); + $named = \JMac\Testing\Double::for('App\Dependency')->strict(); + $list = \JMac\Testing\Double::for('App\First', 'App\Second')->strict(); + $union = \JMac\Testing\Double::for('App\First', 'App\Second'); + $computed = \JMac\Testing\Double::for($className)->strict(); + $proxy = \JMac\Testing\Double::for($real)->passthru(); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation_unsupported.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation_unsupported.php.inc new file mode 100644 index 00000000..52e37089 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/creation_unsupported.php.inc @@ -0,0 +1,20 @@ + 1); + $ignoreWithValue = \Mockery::mock(Dependency::class)->shouldIgnoreMissing(5); + $computedCtor = \Mockery::mock(Dependency::class, $args)->makePartial(); + $spyProxy = \Mockery::spy($real); + $proxyIgnore = \Mockery::mock($real)->shouldIgnoreMissing(); + $service = new Service(\Mockery::mock(Dependency::class)->shouldReceive('run')->andReturn(1)->getMock()); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/double_chain_unchanged.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/double_chain_unchanged.php.inc new file mode 100644 index 00000000..2b2c693e --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/double_chain_unchanged.php.inc @@ -0,0 +1,13 @@ +allows('run')->returns(1); + $double->expects('run')->with('x')->times(2)->returns(1); + $double->expects('run')->never(); + $double->received('run')->with('x'); + $builder->with('x')->times(2); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/expectations.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/expectations.php.inc new file mode 100644 index 00000000..cf3a80f3 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/expectations.php.inc @@ -0,0 +1,41 @@ +shouldReceive('run')->andReturn('y'); + $mock->shouldReceive('run')->once()->with('x')->andReturn('y'); + $mock->shouldReceive('run')->twice(); + $mock->shouldReceive('run')->times(3)->andReturn(1, 2, 3); + $mock->shouldReceive('run')->never(); + $mock->shouldReceive('run')->atLeast()->once(); + $mock->shouldReceive('run')->atLeast()->times($n); + $mock->shouldReceive('run')->atMost()->twice(); + $mock->shouldReceive('run')->between(1, 3); + $mock->shouldReceive('run')->zeroOrMoreTimes()->andReturnTrue(); + $mock->shouldReceive('run')->once()->ordered(); + $this->mock->shouldReceive('run')->once(); + } +} +----- +allows('run')->returns('y'); + $mock->expects('run')->with('x')->returns('y'); + $mock->expects('run')->times(2); + $mock->expects('run')->times(3)->returns(1, 2, 3); + $mock->expects('run')->never(); + $mock->expects('run')->times(minimum: 1); + $mock->expects('run')->times(minimum: $n); + $mock->expects('run')->times(maximum: 2); + $mock->expects('run')->times(1, 3); + $mock->allows('run')->returns(true); + $mock->expects('run')->ordered(); + $this->mock->expects('run'); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/extended_matchers.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/extended_matchers.php.inc new file mode 100644 index 00000000..f025c579 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/extended_matchers.php.inc @@ -0,0 +1,33 @@ +shouldReceive('contains')->with(\Mockery::contains(1, 'two'))->andReturn(1); + $mock->shouldReceive('subset')->with(\Mockery::subset(['a' => 1]), \Mockery::subset(['b' => 2], false))->andReturn(1); + $mock->shouldReceive('duck')->with(\Mockery::ducktype('run', 'stop'))->andReturn(1); + $mock->shouldReceive('alias')->with(\Mockery::type('integer'), \Mockery::type('long'), \Mockery::type('double'), \Mockery::type('real'), \Mockery::type('Bool'))->andReturn(1); + $mock->shouldReceive('native')->with(\Mockery::type('array'), \Mockery::type('object'), \Mockery::type('callable'), \Mockery::type('iterable'), \Mockery::type('null'), \Mockery::type('float'))->andReturn(1); + $mock->shouldReceive('predicate')->with(\Mockery::type('numeric'), \Mockery::type('scalar'), \Mockery::type('resource'), \Mockery::type('countable'))->andReturn(1); + $mock->shouldReceive('float')->with(\Mockery::type('finite'), \Mockery::type('infinite'), \Mockery::type('nan'))->andReturn(1); + $mock->shouldReceive('class')->with(\Mockery::type(Foo::class), \Mockery::type('App\Foo'), \Mockery::type($type))->andReturn(1); + } +} +----- +allows('contains')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_array($value) && in_array(1, $value) && in_array('two', $value)))->returns(1); + $mock->allows('subset')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_array($value) && array_replace_recursive($value, ['a' => 1]) === $value), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_array($value) && array_replace_recursive($value, ['b' => 2]) == $value))->returns(1); + $mock->allows('duck')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_object($value) && method_exists($value, 'run') && method_exists($value, 'stop')))->returns(1); + $mock->allows('alias')->with(\JMac\Testing\Matching\Argument::type('int'), \JMac\Testing\Matching\Argument::type('int'), \JMac\Testing\Matching\Argument::type('float'), \JMac\Testing\Matching\Argument::type('float'), \JMac\Testing\Matching\Argument::type('bool'))->returns(1); + $mock->allows('native')->with(\JMac\Testing\Matching\Argument::type('array'), \JMac\Testing\Matching\Argument::type('object'), \JMac\Testing\Matching\Argument::type('callable'), \JMac\Testing\Matching\Argument::type('iterable'), \JMac\Testing\Matching\Argument::type('null'), \JMac\Testing\Matching\Argument::type('float'))->returns(1); + $mock->allows('predicate')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_numeric($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_scalar($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_resource($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_countable($value)))->returns(1); + $mock->allows('float')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_finite($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_infinite($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_nan($value)))->returns(1); + $mock->allows('class')->with(\JMac\Testing\Matching\Argument::type(Foo::class), \JMac\Testing\Matching\Argument::type('App\Foo'), \JMac\Testing\Matching\Argument::type($type))->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/matchers.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/matchers.php.inc new file mode 100644 index 00000000..b479ca4f --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/matchers.php.inc @@ -0,0 +1,37 @@ +shouldReceive('a')->with(Mockery::any(), Mockery::type('string'), Mockery::on(fn ($v) => $v > 1))->andReturn(1); + $mock->shouldReceive('b')->with(Mockery::capture($captured), Mockery::pattern('/^\d+$/'))->andReturn(1); + $mock->shouldReceive('c')->with(Mockery::anyOf(1, 2), Mockery::notAnyOf(3, 4), Mockery::not(5))->andReturn(1); + $mock->shouldReceive('d')->with(Mockery::isSame($object), Mockery::mustBe($value), Mockery::isEqual($loose))->andReturn(1); + $mock->shouldReceive('e')->with(Mockery::hasValue('v'), Mockery::contains('c'))->andReturn(1); + $mock->shouldReceive('f')->with(Mockery::hasKey('k'))->andReturn(1); + $mock->shouldReceive('g')->with('first', Mockery::andAnyOtherArgs())->andReturn(1); + $mock->shouldReceive('h')->with(Mockery::andAnyOthers())->andReturn(1); + } +} +----- +allows('a')->with(\JMac\Testing\Matching\Argument::any(), \JMac\Testing\Matching\Argument::type('string'), \JMac\Testing\Matching\Argument::satisfies(fn ($v) => $v > 1))->returns(1); + $mock->allows('b')->with(\JMac\Testing\Matching\Argument::capture($captured), \JMac\Testing\Matching\Argument::matches('/^\d+$/'))->returns(1); + $mock->allows('c')->with(\JMac\Testing\Matching\Argument::any(1, 2), \JMac\Testing\Matching\Argument::not()->any(3, 4), \JMac\Testing\Matching\Argument::not(5))->returns(1); + $mock->allows('d')->with(\JMac\Testing\Matching\Argument::same($object), $value, $loose)->returns(1); + $mock->allows('e')->with(\JMac\Testing\Matching\Argument::contains('v'), \JMac\Testing\Matching\Argument::contains('c'))->returns(1); + $mock->allows('f')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => (is_array($value) || $value instanceof \ArrayAccess) && array_key_exists('k', (array) $value)))->returns(1); + $mock->allows('g')->with('first', \JMac\Testing\Matching\Argument::remaining())->returns(1); + $mock->allows('h')->with(\JMac\Testing\Matching\Argument::remaining())->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/multiple_methods.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/multiple_methods.php.inc new file mode 100644 index 00000000..504aebf9 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/multiple_methods.php.inc @@ -0,0 +1,39 @@ +shouldReceive('a', 'b')->andReturn(1); + $mock->shouldReceive('a', 'b')->once()->with(\Mockery::type('int'), $value)->andReturn([1, 2]); + $mock->shouldReceive(['a' => 1, 'b' => 2]); + $mock->shouldReceive(['a' => 1], 'c')->once(); + $mock->allows(['a' => 1, 'b' => $value]); + $this->mock->shouldNotReceive('a', 'b'); + $mock->shouldNotReceive()->run('x'); + $mock->shouldReceive()->run('x')->andReturn(1); + } +} +----- +allows('a')->returns(1); + $mock->allows('b')->returns(1); + $mock->expects('a')->with(\JMac\Testing\Matching\Argument::type('int'), $value)->returns([1, 2]); + $mock->expects('b')->with(\JMac\Testing\Matching\Argument::type('int'), $value)->returns([1, 2]); + $mock->allows('a')->returns(1); + $mock->allows('b')->returns(2); + $mock->expects('a')->returns(1); + $mock->expects('c'); + $mock->allows('a')->returns(1); + $mock->allows('b')->returns($value); + $this->mock->expects('a')->never(); + $this->mock->expects('b')->never(); + $mock->expects('run')->with('x')->never(); + $mock->allows('run')->with('x')->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/predicate_variable_clash.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/predicate_variable_clash.php.inc new file mode 100644 index 00000000..6d58134f --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/predicate_variable_clash.php.inc @@ -0,0 +1,21 @@ +shouldReceive('f')->with(\Mockery::hasKey($value))->andReturn(1); + } +} +----- +allows('f')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value2) => (is_array($value2) || $value2 instanceof \ArrayAccess) && array_key_exists($value, (array) $value2)))->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/returns.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/returns.php.inc new file mode 100644 index 00000000..1db4ef66 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/returns.php.inc @@ -0,0 +1,51 @@ +shouldReceive('a')->andReturns(1); + $mock->shouldReceive('b')->andReturn(); + $mock->shouldReceive('c')->andReturnValues([1, 2]); + $mock->shouldReceive('d')->andReturnNull(); + $mock->shouldReceive('e')->andReturnFalse(); + $mock->shouldReceive('f')->andReturnSelf(); + $this->mock->shouldReceive('g')->andReturnSelf(); + $mock->shouldReceive('h')->andReturnArg(1); + $mock->shouldReceive('i')->andReturnUsing(fn ($x) => $x * 2); + $mock->shouldReceive('j')->andThrow(new \RuntimeException('boom')); + $mock->shouldReceive('k')->andThrows($exception); + $mock->shouldReceive('l')->andThrow(\RuntimeException::class, 'boom', 5); + $mock->shouldReceive('m')->andThrowExceptions([new \LogicException(), $other]); + $mock->shouldReceive('n')->andReturnValues($values); + $mock->shouldReceive('o')->andThrowExceptions($exceptions); + $mock->shouldReceive('p')->andThrow('RuntimeException', 'boom'); + $mock->shouldReceive('q')->andThrow($exception, 'ignored', 0); + } +} +----- +allows('a')->returns(1); + $mock->allows('b')->returns(null); + $mock->allows('c')->returns(1, 2); + $mock->allows('d')->returns(null); + $mock->allows('e')->returns(false); + $mock->allows('f')->returns($mock); + $this->mock->allows('g')->returns($this->mock); + $mock->allows('h')->resolves(fn(...$args) => $args[1]); + $mock->allows('i')->resolves(fn ($x) => $x * 2); + $mock->allows('j')->throws(new \RuntimeException('boom')); + $mock->allows('k')->throws($exception); + $mock->allows('l')->throws(new \RuntimeException('boom', 5)); + $mock->allows('m')->throws(new \LogicException(), $other); + $mock->allows('n')->returns(...$values); + $mock->allows('o')->throws(...$exceptions); + $mock->allows('p')->throws(new \RuntimeException('boom')); + $mock->allows('q')->throws($exception); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/split_assignments.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/split_assignments.php.inc new file mode 100644 index 00000000..5f13d193 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/split_assignments.php.inc @@ -0,0 +1,37 @@ + 1, 'stop' => $value]); + $this->spy = \Mockery::spy(Dependency::class, ['run' => 1]); + $inline = \Mockery::mock(Dependency::class)->shouldReceive('run')->once()->andReturn(1)->getMock(); + $loose = \Mockery::mock(Dependency::class)->shouldIgnoreMissing()->shouldReceive('run')->andReturn(1)->getMock(); + $partial = \Mockery::mock(Real::class)->makePartial()->shouldReceive('a', 'b')->andReturn(2)->getMock(); + $magic = \Mockery::mock(Dependency::class)->allows()->run('x')->andReturn(1)->getMock(); + } +} +----- +strict(); + $quick->allows('run')->returns(1); + $quick->allows('stop')->returns($value); + $this->spy = \JMac\Testing\Double::for(Dependency::class); + $this->spy->allows('run')->returns(1); + $inline = \JMac\Testing\Double::for(Dependency::class)->strict(); + $inline->expects('run')->returns(1); + $loose = \JMac\Testing\Double::for(Dependency::class); + $loose->allows('run')->returns(1); + $partial = \JMac\Testing\Double::for(Real::class)->passthru(); + $partial->allows('a')->returns(2); + $partial->allows('b')->returns(2); + $magic = \JMac\Testing\Double::for(Dependency::class)->strict(); + $magic->allows('run')->with('x')->returns(1); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unconvertible_chain_keeps_factory.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unconvertible_chain_keeps_factory.php.inc new file mode 100644 index 00000000..1d91c839 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unconvertible_chain_keeps_factory.php.inc @@ -0,0 +1,11 @@ +shouldReceive('run')->andReturn(1)->passthru(); + $service = new Service(\Mockery::mock(Dependency::class)->shouldReceive('run')->andReturn(1)->getMock()); + $kept = \Mockery::mock(Dependency::class)->shouldReceive('run')->andReturnUndefined()->getMock(); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unsupported_left_unchanged.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unsupported_left_unchanged.php.inc new file mode 100644 index 00000000..ab7e9944 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/unsupported_left_unchanged.php.inc @@ -0,0 +1,35 @@ +shouldReceive('run')->with('x')->andReturn(1)->byDefault(); + $mock->shouldReceive('run')->once()->byDefault(); + $mock->shouldReceive('run')->passthru(); + $mock->shouldReceive('run')->andSet('prop', 1); + $mock->shouldReceive('run')->ordered('group'); + $mock->shouldReceive('run')->globally()->ordered(); + $mock->shouldReceive('a->b')->andReturn(1); + $mock->shouldReceive($map); + $mock->shouldReceive('a', 'b')->andReturn(new Value()); + $mock->shouldReceive('a', 'b')->andReturnUsing(makeCallback()); + $mocks[0]->shouldReceive('a', 'b')->andReturn(1); + $mock->allows(['a' => 1])->andReturn(2); + $mock->shouldReceive('run')->andReturnUndefined(); + $mock->shouldReceive('run')->andReturnUsing($a, $b); + $mock->shouldReceive('run')->andThrow($exception, $message); + $mock->shouldReceive('run')->andReturnValues($unknown); + $mock->shouldReceive('run')->withArgs($unknown); + $mock->shouldReceive('run')->withSomeOfArgs(\Mockery::any()); + $mock->shouldReceive('run')->withSomeOfArgs($args); + $mock->shouldReceive('run')->with(\Mockery::subset(['a' => 1], $strict))->andReturn(1); + $mock->shouldReceive('run')->with(\Mockery::not(\Mockery::type('int')))->andReturn(1); + $mock->shouldReceive('run')->with(equalTo(5))->andReturn(1); + $mock->shouldReceive('run')->atLeast()->andReturn(1); + $mock->shouldReceive('run')->once()->twice(); + $spy->shouldHaveReceived('run')->andReturn(1); + $spy->shouldHaveReceived('run', $unknown); + $spy->shouldHaveBeenCalled([1]); + } +} diff --git a/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/verification.php.inc b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/verification.php.inc new file mode 100644 index 00000000..bd9ff287 --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/MockeryToDoubleRector/verification.php.inc @@ -0,0 +1,49 @@ +shouldHaveReceived('run'); + $spy->shouldHaveReceived('run')->with('x')->once(); + $spy->shouldHaveReceived('run', ['x', 2]); + $spy->shouldHaveReceived('run')->times(3); + $spy->shouldHaveReceived()->run('x'); + $spy->shouldHaveReceived()->run(); + $spy->shouldNotHaveReceived('run'); + $spy->shouldNotHaveReceived('run', ['x']); + $spy->shouldNotHaveReceived()->run('x'); + $spy->shouldHaveReceived('run', fn ($x) => $x > 1); + $spy->shouldHaveReceived('run', $args)->twice(); + $spy->shouldHaveReceived('run')->withArgs([\Mockery::any()])->atLeast()->once(); + $callable->shouldHaveBeenCalled(); + $callable->shouldNotHaveBeenCalled(); + $callable->shouldNotHaveBeenCalled([1]); + \Mockery::close(); + } +} +----- +received('run'); + $spy->received('run')->with('x')->times(1); + $spy->received('run')->with('x', 2); + $spy->received('run')->times(3); + $spy->received('run')->with('x'); + $spy->received('run')->with(\JMac\Testing\Matching\Argument::none()); + $spy->received('run')->never(); + $spy->received('run')->with('x')->never(); + $spy->received('run')->with('x')->never(); + $spy->received('run')->with(\JMac\Testing\Matching\Argument::all(fn ($x) => $x > 1)); + $spy->received('run')->with(...$args)->times(2); + $spy->received('run')->with(\JMac\Testing\Matching\Argument::any())->times(minimum: 1); + $callable->received('__invoke'); + $callable->received('__invoke')->never(); + $callable->received('__invoke')->with(1)->never(); + \JMac\Testing\Double::verifyAll(); + } +} diff --git a/bridge/rector/src/MockeryToDouble/TODO.md b/bridge/rector/src/MockeryToDouble/TODO.md new file mode 100644 index 00000000..21f7ef6a --- /dev/null +++ b/bridge/rector/src/MockeryToDouble/TODO.md @@ -0,0 +1,53 @@ +# Mockery -> Double: outstanding / partial conversions + +The `mockery-to-double` set follows Double's [Mockery migration table](https://testdoublephp.com/migrating-from-mockery), +checked against both libraries' sources. It does not depend on the test framework. + +## Implemented + +- **MockeryToDoubleRector** (registered) — see the class docblock for the full map. Where it departs + from the migration table, on purpose: + - `Mockery::mock(X)` → `Double::for(X)->strict()`, not a bare `Double::for(X)`. A plain Mockery mock + throws on an unconfigured call; Double's default Loose mode answers it. Strict keeps the failure. + `Mockery::spy(X)` and `->shouldIgnoreMissing()` are the Loose ones. + - `shouldReceive('m')->once()` → `expects('m')` with no `times(1)`: `expects()` means exactly once. + - `allows('m')` / `expects('m')` are Double verbs too, so such a chain converts only when it carries + a Mockery-only link (`andReturn`, `once`, `withArgs`, …). A Double chain is never rewritten. + - `shouldNotHaveBeenCalled()` is not `unused()`: Mockery means "`__invoke` was never called", so it + becomes `received('__invoke')->never()`; `shouldHaveBeenCalled()` is `received('__invoke')`. + - `Mockery::type()` checks `is_()` when that function exists: the names Double's `type()` + spells differently are normalised (`integer`/`long` → `int`, `double`/`real` → `float`), and the + checks Double has no type name for (`numeric`, `scalar`, `resource`, `countable`, …) become a + `satisfies()` predicate. + - Statements that set up several expectations at once are split: `shouldReceive('a', 'b')`, the + `['m' => $return]` maps of `shouldReceive()`/`allows()`, the quick definitions + `$m = Mockery::mock(X, ['m' => 1])`, and `$m = Mockery::mock(X)->shouldReceive(...)->...->getMock()`. + Several methods repeat every link, so their arguments must be side-effect free (values, variables, + closures, matchers). + - `byDefault()` drops when the expectation has no arguments and no count: Double tries the newest + expectation first, so a later one overrides it. With arguments or a count it would keep answering + calls Mockery sends past it, so it stays. + - `hasKey`, several-value `contains`, `subset` and `ducktype` become the `satisfies()` predicate + Mockery's own matcher evaluates. + - A computed target converts by its PHPStan type: a class-string as a target, an object as the proxied + partial `Double::for($object)->passthru()`. +- **MockeryIntegrationToDoubleRector** (registered) — `use MockeryPHPUnitIntegration` → `use + VerifiesDoubles`, and `extends MockeryTestCase` → `extends TestCase` plus the trait, for suites that stay + on PHPUnit. A class overriding `mockeryTestSetUp()`/`mockeryTestTearDown()`, or adapting the trait, is + left alone. + +## Left for manual migration + +- Comparison strictness (by design): Mockery compares plain values with `==`, Double with `===` for + scalars. A test relying on coercion (`'1'` against `1`) fails after conversion; fix the expected value. +- A proxied partial: `Double::for($object)->passthru()` copies the object's state, while Mockery's proxy + forwards to the object itself, so a test reading the original object after the calls sees a difference. +- A bare `Mockery::mock()`, a full mock with constructor arguments, a closure target, a computed target + of unknown type, and the string targets `alias:`, `overload:`, `X[m]`: Double needs a real type and + has no aliases, instance mocks or static mocking. +- `passthru()`, `andSet()`, `andReturnUndefined()`, `globally()`, grouped `ordered('g')`, demeter + `shouldReceive('a->b')`, several `andReturnUsing()` closures, `byDefault()` with arguments or a count. +- Matchers with no Double form: a matcher nested in another, a computed `subset()` strictness flag, + Hamcrest matchers. +- A statement is converted whole or not at all, but a double is not tracked across statements: when + one statement on a mock stays Mockery while its factory converts, that statement needs a hand fix. diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector.php similarity index 54% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector.php index 1edc8322..ae3c211d 100644 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector.php +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector.php @@ -2,18 +2,19 @@ declare(strict_types=1); -namespace Testo\Bridge\Rector\PhpunitToTesto; +namespace Testo\Bridge\Rector\PhpunitToDouble; 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\BinaryOp; +use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Expr\Empty_; -use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; @@ -21,10 +22,16 @@ use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Param; use PhpParser\Node\Scalar\Int_; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Expression; +use PhpParser\Node\VariadicPlaceholder; use Rector\Rector\AbstractRector; use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; +use Testo\Bridge\Rector\Internal\PhpunitConstraint; +use Testo\Bridge\Rector\Internal\PhpunitMockFactory; +use Testo\Bridge\Rector\Internal\PredicateVariable; +use Testo\Bridge\Rector\Internal\ReturnValueMap; use Testo\Bridge\Rector\Testing\TestRectorFixtures; /** @@ -37,49 +44,56 @@ * $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: + * Creation ({@see PhpunitMockFactory} reads the PHPUnit side): `createMock(X)`/`createStub(X)`, the + * intersection factories and the constructor-disabling builder → `Double::for(...)`, which never runs a + * constructor and answers an unconfigured call with a default, as PHPUnit does; + * `disableAutoReturnValueGeneration()` adds `->strict()`. A partial double — `createPartialMock(X, ['a'])`, + * `onlyMethods(['a'])` — is `Double::for(X)->passthru()` (with `passthru(new X(...))` when the builder runs + * the constructor) plus an `allows('a')` per doubled method, so those answer with a default while every + * other method runs for real. `createConfiguredMock(X, ['m' => $v])` is `Double::for(X)` plus an + * `allows('m')->returns($v)` per entry. The added `allows()` calls are statements of their own, so these + * two forms convert when the double is assigned to a variable or property. * - * - `$this->createMock(X)` / `$this->createStub(X)` → `Double::for(X)`, - * `create{Mock,Stub}ForIntersectionOfInterfaces([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')`; `withAnyParameters()` drops - * away (Double's default); 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()/onConsecutiveCalls()/returnArgument()/returnSelf())` 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` and `isNull()`/`isTrue()`/`isFalse()` to `null`/`true`/`false` (Double matches by - * equality by default); the comparison and string constraints Double has no dedicated matcher for - * become a predicate — `greaterThan`/`lessThan`/`greaterThanOrEqual`/`lessThanOrEqual`, - * `isEmpty`, `stringContains`, `stringStartsWith`/`stringEndsWith`, `arrayHasKey` → - * `satisfies(fn ($value) => …)`; and the composites fold in recursively — `logicalNot()` → - * `Argument::not(...)` / `Argument::not()->…()`, `logicalOr()` → `Argument::any(...)`. A plain value - * passes through. + * Configuration chain, rebuilt from its outermost call: PHPUnit's invocation matcher moves off + * `expects()` onto the verb — `any()` picks `allows()`, every other matcher 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 from + * `->method('m')` onto the verb; `withAnyParameters()` drops away; the returns map + * `willReturn`/`willReturnOnConsecutiveCalls` → `returns`, `willThrowException` → `throws`, + * `willReturnCallback` → `resolves`, `willReturnArgument($n)` → `resolves(fn (...$args) => $args[$n])`, + * `willReturnSelf()` → `returns()`, `willReturnMap($map)` → `resolves()` + * ({@see ReturnValueMap}), plus the legacy `will($this->returnValue()/…/returnValueMap())` wrappers. * - * 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). + * `with()` constraints become `Argument::*` matchers where Double has one: `anything` → `any`, + * `identicalTo` → `same`, `isInstanceOf` and the `isType()` names Double's `type()` knows → `type`, + * `callback` → `satisfies`, `contains`/`containsEqual` → `contains`, `containsIdentical` → + * `contains(Argument::same(...))`, `matchesRegularExpression` → `matches`, `logicalNot` → `not`, + * `logicalOr` → `any(...)`; `equalTo($x)` unwraps to `$x` and `isNull`/`isTrue`/`isFalse` to literals. + * Every other constraint becomes `Argument::satisfies(fn ($value) => …)` over its own PHP expression + * ({@see PhpunitConstraint}) — comparisons, `logicalAnd`/`logicalXor`, delta/case/canonicalizing + * equality, string, count, JSON, file and type checks. * * 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 with no faithful form (`logicalAnd` — no per-argument AND matcher; - * `equalToWithDelta`/`equalToCanonicalizing` — loose comparison; a case-insensitive `stringContains`) - * — 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). + * `expects()` with a recognised matcher, or one of the `will*` return verbs — so an unrelated fluent + * chain is left alone, and any link with no faithful counterpart aborts the whole chain: a variable + * matcher, `prophesize()`, `getMockForAbstractClass()`, a builder step with no Double form, a strict + * partial, `stringContains()` with a computed case flag. Leaving a raw `$this->…()` constraint would + * break once the test loses its TestCase base. Those stay for manual migration (see + * {@see UnconvertibleMockToDoubleRector} and TODO.md). */ #[TestRectorFixtures('CreateMockToDoubleRector')] final class CreateMockToDoubleRector extends AbstractRector { + /** + * The type names Double's `Argument::type()` checks natively; any other name it treats as a class. + */ + private const DOUBLE_TYPES = ['int', 'float', 'string', 'bool', 'array', 'object', 'callable', 'iterable', 'null']; + + /** + * The variable the predicates of the statement being rebuilt are written over. + */ + private string $predicateName = 'value'; + public function getRuleDefinition(): RuleDefinition { return new RuleDefinition( @@ -110,22 +124,32 @@ public function getNodeTypes(): array /** * 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. + * never interfere: an unconvertible outer link (e.g. `willReturnSelf()` on a complex root) leaves the + * whole statement alone instead of the inner `expects()->method()` being rewritten on its own. The + * factories that need statements of their own are expanded at statement level too. * * @param Expression|MethodCall $node + * @return Node|list|null */ #[\Override] - public function refactor(Node $node): ?Node + public function refactor(Node $node): Node|array|null { if ($node instanceof MethodCall) { - return $this->matchMockFactory($node); + $factory = PhpunitMockFactory::parse($node); + $double = $factory === null ? null : $this->doubleFor($factory); + + return $double === null || $double['setup'] !== [] ? null : $double['double']; + } + + if ($node->expr instanceof Assign) { + return $this->expandFactoryAssignment($node->expr); } if (!$node->expr instanceof MethodCall) { return null; } + $this->predicateName = PredicateVariable::nameFor($node); $rebuilt = $this->rebuildMockChain($node->expr); if ($rebuilt === null) { return null; @@ -137,93 +161,103 @@ public function refactor(Node $node): ?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). + * `$dep = $this->createPartialMock(X, ['a'])` → `$dep = Double::for(X)->passthru();` followed by + * `$dep->allows('a');` — the same for `createConfiguredMock()` with `allows('m')->returns($v)`. + * + * @return list|null */ - private function matchMockFactory(MethodCall $node): ?StaticCall + private function expandFactoryAssignment(Assign $assign): ?array { - # 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 (!$assign->expr instanceof MethodCall) { + return null; } - if (!$this->isName($node->var, 'this')) { + $factory = PhpunitMockFactory::parse($assign->expr); + $double = $factory === null ? null : $this->doubleFor($factory); + if ($double === null || $double['setup'] === [] || $this->cloneDoubleRoot($assign->var) === null) { return null; } - if ($this->isName($node->name, 'createMock') || $this->isName($node->name, 'createStub')) { - return $this->doubleFor($node->args); - } + $statements = [new Expression(new Assign($assign->var, $double['double']))]; + foreach ($double['setup'] as [$method, $return]) { + $root = $this->cloneDoubleRoot($assign->var); + \assert($root !== null); - if ( - $this->isName($node->name, 'createMockForIntersectionOfInterfaces') - || $this->isName($node->name, 'createStubForIntersectionOfInterfaces') - ) { - return $this->intersectionDouble($node->args); + $call = new MethodCall($root, new Identifier('allows'), [new Arg($method)]); + if ($return !== null) { + $call = new MethodCall($call, new Identifier('returns'), [new Arg($return)]); + } + + $statements[] = new Expression($call); } - return null; + return $statements; } /** - * `$this->getMockBuilder(X)->disableOriginalConstructor()->getMock()` → `Double::for(X)`. + * The Double for a PHPUnit factory: the creation expression, plus the `[method, return]` pairs that + * still have to be set up on it (`allows(method)`, with `returns(return)` when one is given). Null + * when the factory has no Double form. * - * 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. + * @return array{double: Node\Expr, setup: list}|null */ - private function builderDouble(MethodCall $getMock): ?StaticCall + private function doubleFor(PhpunitMockFactory $factory): ?array { - if ($getMock->args !== []) { - return null; - } + if ($factory->configuration !== null) { + $setup = []; + foreach ($factory->configuration->items as $item) { + \assert($item !== null && $item->key !== null); + $setup[] = [$item->key, $item->value]; + } - $sawDisableConstructor = false; - $cursor = $getMock->var; - while ($cursor instanceof MethodCall) { - $name = $this->segmentName($cursor); + return ['double' => $this->doubleCall($factory->targets), 'setup' => $setup]; + } - if ($name === 'disableOriginalConstructor' && $cursor->args === []) { - $sawDisableConstructor = true; - $cursor = $cursor->var; - continue; - } + if ($factory->partialMethods === null) { + $double = $this->doubleCall($factory->targets); - if ($name === 'getMockBuilder' && $this->isName($cursor->var, 'this')) { - return $sawDisableConstructor ? $this->doubleFor($cursor->args) : null; - } + return ['double' => $factory->autoReturn ? $double : new MethodCall($double, new Identifier('strict')), 'setup' => []]; + } + # A doubled method without auto-return fails when called unconfigured; a passthru double has no + # per-method strictness to express that with. + if (!$factory->autoReturn && $factory->partialMethods !== []) { return null; } - return null; + $real = $factory->constructorArgs === null ? [] : [new Arg($this->construct($factory->target(), $factory->constructorArgs))]; + + return [ + 'double' => new MethodCall($this->doubleCall([new Arg($factory->target())]), new Identifier('passthru'), $real), + 'setup' => \array_map(static fn(Node\Expr $method): array => [$method, null], $factory->partialMethods), + ]; } /** - * @param list $args + * `new X(...$constructorArgs)` — the real instance a passthru double copies its state from, built the + * way PHPUnit's builder would have run the constructor. A literal argument list is unpacked in place. */ - private function intersectionDouble(array $args): ?StaticCall + private function construct(Node\Expr $class, Node\Expr $constructorArgs): New_ { - $first = $args[0] ?? null; - if (!$first instanceof Arg || !$first->value instanceof Array_) { - return null; - } + $className = match (true) { + $class instanceof ClassConstFetch && $this->isName($class->name, 'class') && $class->class instanceof Name => $class->class, + $class instanceof String_ => new FullyQualified(\ltrim($class->value, '\\')), + default => $class, + }; - $targets = []; - foreach ($first->value->items as $item) { - if ($item === null) { - return null; + $args = [new Arg($constructorArgs, unpack: true)]; + if ($constructorArgs instanceof Array_) { + $args = []; + foreach ($constructorArgs->items as $item) { + if ($item === null || $item->key !== null || $item->unpack || $item->byRef) { + $args = [new Arg($constructorArgs, unpack: true)]; + break; + } + $args[] = new Arg($item->value); } - - $targets[] = new Arg($item->value); } - return $targets === [] ? null : $this->doubleFor($targets); + return new New_($className, $args); } /** @@ -236,12 +270,17 @@ private function intersectionDouble(array $args): ?StaticCall */ private function rebuildMockChain(MethodCall $node): ?MethodCall { + # The chain root may itself be a call: a PHPUnit factory (`$this->createMock(X)->method(…)`) or the + # Double it has already become (`Double::for(X)->passthru()`). Either one ends the chain. $segments = []; $cursor = $node; - while ($cursor instanceof MethodCall) { + while ($cursor instanceof MethodCall && !$this->isFactoryRoot($cursor)) { $segments[] = $cursor; $cursor = $cursor->var; } + if ($segments === []) { + return null; + } $segments = \array_reverse($segments); $root = $segments[0]->var; @@ -267,7 +306,7 @@ private function rebuildMockChain(MethodCall $node): ?MethodCall # 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') { - $returnSelf = $this->returnSelf($root); + $returnSelf = $segments[$i]->args === [] ? $this->returnSelf($root) : null; if ($returnSelf === null) { return null; } @@ -305,11 +344,23 @@ private function rebuildMockChain(MethodCall $node): ?MethodCall return $isMock ? $result : null; } + private function isFactoryRoot(MethodCall $call): bool + { + if (PhpunitMockFactory::parse($call) !== null) { + return true; + } + + return ($this->isName($call->name, 'strict') || $this->isName($call->name, 'passthru')) + && $call->var instanceof StaticCall + && $this->isName($call->var->class, 'JMac\\Testing\\Double') + && $this->isName($call->var->name, 'for'); + } + /** * 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 + * @return array{0: non-empty-string, 1: list, 2: bool}|null */ private function rewriteSegment(string $name, MethodCall $segment, Node\Expr $root): ?array { @@ -322,6 +373,7 @@ private function rewriteSegment(string $name, MethodCall $segment, Node\Expr $ro 'willThrowException' => ['throws', $segment->args, true], 'willReturnCallback' => ['resolves', $segment->args, true], 'willReturnArgument' => $this->mapReturnArgument($segment->args), + 'willReturnMap' => $this->mapReturnMap($segment->args), 'will' => $this->mapWill($segment->args[0] ?? null, $root), default => null, }; @@ -332,8 +384,8 @@ private function rewriteSegment(string $name, MethodCall $segment, Node\Expr $ro * bare value). A plain value passes through; a constraint with no faithful matcher aborts the whole * chain, since leaving the raw `$this->…()` call would break once the test loses its TestCase base. * - * @param list $args - * @return array{0: non-empty-string, 1: list, 2: bool}|null + * @param list $args + * @return array{0: non-empty-string, 1: list, 2: bool}|null */ private function mapWith(array $args): ?array { @@ -357,63 +409,64 @@ private function mapWith(array $args): ?array /** * Maps a single `with()` argument expression to its Double matcher expression: a plain value passes - * through unchanged; a PHPUnit constraint (`$this->equalTo()`, `$this->greaterThan()`, - * `$this->logicalNot()`, …) becomes the matching `Argument::*` matcher, a bare value (for `equalTo`, - * whose value already matches by equality), or a `satisfies()` predicate for the comparison/string - * constraints Double has no dedicated matcher for; an unmappable constraint returns null to abort the + * through unchanged; a PHPUnit constraint becomes the matching `Argument::*` matcher, a bare value + * (for `equalTo`, whose value already matches by equality), or a `satisfies()` predicate for the + * constraints Double has no dedicated matcher for; one with no faithful form returns null to abort the * whole chain. Recursive, so `logicalNot`/`logicalOr` can wrap any mappable inner constraint. */ private function mapConstraintValue(Node\Expr $value): ?Node\Expr { - if (!$this->isConstraintCall($value)) { + $name = PhpunitConstraint::name($value); + if ($name === null) { return $value; } - \assert($value instanceof MethodCall || $value instanceof StaticCall); - $name = $value->name instanceof Identifier ? $value->name->toString() : null; - $args = $value->args; - $first = ($args[0] ?? null) instanceof Arg ? $args[0]->value : null; + $args = PhpunitConstraint::arguments($value); + if ($args === null) { + return null; + } - return match ($name) { - 'anything' => $this->argument('any'), - 'equalTo' => $first, - 'identicalTo' => $first !== null ? $this->argument('same', [new Arg($first)]) : null, - 'isInstanceOf', 'isType' => $first !== null ? $this->argument('type', [new Arg($first)]) : null, - 'callback' => $first !== null ? $this->argument('satisfies', [new Arg($first)]) : null, - 'contains' => $first !== null ? $this->argument('contains', [new Arg($first)]) : null, - 'matchesRegularExpression' => $first !== null ? $this->argument('matches', [new Arg($first)]) : null, - 'isNull' => new ConstFetch(new Name('null')), - 'isTrue' => new ConstFetch(new Name('true')), - 'isFalse' => new ConstFetch(new Name('false')), - 'greaterThan' => $first !== null ? $this->satisfies(new BinaryOp\Greater($this->predicateVar(), $first)) : null, - 'lessThan' => $first !== null ? $this->satisfies(new BinaryOp\Smaller($this->predicateVar(), $first)) : null, - 'greaterThanOrEqual' => $first !== null ? $this->satisfies(new BinaryOp\GreaterOrEqual($this->predicateVar(), $first)) : null, - 'lessThanOrEqual' => $first !== null ? $this->satisfies(new BinaryOp\SmallerOrEqual($this->predicateVar(), $first)) : null, - 'isEmpty' => $this->satisfies(new Empty_($this->predicateVar())), - 'stringContains' => $this->stringPredicate('str_contains', $args, $first), - 'stringStartsWith' => $first !== null ? $this->satisfies($this->func('str_starts_with', [$this->predicateVar(), $first])) : null, - 'stringEndsWith' => $first !== null ? $this->satisfies($this->func('str_ends_with', [$this->predicateVar(), $first])) : null, - 'arrayHasKey' => $first !== null ? $this->satisfies($this->func('array_key_exists', [$first, $this->predicateVar()])) : null, - 'logicalNot' => $this->negateConstraint($first), + $first = $args[0] ?? null; + $single = \count($args) === 1; + + # `isType('integer')`, PHPUnit 12's `isInt()`, … → `Argument::type('int')` for the names Double's + # `type()` checks natively; the rest (`numeric`, `scalar`, `resource`) fall through to the predicate. + $checkedType = PhpunitConstraint::checkedType($value); + if (\in_array($checkedType, self::DOUBLE_TYPES, true)) { + return $this->argument('type', [new Arg(new String_($checkedType))]); + } + + $dedicated = match ($name) { + 'anything' => $args === [] ? $this->argument('any') : null, + 'equalTo' => $single ? $first : null, + 'identicalTo' => $single ? $this->argument('same', [new Arg($first)]) : null, + 'isInstanceOf' => $single ? $this->argument('type', [new Arg($first)]) : null, + 'callback' => $single ? $this->argument('satisfies', [new Arg($first)]) : null, + 'contains', 'containsEqual' => $single ? $this->argument('contains', [new Arg($first)]) : null, + 'containsIdentical' => $single ? $this->argument('contains', [new Arg($this->argument('same', [new Arg($first)]))]) : null, + 'matchesRegularExpression' => $single ? $this->argument('matches', [new Arg($first)]) : null, + 'isNull' => $args === [] ? new ConstFetch(new Name('null')) : null, + 'isTrue' => $args === [] ? new ConstFetch(new Name('true')) : null, + 'isFalse' => $args === [] ? new ConstFetch(new Name('false')) : null, + 'logicalNot' => $single ? $this->negateConstraint($first) : null, 'logicalOr' => $this->anyOfConstraints($args), default => null, }; - } + if ($dedicated !== null) { + return $dedicated; + } - /** - * True when an expression is a PHPUnit constraint factory call — `$this->equalTo(...)` or the - * `self::`/`static::` static forms — as opposed to a plain value passed straight to `with()`. - */ - private function isConstraintCall(Node\Expr $value): bool - { - return ($value instanceof MethodCall && $this->isName($value->var, 'this')) - || ($value instanceof StaticCall && ($this->isName($value->class, 'self') || $this->isName($value->class, 'static'))); + $constraint = new PhpunitConstraint($this->predicateName); + $predicate = $constraint->predicate($value); + + return $predicate === null ? null : $this->argument('satisfies', [new Arg($constraint->closure($predicate))]); } /** * `logicalNot($constraint)` → the negated matcher: `Argument::not($value)` for a bare/`equalTo` inner, * or `Argument::not()->type()/same()/satisfies()/contains()/matches()/any()` for an inner that maps to - * one of the matchers `NegatedArgument` mirrors. Anything else (e.g. negating `anything()`) aborts. + * one of the matchers `NegatedArgument` mirrors. Anything else (e.g. negating `anything()`) returns + * null and falls through to the predicate. */ private function negateConstraint(?Node\Expr $inner): ?Node\Expr { @@ -444,19 +497,16 @@ private function negateConstraint(?Node\Expr $inner): ?Node\Expr /** * `logicalOr($a, $b, …)` → `Argument::any($a, $b, …)`, each alternative mapped through - * {@see mapConstraintValue()} (a value or a nested matcher). Aborts if any alternative is unmappable. + * {@see mapConstraintValue()} (a value or a nested matcher). Returns null if any alternative is + * unmappable, leaving the predicate to try. * - * @param list $args + * @param list $args */ private function anyOfConstraints(array $args): ?StaticCall { $alternatives = []; foreach ($args as $arg) { - if (!$arg instanceof Arg) { - return null; - } - - $mapped = $this->mapConstraintValue($arg->value); + $mapped = $this->mapConstraintValue($arg); if ($mapped === null) { return null; } @@ -467,22 +517,6 @@ private function anyOfConstraints(array $args): ?StaticCall return $alternatives === [] ? null : $this->argument('any', $alternatives); } - /** - * `stringContains($needle)` → `Argument::satisfies(fn ($value) => str_contains($value, $needle))`. - * PHPUnit's optional case-insensitivity flag has no `str_contains` equivalent, so a call carrying a - * second argument aborts rather than silently dropping it. - * - * @param list $args - */ - private function stringPredicate(string $function, array $args, ?Node\Expr $needle): ?StaticCall - { - if ($needle === null || \count($args) !== 1) { - return null; - } - - return $this->satisfies($this->func($function, [$this->predicateVar(), $needle])); - } - /** * True when an expression is one of the `\JMac\Testing\Matching\Argument::*` matcher calls this rule * builds — used to tell a mapped matcher apart from a mapped bare value when negating. @@ -494,38 +528,11 @@ private function isArgumentCall(Node\Expr $value): bool && $value->class->toString() === 'JMac\\Testing\\Matching\\Argument'; } - /** - * `Argument::satisfies(fn ($value) => )` — the shared shape for every comparison/string - * constraint Double expresses through a predicate rather than a dedicated matcher. - */ - private function satisfies(Node\Expr $predicate): StaticCall - { - $closure = new ArrowFunction([ - 'params' => [new Param($this->predicateVar())], - 'expr' => $predicate, - ]); - - return $this->argument('satisfies', [new Arg($closure)]); - } - - private function predicateVar(): Variable - { - return new Variable('value'); - } - - /** - * @param list $args - */ - private function func(string $name, array $args): FuncCall - { - return new FuncCall(new Name($name), \array_map(static fn(Node\Expr $arg): Arg => new Arg($arg), $args)); - } - /** * `willReturnArgument($n)` → `resolves(fn (...$args) => $args[$n])`, so the Nth call argument is * returned the same way PHPUnit echoes it back. * - * @param list $args + * @param list $args * @return array{0: non-empty-string, 1: list, 2: bool}|null */ private function mapReturnArgument(array $args): ?array @@ -543,11 +550,26 @@ private function mapReturnArgument(array $args): ?array return ['resolves', [new Arg($resolver)], true]; } + /** + * `willReturnMap($map)` → `resolves()`. + * + * @param list $args + * @return array{0: non-empty-string, 1: list, 2: bool}|null + */ + private function mapReturnMap(array $args): ?array + { + $map = \count($args) === 1 && $args[0] instanceof Arg && !$args[0]->unpack ? $args[0]->value : null; + $resolver = $map === null ? null : ReturnValueMap::resolver($map); + + return $resolver === null ? null : ['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. + * converted `willReturnSelf()` or as the target of an added `allows()`. 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 conversion is left for manual migration rather than aliasing a + * node into two positions of the tree. */ private function cloneDoubleRoot(Node\Expr $root): ?Node\Expr { @@ -556,12 +578,12 @@ private function cloneDoubleRoot(Node\Expr $root): ?Node\Expr } if ( - $root instanceof Node\Expr\PropertyFetch + $root instanceof 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 new PropertyFetch(new Variable($root->var->name), new Identifier($root->name->toString())); } return null; @@ -577,12 +599,12 @@ private function argument(string $method, array $args = []): StaticCall /** * Legacy `will($this->returnValue()/throwException()/returnCallback()/onConsecutiveCalls()/ - * returnArgument()/returnSelf())` → the matching Double verb — the pre-`willReturn*` spelling of the - * same return shapes. + * returnArgument()/returnSelf()/returnValueMap())` → the matching Double verb — the pre-`willReturn*` + * spelling of the same return shapes. * - * @return array{0: non-empty-string, 1: list, 2: bool}|null + * @return array{0: non-empty-string, 1: list, 2: bool}|null */ - private function mapWill(Arg|\PhpParser\Node\VariadicPlaceholder|null $arg, Node\Expr $root): ?array + private function mapWill(Arg|VariadicPlaceholder|null $arg, Node\Expr $root): ?array { if (!$arg instanceof Arg) { return null; @@ -600,6 +622,7 @@ private function mapWill(Arg|\PhpParser\Node\VariadicPlaceholder|null $arg, Node $this->isName($inner->name, 'onConsecutiveCalls') => $inner->args === [] ? null : ['returns', $inner->args, true], $this->isName($inner->name, 'returnArgument') => $this->mapReturnArgument($inner->args), $this->isName($inner->name, 'returnSelf') => $this->returnSelf($root), + $this->isName($inner->name, 'returnValueMap') => $this->mapReturnMap($inner->args), default => null, }; } @@ -624,7 +647,7 @@ private function returnSelf(Node\Expr $root): ?array * * @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 + private function analyzeMatcher(Arg|VariadicPlaceholder|null $arg): ?array { if (!$arg instanceof Arg) { return null; @@ -656,9 +679,9 @@ private function segmentName(MethodCall $segment): ?string } /** - * @param list $args + * @param list $args */ - private function doubleFor(array $args): StaticCall + private function doubleCall(array $args): StaticCall { return new StaticCall(new FullyQualified('JMac\\Testing\\Double'), new Identifier('for'), $args); } diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/comparison_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/comparison_constraints.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/comparison_constraints.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/comparison_constraints.php.inc diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/configured_and_partial.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/configured_and_partial.php.inc new file mode 100644 index 00000000..9ea2582e --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/configured_and_partial.php.inc @@ -0,0 +1,43 @@ +createConfiguredMock(Dependency::class, ['run' => 1, 'stop' => $value]); + $this->stub = $this->createConfiguredStub(Dependency::class, ['run' => 1]); + $partial = $this->createPartialMock(Dependency::class, ['run', 'stop']); + $allReal = $this->createPartialMock(Dependency::class, []); + $named = $this->createPartialMock('App\Other', ['run']); + $partial->method('run')->willReturn(5); + } +} +----- +allows('run')->returns(1); + $configured->allows('stop')->returns($value); + $this->stub = \JMac\Testing\Double::for(Dependency::class); + $this->stub->allows('run')->returns(1); + $partial = \JMac\Testing\Double::for(Dependency::class)->passthru(); + $partial->allows('run'); + $partial->allows('stop'); + $allReal = \JMac\Testing\Double::for(Dependency::class)->passthru(); + $named = \JMac\Testing\Double::for('App\Other')->passthru(); + $named->allows('run'); + $partial->allows('run')->returns(5); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_mock.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/create_mock.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_mock.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/create_mock.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_stub.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/create_stub.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_stub.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/create_stub.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_stub_intersection.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/create_stub_intersection.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/create_stub_intersection.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/create_stub_intersection.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/expects_once_with_return.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/expects_once_with_return.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/expects_once_with_return.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/expects_once_with_return.php.inc diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/extended_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/extended_constraints.php.inc new file mode 100644 index 00000000..a17584d7 --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/extended_constraints.php.inc @@ -0,0 +1,48 @@ +method('and')->with($this->logicalAnd($this->greaterThan(5), $this->lessThan(9)), $this->logicalXor($this->isTrue(), $this->isNull()))->willReturn('r'); + $dep->method('eq')->with($this->equalToWithDelta(5.0, 0.1), $this->equalToIgnoringCase('Abc'))->willReturn('r'); + $dep->method('canon')->with($this->equalToCanonicalizing([1, 2]))->willReturn('r'); + $dep->method('ci')->with($this->stringContains('x', true), $this->stringContains('y', false))->willReturn('r'); + $dep->method('count')->with($this->countOf(2), $this->isList(), $this->isJson())->willReturn('r'); + $dep->method('float')->with($this->isNan(), $this->isFinite(), $this->isInfinite())->willReturn('r'); + $dep->method('file')->with($this->fileExists(), $this->directoryExists(), $this->isReadable(), $this->isWritable())->willReturn('r'); + $dep->method('items')->with($this->containsIdentical(1), $this->containsEqual(2), $this->containsOnly('string'), $this->containsOnlyInstancesOf(Foo::class))->willReturn('r'); + $dep->method('obj')->with($this->objectEquals($expected))->willReturn('r'); + $dep->method('or')->with($this->logicalOr($this->isNull(), $this->logicalAnd($this->greaterThan(1), $this->lessThan(3))))->willReturn('r'); + $dep->method('not')->with($this->logicalNot($this->anything()), $this->logicalNot($this->logicalAnd($this->isTrue(), $this->isFalse())))->willReturn('r'); + } +} +----- +allows('and')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => $value > 5 && $value < 9), \JMac\Testing\Matching\Argument::satisfies(fn($value) => $value === true xor $value === null))->returns('r'); + $dep->allows('eq')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_numeric($value) ? abs($value - 5.0) <= 0.1 : $value == 5.0), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_bool($value) ? $value == 'Abc' : (is_scalar($value) || $value === null) && mb_strtolower((string) $value) == mb_strtolower((string) 'Abc')))->returns('r'); + $dep->allows('canon')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => (static function ($actual, $expected): bool { + if (is_array($actual) !== is_array($expected)) { + return false; + } + if (is_array($actual)) { + sort($actual); + sort($expected); + } + return $actual == $expected; + })($value, [1, 2])))->returns('r'); + $dep->allows('ci')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && mb_stripos($value, 'x') !== false), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && str_contains($value, 'y')))->returns('r'); + $dep->allows('count')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_countable($value) && count($value) === 2), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_array($value) && array_is_list($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && (json_decode($value) !== null || json_last_error() === JSON_ERROR_NONE)))->returns('r'); + $dep->allows('float')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => (is_float($value) || is_int($value)) && is_nan($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => (is_float($value) || is_int($value)) && is_finite($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => (is_float($value) || is_int($value)) && is_infinite($value)))->returns('r'); + $dep->allows('file')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && file_exists($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && is_dir($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && is_readable($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && is_writable($value)))->returns('r'); + $dep->allows('items')->with(\JMac\Testing\Matching\Argument::contains(\JMac\Testing\Matching\Argument::same(1)), \JMac\Testing\Matching\Argument::contains(2), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !is_string($valueItem)) === []), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !$valueItem instanceof Foo) === []))->returns('r'); + $dep->allows('obj')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_object($value) && $value->equals($expected)))->returns('r'); + $dep->allows('or')->with(\JMac\Testing\Matching\Argument::any(null, \JMac\Testing\Matching\Argument::satisfies(fn($value) => $value > 1 && $value < 3)))->returns('r'); + $dep->allows('not')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => !true), \JMac\Testing\Matching\Argument::not()->satisfies(fn($value) => $value === true && $value === false))->returns('r'); + } +} diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/factories_unsupported.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/factories_unsupported.php.inc new file mode 100644 index 00000000..4cac34ef --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/factories_unsupported.php.inc @@ -0,0 +1,18 @@ +createMockForIntersectionOfInterfaces($interfaces); + $computedMap = $this->createConfiguredMock(Dependency::class, $map); + $listMap = $this->createConfiguredMock(Dependency::class, [1, 2]); + $emptyMap = $this->createConfiguredMock(Dependency::class, []); + $computedPartial = $this->createPartialMock(Dependency::class, $methods); + $inlineConfigured = new Service($this->createConfiguredMock(Dependency::class, ['run' => 1])); + $this->mocks[] = $this->createPartialMock(Dependency::class, ['run']); + $prophecy = $this->prophesize(Dependency::class); + $other = $factory->createMock(Dependency::class); + $abstract = $this->getMockForAbstractClass(Dependency::class); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder.php.inc diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_steps.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_steps.php.inc new file mode 100644 index 00000000..7ac2c9c6 --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_steps.php.inc @@ -0,0 +1,38 @@ +getMockBuilder(Dependency::class)->disableOriginalConstructor()->disableOriginalClone()->disableArgumentCloning()->getMock(); + $strict = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->disableAutoReturnValueGeneration()->getMock(); + $partial = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods(['run', 'stop'])->getMock(); + $this->constructed = $this->getMockBuilder(Dependency::class)->onlyMethods(['run'])->getMock(); + $withArgs = $this->getMockBuilder(Dependency::class)->setConstructorArgs([1, 'x'])->onlyMethods(['run'])->getMock(); + $computedArgs = $this->getMockBuilder(Dependency::class)->setConstructorArgs($args)->onlyMethods(['run'])->getMock(); + $real = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods([])->getMock(); + $service = new Service($this->getMockBuilder(Dependency::class)->setConstructorArgs([1])->onlyMethods([])->getMock()); + } +} +----- +strict(); + $partial = \JMac\Testing\Double::for(Dependency::class)->passthru(); + $partial->allows('run'); + $partial->allows('stop'); + $this->constructed = \JMac\Testing\Double::for(Dependency::class)->passthru(new Dependency()); + $this->constructed->allows('run'); + $withArgs = \JMac\Testing\Double::for(Dependency::class)->passthru(new Dependency(1, 'x')); + $withArgs->allows('run'); + $computedArgs = \JMac\Testing\Double::for(Dependency::class)->passthru(new Dependency(...$args)); + $computedArgs->allows('run'); + $real = \JMac\Testing\Double::for(Dependency::class)->passthru(); + $service = new Service(\JMac\Testing\Double::for(Dependency::class)->passthru(new Dependency(1))); + } +} diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc new file mode 100644 index 00000000..6260bbfd --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc @@ -0,0 +1,17 @@ +getMockBuilder(Dependency::class)->getMock(); + $constructedFull = $this->getMockBuilder(Dependency::class)->setConstructorArgs([1])->getMock(); + $contradictory = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->setConstructorArgs([1])->onlyMethods(['run'])->getMock(); + $abstract = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->getMockForAbstractClass(); + $added = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->addMethods(['magic'])->getMock(); + $renamed = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->setMockClassName('Fake')->getMock(); + $strictPartial = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods(['run'])->disableAutoReturnValueGeneration()->getMock(); + $computedMethods = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods($methods)->getMock(); + $inlinePartial = new Service($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/PhpunitToDouble/CreateMockToDoubleRector/inline_factory_chain.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/inline_factory_chain.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/inline_factory_chain.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/intersection.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/intersection.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/intersection.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/intersection.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/legacy_will.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/legacy_will.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will_extras.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/legacy_will_extras.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/legacy_will_extras.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/legacy_will_extras.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/logical_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/logical_constraints.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/logical_constraints.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/logical_constraints.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/matchers.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/matchers.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/matchers.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/matchers.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/non_mock_chain_unchanged.php.inc diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/phpunit12_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/phpunit12_constraints.php.inc new file mode 100644 index 00000000..6fc5d98e --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/phpunit12_constraints.php.inc @@ -0,0 +1,25 @@ +method('a')->with($this->isInt(), $this->isString(), $this->isBool(), $this->isFloat(), $this->isArray(), $this->isObject())->willReturn('r'); + $dep->method('b')->with($this->isCallable(), $this->isIterable(), $this->isNumeric(), $this->isScalar(), $this->isResource(), $this->isClosedResource())->willReturn('r'); + $dep->method('c')->with($this->containsOnlyString(), $this->containsOnlyInt(), $this->containsOnlyClosedResource())->willReturn('r'); + $dep->method('d')->with($this->logicalNot($this->isInt()), $this->stringContains(''))->willReturn('r'); + } +} +----- +allows('a')->with(\JMac\Testing\Matching\Argument::type('int'), \JMac\Testing\Matching\Argument::type('string'), \JMac\Testing\Matching\Argument::type('bool'), \JMac\Testing\Matching\Argument::type('float'), \JMac\Testing\Matching\Argument::type('array'), \JMac\Testing\Matching\Argument::type('object'))->returns('r'); + $dep->allows('b')->with(\JMac\Testing\Matching\Argument::type('callable'), \JMac\Testing\Matching\Argument::type('iterable'), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_numeric($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_scalar($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_resource($value) || gettype($value) === 'resource (closed)'), \JMac\Testing\Matching\Argument::satisfies(fn($value) => gettype($value) === 'resource (closed)'))->returns('r'); + $dep->allows('c')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !is_string($valueItem)) === []), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !is_int($valueItem)) === []), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !(gettype($valueItem) === 'resource (closed)')) === []))->returns('r'); + $dep->allows('d')->with(\JMac\Testing\Matching\Argument::not()->type('int'), \JMac\Testing\Matching\Argument::satisfies(fn($value) => true))->returns('r'); + } +} diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/predicate_variable_clash.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/predicate_variable_clash.php.inc new file mode 100644 index 00000000..5e0bc08b --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/predicate_variable_clash.php.inc @@ -0,0 +1,21 @@ +method('n')->with($this->greaterThan($value))->willReturn('u'); + } +} +----- +allows('n')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value2) => $value2 > $value))->returns('u'); + } +} diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/return_map.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/return_map.php.inc new file mode 100644 index 00000000..cbb4360f --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/return_map.php.inc @@ -0,0 +1,23 @@ +method('run')->willReturnMap([['a', 1], ['b', 'c', 2]]); + $dep->expects($this->exactly(2))->method('map')->willReturnMap($map); + $dep->method('legacy')->will($this->returnValueMap([['a', 1]])); + } +} +----- +allows('run')->resolves(fn(...$args) => array_column(array_filter([['a', 1], ['b', 'c', 2]], fn($row) => array_slice($row, 0, -1) === $args), count($args))[0] ?? null); + $dep->expects('map')->times(2)->resolves(fn(...$args) => array_column(array_filter($map, fn($row) => array_slice($row, 0, -1) === $args), count($args))[0] ?? null); + $dep->allows('legacy')->resolves(fn(...$args) => array_column(array_filter([['a', 1]], fn($row) => array_slice($row, 0, -1) === $args), count($args))[0] ?? null); + } +} diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/string_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/string_constraints.php.inc new file mode 100644 index 00000000..f747b056 --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/string_constraints.php.inc @@ -0,0 +1,19 @@ +method('s')->with($this->stringContains('x'), $this->stringStartsWith('a'), $this->stringEndsWith('z'), $this->arrayHasKey('k'))->willReturn('t'); + } +} +----- +allows('s')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && str_contains($value, 'x')), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && str_starts_with($value, 'a')), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_string($value) && str_ends_with($value, 'z')), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_array($value) ? array_key_exists('k', $value) : $value instanceof \ArrayAccess && $value->offsetExists('k')))->returns('t'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/stub_method.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/stub_method.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/stub_method.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/stub_method.php.inc diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/type_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/type_constraints.php.inc new file mode 100644 index 00000000..192a14ed --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/type_constraints.php.inc @@ -0,0 +1,25 @@ +method('a')->with($this->isType('int'), $this->isType('integer'), $this->isType('boolean'), $this->isType('double'), $this->isType('real'))->willReturn('r'); + $dep->method('b')->with($this->isType('string'), $this->isType('array'), $this->isType('object'), $this->isType('callable'), $this->isType('iterable'), $this->isType('null'))->willReturn('r'); + $dep->method('c')->with($this->isType('numeric'), $this->isType('scalar'), $this->isType('resource'), $this->isType('resource (closed)'))->willReturn('r'); + $dep->method('d')->with($this->logicalNot($this->isType('integer')), $this->logicalNot($this->isType('numeric')))->willReturn('r'); + } +} +----- +allows('a')->with(\JMac\Testing\Matching\Argument::type('int'), \JMac\Testing\Matching\Argument::type('int'), \JMac\Testing\Matching\Argument::type('bool'), \JMac\Testing\Matching\Argument::type('float'), \JMac\Testing\Matching\Argument::type('float'))->returns('r'); + $dep->allows('b')->with(\JMac\Testing\Matching\Argument::type('string'), \JMac\Testing\Matching\Argument::type('array'), \JMac\Testing\Matching\Argument::type('object'), \JMac\Testing\Matching\Argument::type('callable'), \JMac\Testing\Matching\Argument::type('iterable'), \JMac\Testing\Matching\Argument::type('null'))->returns('r'); + $dep->allows('c')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => is_numeric($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_scalar($value)), \JMac\Testing\Matching\Argument::satisfies(fn($value) => is_resource($value) || gettype($value) === 'resource (closed)'), \JMac\Testing\Matching\Argument::satisfies(fn($value) => gettype($value) === 'resource (closed)'))->returns('r'); + $dep->allows('d')->with(\JMac\Testing\Matching\Argument::not()->type('int'), \JMac\Testing\Matching\Argument::not()->satisfies(fn($value) => is_numeric($value)))->returns('r'); + } +} diff --git a/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc new file mode 100644 index 00000000..faecf3f0 --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc @@ -0,0 +1,23 @@ +expects($matcher)->method('run')->willReturn(1); + $dep->expects($this->once())->willReturn(1); + $dep->method('ci')->with($this->stringContains('x', $ignoreCase))->willReturn('c'); + $dep->method('lines')->with($this->stringContains('x', false, true))->willReturn('c'); + $dep->method('type')->with($this->isType('unknown'))->willReturn('c'); + $dep->method('computedType')->with($this->isType($type))->willReturn('c'); + $dep->method('only')->with($this->containsOnly($type))->willReturn('c'); + $dep->method('obj')->with($this->objectEquals($expected, $method))->willReturn('c'); + $dep->method('format')->with($this->matches('%s'))->willReturn('c'); + $dep->method('named')->with($this->equalTo(value: 1))->willReturn('c'); + $dep->method('shadow')->willReturnMap($args); + $dep->method('self')->willReturnSelf(1); + $dep->method('run')->willReturnReference($ref); + $dep->expects($this->once())->method('run')->withConsecutive([1], [2]); + $this->mocks['dep']->method('chain')->willReturnSelf(); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/value_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/value_constraints.php.inc similarity index 71% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/value_constraints.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/value_constraints.php.inc index dfa17ce9..687662fb 100644 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/value_constraints.php.inc +++ b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/value_constraints.php.inc @@ -14,6 +14,6 @@ class SomeTest { public function test() { - $dep->allows('v')->with(null, true, false, \JMac\Testing\Matching\Argument::satisfies(fn($value) => empty($value)))->returns('w'); + $dep->allows('v')->with(null, true, false, \JMac\Testing\Matching\Argument::satisfies(fn($value) => $value instanceof \Countable ? count($value) === 0 : empty($value)))->returns('w'); } } diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_argument.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/willreturn_argument.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_argument.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/willreturn_argument.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_self.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/willreturn_self.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/willreturn_self.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/willreturn_self.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_any_parameters.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/with_any_parameters.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_any_parameters.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/with_any_parameters.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_constraints.php.inc b/bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/with_constraints.php.inc similarity index 100% rename from bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/with_constraints.php.inc rename to bridge/rector/src/PhpunitToDouble/CreateMockToDoubleRector/with_constraints.php.inc diff --git a/bridge/rector/src/PhpunitToDouble/TODO.md b/bridge/rector/src/PhpunitToDouble/TODO.md new file mode 100644 index 00000000..b7b6f62c --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/TODO.md @@ -0,0 +1,52 @@ +# PHPUnit mocks -> Double: outstanding / partial conversions + +The `phpunit-to-double` set moves PHPUnit doubles onto Double (`testo/bridge-double`). The class +docblock of `CreateMockToDoubleRector` carries the full map; this file records what is partial and what +stays manual. + +## Stubbed (not registered) + +- **UnconvertibleMockToDoubleRector** — the forms with no faithful Double target: `prophesize()` (a + different creation/expectation model); `getMockForAbstractClass()`/`getMockForTrait()` (the abstract + methods would need an `allows()` each, which depends on the class); `addMethods()`, `setMockClassName()` + and the bare constructor-calling `getMockBuilder(X)->getMock()`; a partial double with + `disableAutoReturnValueGeneration()` (Double has no per-method strictness); a configured or partial + double that is not assigned to a variable or property; a variable invocation matcher; + `withConsecutive()`; `willReturnReference()`; and the `with()` constraints with no fixed form — + `stringContains()` with a computed case flag or line-ending normalisation, `isType()`/`containsOnly()` + with a computed or unknown type, `matches()` format descriptions. Replace manually with the matching + Double form, a third-party mocking library, or a hand-written fake. + +## Implemented + +- **CreateMockToDoubleRector** (registered). Creation is read by the shared `Internal\PhpunitMockFactory`: + `createMock`/`createStub`, the intersection factories and the constructor-disabling builder → + `Double::for(...)`; `disableAutoReturnValueGeneration()` → `->strict()`; `disableOriginalClone()` and + `disableArgumentCloning()` drop (Double clones neither). `createConfiguredMock(X, ['m' => $v])` becomes + `Double::for(X)` plus an `allows('m')->returns($v)` statement per entry; `createPartialMock(X, ['a'])` and + `onlyMethods(['a'])` become `Double::for(X)->passthru()` plus `allows('a')` per doubled method (an + `allows()` without `returns()` answers with the safe default, whatever the mode), and a builder that runs + the constructor copies its state through `passthru(new X(...$constructorArgs))`. The configuration chain + maps as before, plus `willReturnMap`/`returnValueMap` → `resolves()` (`Internal\ReturnValueMap`, + `ReturnValueMap`'s own first-identical-row lookup). `with()` constraints keep their dedicated + `Argument::*` matchers and fall back to `Argument::satisfies()` over the PHP expression + `Internal\PhpunitConstraint` builds for every other constraint: `logicalAnd`/`logicalXor`, delta, + case-insensitive and canonicalizing equality, `countOf`, `isList`, `isJson`, `isNan`/`isFinite`/ + `isInfinite`, the file constraints, `containsIdentical`/`containsOnly*`, `objectEquals`, and PHPUnit + 12's `isInt()`…`isString()` factories. Those expressions reproduce PHPUnit's own guards (a string + constraint rejects a non-string, `ArrayHasKey` accepts an `ArrayAccess`, `IsEmpty` counts a + `Countable`, `isType('resource')` accepts a closed resource); `PhpunitConstraintTest` pins the verdicts + PHPUnit gives on the edge values. `isType()` names Double's `type()` spells differently are normalised + (`integer` → `int`, `boolean` → `bool`, `double` → `float`); the rest become predicates. The predicate + closures name their parameter `$value`, or `$value2`, … when the statement or its scope already uses + `$value`. + +## Residuals by design + +- Comparison: Double matches a plain value with `===` for scalars, PHPUnit's `equalTo` with `==`. +- `willReturnMap` sees the arguments as passed; PHPUnit also fills in the defaults of omitted optional + parameters, so a map row listing a default only matches in PHPUnit. +- `equalToWithDelta`/`equalToCanonicalizing` reproduce PHPUnit on numbers and on flat arrays; the + recursive comparison of nested arrays is not reproduced. +- A double is not tracked across statements: when one statement on a double stays PHPUnit while its + creation converts, that statement needs a hand fix. diff --git a/bridge/rector/src/PhpunitToDouble/UnconvertibleMockToDoubleRector.php b/bridge/rector/src/PhpunitToDouble/UnconvertibleMockToDoubleRector.php new file mode 100644 index 00000000..265a3e92 --- /dev/null +++ b/bridge/rector/src/PhpunitToDouble/UnconvertibleMockToDoubleRector.php @@ -0,0 +1,66 @@ +getMock()`; a partial double with + * `disableAutoReturnValueGeneration()` (Double has no per-method strictness); a configured or partial + * double that is not assigned to a variable or property (its `allows()` calls need statements of their + * own); a variable invocation matcher; `withConsecutive()`; `willReturnReference()`; and the `with()` + * constraints with no fixed form: `stringContains()` with a computed case flag or line-ending + * normalisation, `isType()`/`containsOnly()` with a computed or unknown type, `matches()` (format + * descriptions). 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 UnconvertibleMockToDoubleRector extends AbstractRector +{ + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'STUB: mock forms with no faithful Double target (prophesize/getMockForAbstractClass/addMethods/withConsecutive/strict partials) — manual migration required (see @todo)', + [ + new CodeSample( + <<<'PHP' + $dep = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->getMockForAbstractClass(); + PHP, + <<<'PHP' + // No faithful Double target: migrate by hand (see CreateMockToDoubleRector for the forms that do convert). + $dep = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->getMockForAbstractClass(); + PHP, + ), + ], + ); + } + + #[\Override] + public function getNodeTypes(): array + { + return [Node\Expr\MethodCall::class]; + } + + /** + * @param Node\Expr\MethodCall $node + */ + #[\Override] + public function refactor(Node $node): ?Node + { + // Not implemented — see class-level @todo. + return null; + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector.php b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector.php new file mode 100644 index 00000000..d0ef7a03 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector.php @@ -0,0 +1,478 @@ +createMock(Dependency::class); + * $dep->expects($this->once())->method('run')->with('x')->willReturn('y'); + * // becomes + * $dep = \Mockery::mock(Dependency::class)->shouldIgnoreMissing(); + * $dep->shouldReceive('run')->once()->with('x')->andReturn('y'); + * + * Creation ({@see PhpunitMockFactory} reads the PHPUnit side): a PHPUnit double answers an unconfigured + * call with a type-appropriate default, and so does an ignore-missing Mockery mock, while a plain one + * throws — so `createMock(X)`/`createStub(X)`, the intersection factories and the constructor-disabling + * builder become `\Mockery::mock(...)->shouldIgnoreMissing()`, and `disableAutoReturnValueGeneration()` + * keeps the plain, throwing mock. `createConfiguredMock(X, $map)` → `\Mockery::mock(X, $map)` (Mockery's + * quick definitions). A partial double — `createPartialMock(X, ['a'])`, `onlyMethods(['a'])` — becomes + * Mockery's traditional partial `\Mockery::mock('X[a]')`, with the constructor arguments as the second + * argument when the builder runs the constructor; an empty method list is `->makePartial()`. + * + * Configuration chain: `expects($matcher)->method('m')` and a bare `method('m')` → `shouldReceive('m')`, + * with the matcher moving onto a count — `once()`, `exactly($n)` → `times($n)`, `never()`, + * `atLeastOnce()` → `atLeast()->once()`, `atLeast($n)`/`atMost($n)` → `atLeast()/atMost()->times($n)`, + * `any()` → no count (Mockery's default). `withAnyParameters()` drops away; the returns map + * `willReturn`/`willReturnOnConsecutiveCalls` → `andReturn`, `willThrowException` → `andThrow`, + * `willReturnCallback` → `andReturnUsing`, `willReturnArgument` → `andReturnArg`, `willReturnSelf` → + * `andReturnSelf`, `willReturnMap` → `andReturnUsing()` ({@see ReturnValueMap}), plus the + * legacy `will($this->returnValue()/…/returnValueMap())` wrappers. + * + * `with()` constraints: a plain value and `equalTo($x)` stay a plain `$x` (both compare loosely); + * `anything` → `Mockery::any()`, `identicalTo` → `isSame`, `isInstanceOf`/`isType` → `type`, + * `callback` → `on`, `matchesRegularExpression` → `pattern`, `arrayHasKey` → `hasKey`, + * `contains`/`containsEqual` → `hasValue`, `isNull`/`isTrue`/`isFalse` → `isSame(null/true/false)` (a + * plain literal would compare loosely). Every other constraint becomes `Mockery::on(fn ($value) => …)` + * over its own PHP expression ({@see PhpunitConstraint}) — comparisons, delta/case/canonicalizing + * equality, string, count, JSON, file and type checks, and `logicalNot`/`Or`/`And`/`Xor` over any of them. + * + * All-or-nothing per chain: a variable matcher, `prophesize()`, `getMockForAbstractClass()`, a builder + * step with no Mockery form, and `stringContains()` with a computed case flag leave the statement + * untouched for manual migration (see TODO.md). + */ +#[TestRectorFixtures('CreateMockToMockeryRector')] +final class CreateMockToMockeryRector extends AbstractRector +{ + /** + * PHP types whose Mockery `type()` check (`is_()`) is PHPUnit's check too. A resource is not: + * PHPUnit also accepts a closed one, which `is_resource()` rejects. + */ + private const MOCKERY_TYPES = ['int', 'float', 'bool', 'string', 'array', 'object', 'callable', 'iterable', 'null', 'numeric', 'scalar']; + + /** + * The variable the predicates of the statement being rebuilt are written over. + */ + private string $predicateName = 'value'; + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Convert PHPUnit `createMock()`/`createStub()` and their `expects()/method()/will*()` configuration chains into Mockery calls', + [ + new CodeSample( + <<<'PHP' + $dep = $this->createMock(Dependency::class); + $dep->expects($this->once())->method('run')->with('x')->willReturn('y'); + PHP, + <<<'PHP' + $dep = \Mockery::mock(Dependency::class)->shouldIgnoreMissing(); + $dep->shouldReceive('run')->once()->with('x')->andReturn('y'); + PHP, + ), + ], + ); + } + + /** + * @return array> + */ + #[\Override] + public function getNodeTypes(): array + { + return [Expression::class, MethodCall::class]; + } + + /** + * The chain rebuild runs at statement level and the factory rewrite at call level, so an + * unconvertible outer link leaves the whole statement alone instead of its inner links being + * rewritten on their own. + * + * @param Expression|MethodCall $node + */ + #[\Override] + public function refactor(Node $node): ?Node + { + if ($node instanceof MethodCall) { + $factory = PhpunitMockFactory::parse($node); + + return $factory === null ? null : $this->mockeryDouble($factory); + } + + if (!$node->expr instanceof MethodCall) { + return null; + } + + $this->predicateName = PredicateVariable::nameFor($node); + $rebuilt = $this->rebuildMockChain($node->expr); + if ($rebuilt === null) { + return null; + } + + $node->expr = $rebuilt; + + return $node; + } + + private function mockeryDouble(PhpunitMockFactory $factory): ?Node\Expr + { + if ($factory->configuration !== null) { + return $this->ignoreMissing($this->mock([$factory->targets[0], new Arg($factory->configuration)])); + } + + if ($factory->partialMethods === null) { + $mock = $this->mock($factory->targets); + + return $factory->autoReturn ? $this->ignoreMissing($mock) : $mock; + } + + $constructorArgs = $factory->constructorArgs === null ? [] : [new Arg($factory->constructorArgs)]; + + # No doubled method at all: every call runs for real, which is Mockery's runtime partial. + if ($factory->partialMethods === []) { + return new MethodCall( + $this->mock([new Arg($factory->target()), ...$constructorArgs]), + new Identifier('makePartial'), + ); + } + + $spec = $this->partialSpec($factory->target(), $factory->partialMethods); + if ($spec === null) { + return null; + } + + $mock = $this->mock([new Arg($spec), ...$constructorArgs]); + + return $factory->autoReturn ? $this->ignoreMissing($mock) : $mock; + } + + /** + * Mockery's traditional-partial target, `'App\Dependency[run,stop]'`: the listed methods are doubled, + * the rest run for real. Needs a literal class and literal method names. + * + * @param list $methods + */ + private function partialSpec(Node\Expr $target, array $methods): ?String_ + { + $class = match (true) { + $target instanceof ClassConstFetch && $this->isName($target->name, 'class') && $target->class instanceof Name => $this->getName($target->class), + $target instanceof String_ => \ltrim($target->value, '\\'), + default => null, + }; + if ($class === null) { + return null; + } + + $names = []; + foreach ($methods as $method) { + if (!$method instanceof String_) { + return null; + } + $names[] = $method->value; + } + + return new String_($class . '[' . \implode(',', $names) . ']'); + } + + /** + * Rebuilds a configuration chain into its Mockery form, or returns null when the chain carries no + * PHPUnit mock signal or hits a link with no faithful counterpart. Idempotent: the rebuilt chain opens + * with `shouldReceive()` and uses only Mockery verbs, none of which re-trigger a rewrite. + */ + private function rebuildMockChain(MethodCall $node): ?MethodCall + { + # The chain root may itself be a call: a PHPUnit factory (`$this->createMock(X)->method(…)`) or the + # Mockery factory it has already become. Either one ends the chain rather than joining it. + $segments = []; + $cursor = $node; + while ($cursor instanceof MethodCall && !$this->isFactoryRoot($cursor)) { + $segments[] = $cursor; + $cursor = $cursor->var; + } + if ($segments === []) { + return null; + } + $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 === 'withAnyParameters') { + continue; + } + + if ($name === 'expects') { + $countCalls = $this->analyzeMatcher($segments[$i]->args[0] ?? null); + $methodSegment = $segments[$i + 1] ?? null; + if ($countCalls === null || $methodSegment === null || $this->segmentName($methodSegment) !== 'method') { + return null; + } + + $result = new MethodCall($result, new Identifier('shouldReceive'), $methodSegment->args); + foreach ($countCalls as [$method, $args]) { + $result = new MethodCall($result, new Identifier($method), $args); + } + $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; + } + + private function isFactoryRoot(MethodCall $call): bool + { + if (PhpunitMockFactory::parse($call) !== null) { + return true; + } + + return ($this->isName($call->name, 'shouldIgnoreMissing') || $this->isName($call->name, 'makePartial')) + && $call->var instanceof StaticCall + && $this->isName($call->var->class, 'Mockery') + && $this->isName($call->var->name, 'mock'); + } + + /** + * Maps a non-`expects` chain link to `[method, args, isMockSignal]`, or null when it has no faithful + * Mockery counterpart. + * + * @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')`) is not a signal on its own — a following `will*` + # confirms the chain. + 'method' => ['shouldReceive', $segment->args, false], + 'with' => $this->mapWith($segment->args), + 'willReturn', 'willReturnOnConsecutiveCalls' => ['andReturn', $segment->args, true], + 'willThrowException' => ['andThrow', $segment->args, true], + 'willReturnCallback' => ['andReturnUsing', $segment->args, true], + 'willReturnArgument' => ['andReturnArg', $segment->args, true], + 'willReturnSelf' => $segment->args === [] ? ['andReturnSelf', [], true] : null, + 'willReturnMap' => $this->mapReturnMap($segment->args), + 'will' => $this->mapWill($segment->args[0] ?? null), + default => null, + }; + } + + /** + * `willReturnMap($map)` → `andReturnUsing()`. + * + * @param list $args + * @return array{0: non-empty-string, 1: list, 2: bool}|null + */ + private function mapReturnMap(array $args): ?array + { + $map = \count($args) === 1 && $args[0] instanceof Arg && !$args[0]->unpack ? $args[0]->value : null; + $resolver = $map === null ? null : ReturnValueMap::resolver($map); + + return $resolver === null ? null : ['andReturnUsing', [new Arg($resolver)], true]; + } + + /** + * Legacy `will($this->returnValue()/throwException()/returnCallback()/onConsecutiveCalls()/ + * returnArgument()/returnSelf()/returnValueMap())` → the matching Mockery return verb. + * + * @return array{0: non-empty-string, 1: list, 2: bool}|null + */ + private function mapWill(Arg|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') => ['andReturn', $inner->args, true], + $this->isName($inner->name, 'throwException') => ['andThrow', $inner->args, true], + $this->isName($inner->name, 'returnCallback') => ['andReturnUsing', $inner->args, true], + $this->isName($inner->name, 'onConsecutiveCalls') => $inner->args === [] ? null : ['andReturn', $inner->args, true], + $this->isName($inner->name, 'returnArgument') => ['andReturnArg', $inner->args, true], + $this->isName($inner->name, 'returnSelf') => $inner->args === [] ? ['andReturnSelf', [], true] : null, + $this->isName($inner->name, 'returnValueMap') => $this->mapReturnMap($inner->args), + default => null, + }; + } + + /** + * Turns a PHPUnit invocation matcher into the Mockery count calls that follow `shouldReceive()`. + * `any()` needs none; a variable or unrecognised matcher returns null, aborting the conversion. + * + * @return list}>|null + */ + private function analyzeMatcher(Arg|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') => [], + $this->isName($matcher->name, 'once') => [['once', []]], + $this->isName($matcher->name, 'never') => [['never', []]], + $this->isName($matcher->name, 'exactly') && $value !== null => [['times', [new Arg($value)]]], + $this->isName($matcher->name, 'atLeastOnce') => [['atLeast', []], ['once', []]], + $this->isName($matcher->name, 'atLeast') && $value !== null => [['atLeast', []], ['times', [new Arg($value)]]], + $this->isName($matcher->name, 'atMost') && $value !== null => [['atMost', []], ['times', [new Arg($value)]]], + default => null, + }; + } + + /** + * Maps a `with()` call, translating each PHPUnit constraint to its Mockery matcher (or plain value). + * A constraint with no faithful form aborts the whole chain, since leaving the raw `$this->…()` 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->value); + if ($constraint === null) { + return null; + } + + $mapped[] = new Arg($constraint); + } + + return ['with', $mapped, false]; + } + + /** + * One `with()` argument: a plain value passes through, a constraint with a dedicated Mockery matcher + * maps onto it, and the rest become a `Mockery::on()` predicate — or null when no faithful form exists. + */ + private function mapConstraint(Node\Expr $value): ?Node\Expr + { + $name = PhpunitConstraint::name($value); + if ($name === null) { + return $value; + } + + $args = PhpunitConstraint::arguments($value) ?? []; + $first = $args[0] ?? null; + $single = \count($args) === 1; + + # `isType('integer')`, PHPUnit 12's `isInt()`, … → `Mockery::type('int')` where Mockery's own + # `is_*()` check agrees with PHPUnit's. + $checkedType = PhpunitConstraint::checkedType($value); + if (\in_array($checkedType, self::MOCKERY_TYPES, true)) { + return $this->mockery('type', [new String_($checkedType)]); + } + + $dedicated = match ($name) { + 'anything' => $args === [] ? $this->mockery('any') : null, + 'equalTo' => $single ? $first : null, + 'identicalTo' => $single ? $this->mockery('isSame', [$first]) : null, + 'isInstanceOf' => $single ? $this->mockery('type', [$first]) : null, + 'isType' => $single && !$first instanceof String_ ? $this->mockery('type', [$first]) : null, + 'callback' => $single ? $this->mockery('on', [$first]) : null, + 'matchesRegularExpression' => $single ? $this->mockery('pattern', [$first]) : null, + 'arrayHasKey' => $single ? $this->mockery('hasKey', [$first]) : null, + 'contains', 'containsEqual' => $single ? $this->mockery('hasValue', [$first]) : null, + 'isNull' => $args === [] ? $this->mockery('isSame', [new ConstFetch(new Name('null'))]) : null, + 'isTrue' => $args === [] ? $this->mockery('isSame', [new ConstFetch(new Name('true'))]) : null, + 'isFalse' => $args === [] ? $this->mockery('isSame', [new ConstFetch(new Name('false'))]) : null, + default => null, + }; + if ($dedicated !== null) { + return $dedicated; + } + + $constraint = new PhpunitConstraint($this->predicateName); + $predicate = $constraint->predicate($value); + + return $predicate === null ? null : $this->mockery('on', [$constraint->closure($predicate)]); + } + + private function segmentName(MethodCall $segment): ?string + { + return $segment->name instanceof Identifier ? $segment->name->toString() : null; + } + + /** + * @param list $args + */ + private function mock(array $args): StaticCall + { + return new StaticCall(new FullyQualified('Mockery'), new Identifier('mock'), $args); + } + + private function ignoreMissing(StaticCall $mock): MethodCall + { + return new MethodCall($mock, new Identifier('shouldIgnoreMissing')); + } + + /** + * @param list $args + */ + private function mockery(string $method, array $args = []): StaticCall + { + return new StaticCall( + new FullyQualified('Mockery'), + new Identifier($method), + \array_map(static fn(Node\Expr $arg): Arg => new Arg($arg), $args), + ); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/builder.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/builder.php.inc new file mode 100644 index 00000000..dcdd7606 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/builder.php.inc @@ -0,0 +1,35 @@ +getMockBuilder(Dependency::class)->disableOriginalConstructor()->getMock(); + $neutral = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->disableOriginalClone()->disableArgumentCloning()->getMock(); + $strict = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->disableAutoReturnValueGeneration()->getMock(); + $partial = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods(['run'])->getMock(); + $constructed = $this->getMockBuilder(Dependency::class)->onlyMethods(['run'])->getMock(); + $withArgs = $this->getMockBuilder(Dependency::class)->setConstructorArgs([1, 'x'])->onlyMethods(['run'])->getMock(); + $strictPartial = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods(['run'])->disableAutoReturnValueGeneration()->getMock(); + $real = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods([])->getMock(); + $realConstructed = $this->getMockBuilder(Dependency::class)->setConstructorArgs($args)->onlyMethods([])->getMock(); + } +} +----- +shouldIgnoreMissing(); + $neutral = \Mockery::mock(Dependency::class)->shouldIgnoreMissing(); + $strict = \Mockery::mock(Dependency::class); + $partial = \Mockery::mock('Dependency[run]')->shouldIgnoreMissing(); + $constructed = \Mockery::mock('Dependency[run]', [])->shouldIgnoreMissing(); + $withArgs = \Mockery::mock('Dependency[run]', [1, 'x'])->shouldIgnoreMissing(); + $strictPartial = \Mockery::mock('Dependency[run]'); + $real = \Mockery::mock(Dependency::class)->makePartial(); + $realConstructed = \Mockery::mock(Dependency::class, $args)->makePartial(); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/dedicated_constraints.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/dedicated_constraints.php.inc new file mode 100644 index 00000000..66e12663 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/dedicated_constraints.php.inc @@ -0,0 +1,31 @@ +method('a')->with($this->equalTo('a'), $this->anything(), $this->isInstanceOf(Foo::class))->willReturn('y'); + $dep->method('b')->with($this->callback(fn ($x) => $x > 0), $this->identicalTo(3), $this->matchesRegularExpression('/x/'))->willReturn('z'); + $dep->method('c')->with($this->isNull(), $this->isTrue(), $this->isFalse())->willReturn('w'); + $dep->method('d')->with($this->arrayHasKey('k'), $this->contains('v'), $this->containsEqual('e'))->willReturn('t'); + $dep->method('e')->with($this->isType('string'), self::isType('boolean'), $this->isType($type))->willReturn('u'); + $dep->method('f')->with(static::isType('integer'), $this->isType('double'), $this->isType('numeric'), $this->isType('scalar'), $this->isType('resource'))->willReturn('u'); + $dep->method('g')->with($this->isInstanceOf($class))->willReturn('u'); + } +} +----- +shouldReceive('a')->with('a', \Mockery::any(), \Mockery::type(Foo::class))->andReturn('y'); + $dep->shouldReceive('b')->with(\Mockery::on(fn ($x) => $x > 0), \Mockery::isSame(3), \Mockery::pattern('/x/'))->andReturn('z'); + $dep->shouldReceive('c')->with(\Mockery::isSame(null), \Mockery::isSame(true), \Mockery::isSame(false))->andReturn('w'); + $dep->shouldReceive('d')->with(\Mockery::hasKey('k'), \Mockery::hasValue('v'), \Mockery::hasValue('e'))->andReturn('t'); + $dep->shouldReceive('e')->with(\Mockery::type('string'), \Mockery::type('bool'), \Mockery::type($type))->andReturn('u'); + $dep->shouldReceive('f')->with(\Mockery::type('int'), \Mockery::type('float'), \Mockery::type('numeric'), \Mockery::type('scalar'), \Mockery::on(fn($value) => is_resource($value) || gettype($value) === 'resource (closed)'))->andReturn('u'); + $dep->shouldReceive('g')->with(\Mockery::type($class))->andReturn('u'); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/extended_constraints.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/extended_constraints.php.inc new file mode 100644 index 00000000..a8bac630 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/extended_constraints.php.inc @@ -0,0 +1,44 @@ +method('eq')->with($this->equalToWithDelta(5.0, 0.1), $this->equalToIgnoringCase('Abc'))->willReturn('r'); + $dep->method('canon')->with($this->equalToCanonicalizing([1, 2]))->willReturn('r'); + $dep->method('ci')->with($this->stringContains('x', true), $this->stringContains('y', false))->willReturn('r'); + $dep->method('count')->with($this->countOf(2), $this->isList(), $this->isJson())->willReturn('r'); + $dep->method('float')->with($this->isNan(), $this->isFinite(), $this->isInfinite())->willReturn('r'); + $dep->method('file')->with($this->fileExists(), $this->directoryExists(), $this->isReadable(), $this->isWritable())->willReturn('r'); + $dep->method('items')->with($this->containsIdentical(1), $this->containsOnly('string'), $this->containsOnlyInstancesOf(Foo::class))->willReturn('r'); + $dep->method('obj')->with($this->objectEquals($expected), $this->objectEquals($expected, 'sameAs'))->willReturn('r'); + $dep->method('closed')->with($this->isType('resource (closed)'))->willReturn('r'); + } +} +----- +shouldReceive('eq')->with(\Mockery::on(fn($value) => is_numeric($value) ? abs($value - 5.0) <= 0.1 : $value == 5.0), \Mockery::on(fn($value) => is_bool($value) ? $value == 'Abc' : (is_scalar($value) || $value === null) && mb_strtolower((string) $value) == mb_strtolower((string) 'Abc')))->andReturn('r'); + $dep->shouldReceive('canon')->with(\Mockery::on(fn($value) => (static function ($actual, $expected): bool { + if (is_array($actual) !== is_array($expected)) { + return false; + } + if (is_array($actual)) { + sort($actual); + sort($expected); + } + return $actual == $expected; + })($value, [1, 2])))->andReturn('r'); + $dep->shouldReceive('ci')->with(\Mockery::on(fn($value) => is_string($value) && mb_stripos($value, 'x') !== false), \Mockery::on(fn($value) => is_string($value) && str_contains($value, 'y')))->andReturn('r'); + $dep->shouldReceive('count')->with(\Mockery::on(fn($value) => is_countable($value) && count($value) === 2), \Mockery::on(fn($value) => is_array($value) && array_is_list($value)), \Mockery::on(fn($value) => is_string($value) && (json_decode($value) !== null || json_last_error() === JSON_ERROR_NONE)))->andReturn('r'); + $dep->shouldReceive('float')->with(\Mockery::on(fn($value) => (is_float($value) || is_int($value)) && is_nan($value)), \Mockery::on(fn($value) => (is_float($value) || is_int($value)) && is_finite($value)), \Mockery::on(fn($value) => (is_float($value) || is_int($value)) && is_infinite($value)))->andReturn('r'); + $dep->shouldReceive('file')->with(\Mockery::on(fn($value) => is_string($value) && file_exists($value)), \Mockery::on(fn($value) => is_string($value) && is_dir($value)), \Mockery::on(fn($value) => is_string($value) && is_readable($value)), \Mockery::on(fn($value) => is_string($value) && is_writable($value)))->andReturn('r'); + $dep->shouldReceive('items')->with(\Mockery::on(fn($value) => is_iterable($value) && in_array(1, [...$value], true)), \Mockery::on(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !is_string($valueItem)) === []), \Mockery::on(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !$valueItem instanceof Foo) === []))->andReturn('r'); + $dep->shouldReceive('obj')->with(\Mockery::on(fn($value) => is_object($value) && $value->equals($expected)), \Mockery::on(fn($value) => is_object($value) && $value->sameAs($expected)))->andReturn('r'); + $dep->shouldReceive('closed')->with(\Mockery::on(fn($value) => gettype($value) === 'resource (closed)'))->andReturn('r'); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories.php.inc new file mode 100644 index 00000000..ac0b8f0b --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories.php.inc @@ -0,0 +1,43 @@ +createMock(Dependency::class); + $stub = $this->createStub(Dependency::class); + $both = $this->createMockForIntersectionOfInterfaces([A::class, B::class]); + $bothStub = $this->createStubForIntersectionOfInterfaces([A::class, B::class]); + $configured = $this->createConfiguredMock(Dependency::class, ['run' => 1, 'stop' => $value]); + $configuredStub = $this->createConfiguredStub(Dependency::class, ['run' => 1]); + $partial = $this->createPartialMock(Dependency::class, ['run', 'stop']); + $allReal = $this->createPartialMock(Dependency::class, []); + $named = $this->createPartialMock('App\Other', ['run']); + } +} +----- +shouldIgnoreMissing(); + $stub = \Mockery::mock(Dependency::class)->shouldIgnoreMissing(); + $both = \Mockery::mock(A::class, B::class)->shouldIgnoreMissing(); + $bothStub = \Mockery::mock(A::class, B::class)->shouldIgnoreMissing(); + $configured = \Mockery::mock(Dependency::class, ['run' => 1, 'stop' => $value])->shouldIgnoreMissing(); + $configuredStub = \Mockery::mock(Dependency::class, ['run' => 1])->shouldIgnoreMissing(); + $partial = \Mockery::mock('App\Dependency[run,stop]')->shouldIgnoreMissing(); + $allReal = \Mockery::mock(Dependency::class)->makePartial(); + $named = \Mockery::mock('App\Other[run]')->shouldIgnoreMissing(); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories_unsupported.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories_unsupported.php.inc new file mode 100644 index 00000000..30c3054f --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/factories_unsupported.php.inc @@ -0,0 +1,25 @@ +getMockBuilder(Dependency::class)->getMock(); + $constructedFull = $this->getMockBuilder(Dependency::class)->setConstructorArgs([1])->getMock(); + $contradictory = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->setConstructorArgs([1])->onlyMethods(['run'])->getMock(); + $abstract = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->getMockForAbstractClass(); + $added = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->addMethods(['magic'])->getMock(); + $renamed = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->setMockClassName('Fake')->getMock(); + $twice = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->disableOriginalConstructor()->getMock(); + $computedMethods = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods($methods)->getMock(); + $computedIntersection = $this->createMockForIntersectionOfInterfaces($interfaces); + $computedMap = $this->createConfiguredMock(Dependency::class, $map); + $listMap = $this->createConfiguredMock(Dependency::class, [1, 2]); + $emptyMap = $this->createConfiguredMock(Dependency::class, []); + $computedPartial = $this->createPartialMock(Dependency::class, $methods); + $computedName = $this->createPartialMock(Dependency::class, [$method]); + $computedClass = $this->createPartialMock($class, ['run']); + $prophecy = $this->prophesize(Dependency::class); + $other = $factory->createMock(Dependency::class); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/matchers.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/matchers.php.inc new file mode 100644 index 00000000..f501446b --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/matchers.php.inc @@ -0,0 +1,35 @@ +expects($this->once())->method('run')->with('x')->willReturn('y'); + $dep->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(self::atMost(5))->method('f')->willReturn('f'); + $dep->expects($this->once())->method('g')->withAnyParameters()->willReturn('g'); + $this->createMock(Dependency::class)->method('run')->willReturn('y'); + } +} +----- +shouldReceive('run')->once()->with('x')->andReturn('y'); + $dep->shouldReceive('a')->never(); + $dep->shouldReceive('b')->times(3)->andReturn('b'); + $dep->shouldReceive('c')->andReturn('c'); + $dep->shouldReceive('d')->atLeast()->once()->andReturn('d'); + $dep->shouldReceive('e')->atLeast()->times(2)->andReturn('e'); + $dep->shouldReceive('f')->atMost()->times(5)->andReturn('f'); + $dep->shouldReceive('g')->once()->andReturn('g'); + \Mockery::mock(Dependency::class)->shouldIgnoreMissing()->shouldReceive('run')->andReturn('y'); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/phpunit12_constraints.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/phpunit12_constraints.php.inc new file mode 100644 index 00000000..38d92b89 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/phpunit12_constraints.php.inc @@ -0,0 +1,25 @@ +method('a')->with($this->isInt(), $this->isString(), $this->isBool(), $this->isFloat(), $this->isArray(), $this->isObject())->willReturn('r'); + $dep->method('b')->with($this->isCallable(), $this->isIterable(), $this->isNumeric(), $this->isScalar(), $this->isResource(), $this->isClosedResource())->willReturn('r'); + $dep->method('c')->with($this->containsOnlyString(), $this->containsOnlyNumeric())->willReturn('r'); + $dep->method('d')->with($this->logicalNot($this->isInt()), $this->stringContains(''))->willReturn('r'); + } +} +----- +shouldReceive('a')->with(\Mockery::type('int'), \Mockery::type('string'), \Mockery::type('bool'), \Mockery::type('float'), \Mockery::type('array'), \Mockery::type('object'))->andReturn('r'); + $dep->shouldReceive('b')->with(\Mockery::type('callable'), \Mockery::type('iterable'), \Mockery::type('numeric'), \Mockery::type('scalar'), \Mockery::on(fn($value) => is_resource($value) || gettype($value) === 'resource (closed)'), \Mockery::on(fn($value) => gettype($value) === 'resource (closed)'))->andReturn('r'); + $dep->shouldReceive('c')->with(\Mockery::on(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !is_string($valueItem)) === []), \Mockery::on(fn($value) => is_iterable($value) && array_filter([...$value], fn($valueItem) => !is_numeric($valueItem)) === []))->andReturn('r'); + $dep->shouldReceive('d')->with(\Mockery::on(fn($value) => !is_int($value)), \Mockery::on(fn($value) => true))->andReturn('r'); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_constraints.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_constraints.php.inc new file mode 100644 index 00000000..15c88f91 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_constraints.php.inc @@ -0,0 +1,35 @@ +method('n')->with($this->greaterThan(5), $this->lessThan(9), $this->greaterThanOrEqual(1), $this->lessThanOrEqual(3))->willReturn('u'); + $dep->method('s')->with($this->stringContains('x'), $this->stringStartsWith('a'), $this->stringEndsWith('z'), $this->isEmpty())->willReturn('t'); + $dep->method('not')->with($this->logicalNot($this->equalTo('a')), $this->logicalNot($this->isType('int')), $this->logicalNot($this->isInstanceOf(Foo::class)))->willReturn('r'); + $dep->method('or')->with($this->logicalOr($this->equalTo('a'), 'b', $this->isNull()))->willReturn('r'); + $dep->method('and')->with($this->logicalAnd($this->greaterThan(5), $this->lessThan(9)))->willReturn('r'); + $dep->method('xor')->with($this->logicalXor($this->isTrue(), $this->isNull()))->willReturn('r'); + $dep->method('nested')->with($this->logicalOr($this->callback(fn ($x) => $x === 1), $this->matchesRegularExpression('/^a/')))->willReturn('r'); + $dep->method('key')->with($this->logicalNot($this->arrayHasKey('k')))->willReturn('r'); + $dep->method('class')->with($this->logicalOr($this->isInstanceOf($class), 1))->willReturn('r'); + } +} +----- +shouldReceive('n')->with(\Mockery::on(fn($value) => $value > 5), \Mockery::on(fn($value) => $value < 9), \Mockery::on(fn($value) => $value >= 1), \Mockery::on(fn($value) => $value <= 3))->andReturn('u'); + $dep->shouldReceive('s')->with(\Mockery::on(fn($value) => is_string($value) && str_contains($value, 'x')), \Mockery::on(fn($value) => is_string($value) && str_starts_with($value, 'a')), \Mockery::on(fn($value) => is_string($value) && str_ends_with($value, 'z')), \Mockery::on(fn($value) => $value instanceof \Countable ? count($value) === 0 : empty($value)))->andReturn('t'); + $dep->shouldReceive('not')->with(\Mockery::on(fn($value) => !($value == 'a')), \Mockery::on(fn($value) => !is_int($value)), \Mockery::on(fn($value) => !$value instanceof Foo))->andReturn('r'); + $dep->shouldReceive('or')->with(\Mockery::on(fn($value) => $value == 'a' || $value == 'b' || $value === null))->andReturn('r'); + $dep->shouldReceive('and')->with(\Mockery::on(fn($value) => $value > 5 && $value < 9))->andReturn('r'); + $dep->shouldReceive('xor')->with(\Mockery::on(fn($value) => $value === true xor $value === null))->andReturn('r'); + $dep->shouldReceive('nested')->with(\Mockery::on(fn($value) => (fn ($x) => $x === 1)($value) || is_string($value) && preg_match('/^a/', $value) > 0))->andReturn('r'); + $dep->shouldReceive('key')->with(\Mockery::on(fn($value) => !(is_array($value) ? array_key_exists('k', $value) : $value instanceof \ArrayAccess && $value->offsetExists('k'))))->andReturn('r'); + $dep->shouldReceive('class')->with(\Mockery::on(fn($value) => $value instanceof $class || $value == 1))->andReturn('r'); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_variable_clash.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_variable_clash.php.inc new file mode 100644 index 00000000..25390b6d --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/predicate_variable_clash.php.inc @@ -0,0 +1,21 @@ +method('n')->with($this->greaterThan($value))->willReturn('u'); + } +} +----- +shouldReceive('n')->with(\Mockery::on(fn($value2) => $value2 > $value))->andReturn('u'); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/return_map.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/return_map.php.inc new file mode 100644 index 00000000..f0375161 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/return_map.php.inc @@ -0,0 +1,23 @@ +method('run')->willReturnMap([['a', 1], ['b', 'c', 2]]); + $dep->expects($this->exactly(2))->method('map')->willReturnMap($map); + $dep->method('legacy')->will($this->returnValueMap([['a', 1]])); + } +} +----- +shouldReceive('run')->andReturnUsing(fn(...$args) => array_column(array_filter([['a', 1], ['b', 'c', 2]], fn($row) => array_slice($row, 0, -1) === $args), count($args))[0] ?? null); + $dep->shouldReceive('map')->times(2)->andReturnUsing(fn(...$args) => array_column(array_filter($map, fn($row) => array_slice($row, 0, -1) === $args), count($args))[0] ?? null); + $dep->shouldReceive('legacy')->andReturnUsing(fn(...$args) => array_column(array_filter([['a', 1]], fn($row) => array_slice($row, 0, -1) === $args), count($args))[0] ?? null); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/returns.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/returns.php.inc new file mode 100644 index 00000000..e801c30f --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/returns.php.inc @@ -0,0 +1,41 @@ +method('run')->willReturn('y'); + $dep->method('boom')->willThrowException(new \RuntimeException()); + $dep->method('lazy')->willReturnCallback(fn () => 1); + $dep->method('seq')->willReturnOnConsecutiveCalls(1, 2, 3); + $dep->method('args')->willReturnArgument(1); + $dep->expects($this->once())->method('chain')->willReturnSelf(); + $dep->expects($this->once())->method('a')->will($this->returnValue('y')); + $dep->method('b')->will($this->throwException(new \RuntimeException())); + $dep->method('c')->will($this->onConsecutiveCalls(1, 2)); + $dep->method('d')->will($this->returnArgument(0)); + $dep->method('e')->will($this->returnCallback(fn () => 2)); + $dep->method('f')->will($this->returnSelf()); + } +} +----- +shouldReceive('run')->andReturn('y'); + $dep->shouldReceive('boom')->andThrow(new \RuntimeException()); + $dep->shouldReceive('lazy')->andReturnUsing(fn () => 1); + $dep->shouldReceive('seq')->andReturn(1, 2, 3); + $dep->shouldReceive('args')->andReturnArg(1); + $dep->shouldReceive('chain')->once()->andReturnSelf(); + $dep->shouldReceive('a')->once()->andReturn('y'); + $dep->shouldReceive('b')->andThrow(new \RuntimeException()); + $dep->shouldReceive('c')->andReturn(1, 2); + $dep->shouldReceive('d')->andReturnArg(0); + $dep->shouldReceive('e')->andReturnUsing(fn () => 2); + $dep->shouldReceive('f')->andReturnSelf(); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/unsupported_left_unchanged.php.inc b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/unsupported_left_unchanged.php.inc new file mode 100644 index 00000000..b3fe7a32 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/CreateMockToMockeryRector/unsupported_left_unchanged.php.inc @@ -0,0 +1,24 @@ +where('active', true)->orderBy('name')->get(); + $dep->method('run'); + $dep->expects($matcher)->method('run')->willReturn(1); + $dep->expects($this->once())->willReturn(1); + $dep->method('ci')->with($this->stringContains('x', $ignoreCase))->willReturn('c'); + $dep->method('lines')->with($this->stringContains('x', false, true))->willReturn('c'); + $dep->method('type')->with($this->isType('unknown'))->willReturn('c'); + $dep->method('only')->with($this->containsOnly($type))->willReturn('c'); + $dep->method('obj')->with($this->objectEquals($expected, $method))->willReturn('c'); + $dep->method('format')->with($this->matches('%s'))->willReturn('c'); + $dep->method('named')->with($this->equalTo(value: 1))->willReturn('c'); + $dep->method('shadow')->willReturnMap($args); + $dep->method('self')->willReturnSelf(1); + $dep->method('run')->willReturnReference($ref); + $dep->expects($this->once())->method('run')->withConsecutive([1], [2]); + $mock->shouldReceive('run')->once()->andReturn(1); + } +} diff --git a/bridge/rector/src/PhpunitToMockery/TODO.md b/bridge/rector/src/PhpunitToMockery/TODO.md new file mode 100644 index 00000000..c212b800 --- /dev/null +++ b/bridge/rector/src/PhpunitToMockery/TODO.md @@ -0,0 +1,35 @@ +# PHPUnit mocks -> Mockery: outstanding / partial conversions + +The `phpunit-to-mockery` set moves PHPUnit doubles onto Mockery, verified after every test by +`testo/bridge-mockery` (register `MockeryPlugin`, or nothing calls `Mockery::close()`). The class docblock +of `CreateMockToMockeryRector` carries the full map. + +## Implemented + +- **CreateMockToMockeryRector** (registered). Creation, read by the shared `Internal\PhpunitMockFactory`: + `createMock`/`createStub`/intersections/the constructor-disabling builder → + `\Mockery::mock(...)->shouldIgnoreMissing()` — a PHPUnit double answers an unconfigured call with a + type-appropriate default, so does an ignore-missing Mockery mock, and a plain `Mockery::mock()` throws; + `disableAutoReturnValueGeneration()` keeps the plain, throwing mock. `createConfiguredMock(X, $map)` → + `\Mockery::mock(X, $map)->shouldIgnoreMissing()` (quick definitions). A partial — `createPartialMock(X, + ['a'])`, `onlyMethods(['a'])` — becomes the traditional partial `\Mockery::mock('App\X[a]')`, with the + constructor arguments as the second argument when the builder runs the constructor (`[]` when it runs + it without arguments); an empty method list is `->makePartial()`. The chain: `expects($matcher)->method('m')` + / `method('m')` → `shouldReceive('m')` plus the count; `withAnyParameters()` drops; the returns map onto + `andReturn`/`andThrow`/`andReturnUsing`/`andReturnArg`/`andReturnSelf`, `willReturnMap` → + `andReturnUsing()`, legacy `will(...)` included. `with()` constraints: a plain value and `equalTo` + stay plain (both loose), `anything`→`any`, `identicalTo`→`isSame`, `isInstanceOf`→`type`, the type + checks Mockery's `is_*()` agrees on → `type` (`isType('integer')`, `isInt()`, …), `callback`→`on`, + `matchesRegularExpression`→`pattern`, `arrayHasKey`→`hasKey`, `contains`/`containsEqual`→`hasValue`, + `isNull`/`isTrue`/`isFalse`→`isSame(literal)`. Everything else becomes `Mockery::on()` over the + expression `Internal\PhpunitConstraint` builds (see `../PhpunitToDouble/TODO.md` for the list and its + guards), including `logicalAnd`/`logicalXor` and a `resource` type check (Mockery's `is_resource()` + rejects a closed resource, PHPUnit's does not). + +## Left for manual migration + +- A variable invocation matcher, `prophesize()`, `getMockForAbstractClass()`, `addMethods()`, + `setMockClassName()`, the bare constructor-calling `getMockBuilder(X)->getMock()`, a partial with a + computed class or method name (Mockery's partial target is a literal string), `withConsecutive()`, + `willReturnReference()`. +- The constraints with no fixed form: see `../PhpunitToDouble/TODO.md`. diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc deleted file mode 100644 index fc2f9c58..00000000 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/get_mock_builder_unsupported.php.inc +++ /dev/null @@ -1,10 +0,0 @@ -getMockBuilder(Dependency::class)->getMock(); - $partial = $this->getMockBuilder(Dependency::class)->disableOriginalConstructor()->onlyMethods(['run'])->getMock(); - } -} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/string_constraints.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/string_constraints.php.inc deleted file mode 100644 index 838cecb8..00000000 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/string_constraints.php.inc +++ /dev/null @@ -1,19 +0,0 @@ -method('s')->with($this->stringContains('x'), $this->stringStartsWith('a'), $this->stringEndsWith('z'), $this->arrayHasKey('k'))->willReturn('t'); - } -} ------ -allows('s')->with(\JMac\Testing\Matching\Argument::satisfies(fn($value) => str_contains($value, 'x')), \JMac\Testing\Matching\Argument::satisfies(fn($value) => str_starts_with($value, 'a')), \JMac\Testing\Matching\Argument::satisfies(fn($value) => str_ends_with($value, 'z')), \JMac\Testing\Matching\Argument::satisfies(fn($value) => array_key_exists('k', $value)))->returns('t'); - } -} diff --git a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc deleted file mode 100644 index a90e826f..00000000 --- a/bridge/rector/src/PhpunitToTesto/CreateMockToDoubleRector/unsupported_left_unchanged.php.inc +++ /dev/null @@ -1,12 +0,0 @@ -method('run')->willReturnMap([['a', 1], ['b', 2]]); - $dep->method('and')->with($this->logicalAnd($this->greaterThan(5), $this->lessThan(9)))->willReturn('u'); - $dep->method('delta')->with($this->equalToWithDelta(5.0, 0.1))->willReturn('d'); - $dep->method('ci')->with($this->stringContains('x', true))->willReturn('c'); - } -} diff --git a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php b/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php deleted file mode 100644 index e09235d1..00000000 --- a/bridge/rector/src/PhpunitToTesto/MockToTestoRector.php +++ /dev/null @@ -1,66 +0,0 @@ -getMock()`, all of which change what is - * doubled; the return shape `willReturnMap()`; a variable invocation matcher; and the `with()` - * constraints that have no faithful Double form: `logicalAnd()` (Double's `all()` is a whole-list - * predicate, not a per-argument AND), `equalToWithDelta()`/`equalToCanonicalizing()` (loose/delta - * comparison), and a case-insensitive `stringContains()` (no `str_contains` equivalent). 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: mock forms with no faithful Double target (prophesize/willReturnMap/builder-with-extra-steps/logicalAnd/delta-equality) — manual migration required (see @todo)', - [ - new CodeSample( - <<<'PHP' - $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)->onlyMethods(['run'])->getMock(); - PHP, - ), - ], - ); - } - - #[\Override] - public function getNodeTypes(): array - { - return [Node\Expr\MethodCall::class]; - } - - /** - * @param Node\Expr\MethodCall $node - */ - #[\Override] - public function refactor(Node $node): ?Node - { - // Not implemented — see class-level @todo. - return null; - } -} diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 152cbdd4..cefc2889 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -5,17 +5,11 @@ are either impossible to convert automatically or too fragile to automate safely Stub rules (`refactor()` returns `null`, no `#[TestRectorFixtures]`, not registered) exist for each so the intent and blockers are discoverable in code. +Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit-to-mockery`, tracked in +`../PhpunitToDouble/TODO.md` and `../PhpunitToMockery/TODO.md`. + ## Stubbed (not registered) -- **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 the - `with()` constraints with no faithful form: `logicalAnd` (Double has `all()`, a whole-argument-list - predicate, but no per-argument AND matcher), `equalToWithDelta`/`equalToCanonicalizing` (loose/delta - comparison), and a case-insensitive `stringContains` (no `str_contains` equivalent). 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 @@ -31,27 +25,6 @@ exist for each so the intent and blockers are discoverable in code. it — a guarded call, one deeper in the body, and a non-literal message no attribute argument can hold. A call opening `setUp()` also stays a throw: hoisting it to a class-level `#[Skip]` would strip the rest of the hook. -- **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)`, `create{Mock,Stub}ForIntersectionOfInterfaces([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')`, `withAnyParameters()` drops away, 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()/onConsecutiveCalls()/returnArgument()/returnSelf())` 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`, `isNull`/`isTrue`/`isFalse`→literals; - `greaterThan`/`lessThan`/`…OrEqual`, `isEmpty`, `stringContains`, `stringStartsWith`/`EndsWith`, - `arrayHasKey`→`satisfies(fn ($value) => …)`; `logicalNot`→`Argument::not(...)`, - `logicalOr`→`Argument::any(...)`). A chain carrying an unmappable link — including a `with()` - constraint with no faithful 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/bridge/rector/src/Set/TestoRectorSetList.php b/bridge/rector/src/Set/TestoRectorSetList.php index d928260f..4ded79d2 100644 --- a/bridge/rector/src/Set/TestoRectorSetList.php +++ b/bridge/rector/src/Set/TestoRectorSetList.php @@ -44,4 +44,25 @@ final class TestoRectorSetList * @var string */ public const PEST_TO_TESTO = __DIR__ . '/../../config/pest-to-testo.php'; + + /** + * PHPUnit mocks -> Double. See {@see config/phpunit-to-double.php}. + * + * @var string + */ + public const PHPUNIT_TO_DOUBLE = __DIR__ . '/../../config/phpunit-to-double.php'; + + /** + * PHPUnit mocks -> Mockery. See {@see config/phpunit-to-mockery.php}. + * + * @var string + */ + public const PHPUNIT_TO_MOCKERY = __DIR__ . '/../../config/phpunit-to-mockery.php'; + + /** + * Mockery -> Double. See {@see config/mockery-to-double.php}. + * + * @var string + */ + public const MOCKERY_TO_DOUBLE = __DIR__ . '/../../config/mockery-to-double.php'; } diff --git a/bridge/rector/tests/Unit/PhpunitConstraintTest.php b/bridge/rector/tests/Unit/PhpunitConstraintTest.php new file mode 100644 index 00000000..564afd66 --- /dev/null +++ b/bridge/rector/tests/Unit/PhpunitConstraintTest.php @@ -0,0 +1,204 @@ +predicate(self::parse($constraint)); + Assert::notNull($predicate); + + $closure = eval('return ' . (new Standard())->prettyPrintExpr($builder->closure($predicate)) . ';'); + + Assert::same((bool) $closure($value), $expected); + } + + #[DataSet(['self::stringContains("x", $ignoreCase)'], 'computed case flag')] + #[DataSet(['self::stringContains("x", false, true)'], 'line-ending flag')] + #[DataSet(['self::isType("unknown")'], 'unknown type name')] + #[DataSet(['self::isType($type)'], 'computed type name')] + #[DataSet(['self::containsOnly($type)'], 'computed item type')] + #[DataSet(['self::objectEquals($expected, $method)'], 'computed comparison method')] + #[DataSet(['self::equalTo(value: 5)'], 'named argument')] + #[DataSet(['self::matches("%s")'], 'format description')] + #[DataSet(['self::logicalOr(self::isType($type), 1)'], 'unmappable operand')] + public function constraintWithoutFaithfulFormHasNoPredicate(string $constraint): void + { + Assert::null((new PhpunitConstraint('value'))->predicate(self::parse($constraint))); + } + + public static function verdicts(): iterable + { + yield 'equalTo(5) on 5' => ['self::equalTo(5)', 5, true]; + yield 'equalTo(5) on \'5\'' => ['self::equalTo(5)', '5', true]; + yield 'equalTo(5) on 9' => ['self::equalTo(5)', 9, false]; + yield 'equalTo(5) on true' => ['self::equalTo(5)', true, true]; + yield 'equalTo(5) on null' => ['self::equalTo(5)', null, false]; + yield 'identicalTo(5) on 5' => ['self::identicalTo(5)', 5, true]; + yield 'identicalTo(5) on \'5\'' => ['self::identicalTo(5)', '5', false]; + yield 'identicalTo(5) on 5.05' => ['self::identicalTo(5)', 5.05, false]; + yield 'isNull() on null' => ['self::isNull()', null, true]; + yield 'isNull() on false' => ['self::isNull()', false, false]; + yield 'isNull() on \'\'' => ['self::isNull()', '', false]; + yield 'greaterThan(5) on 9' => ['self::greaterThan(5)', 9, true]; + yield 'greaterThan(5) on 5' => ['self::greaterThan(5)', 5, false]; + yield 'greaterThan(5) on \'9\'' => ['self::greaterThan(5)', '9', true]; + yield 'greaterThan(5) on null' => ['self::greaterThan(5)', null, false]; + yield 'lessThanOrEqual(5) on 5' => ['self::lessThanOrEqual(5)', 5, true]; + yield 'lessThanOrEqual(5) on 9' => ['self::lessThanOrEqual(5)', 9, false]; + yield 'lessThanOrEqual(5) on 5.05' => ['self::lessThanOrEqual(5)', 5.05, false]; + yield 'equalToWithDelta(5.0, 0.1) on 5' => ['self::equalToWithDelta(5.0, 0.1)', 5, true]; + yield 'equalToWithDelta(5.0, 0.1) on 5.05' => ['self::equalToWithDelta(5.0, 0.1)', 5.05, true]; + yield 'equalToWithDelta(5.0, 0.1) on 9' => ['self::equalToWithDelta(5.0, 0.1)', 9, false]; + yield 'equalToWithDelta(5.0, 0.1) on true' => ['self::equalToWithDelta(5.0, 0.1)', true, true]; + yield 'equalToWithDelta(5.0, 0.1) on \'abc\'' => ['self::equalToWithDelta(5.0, 0.1)', 'abc', false]; + yield 'equalToWithDelta(5.0, 0.1) on []' => ['self::equalToWithDelta(5.0, 0.1)', [], false]; + yield 'equalToIgnoringCase(\'abc\') on \'ABC\'' => ['self::equalToIgnoringCase(\'abc\')', 'ABC', true]; + yield 'equalToIgnoringCase(\'abc\') on \'axz\'' => ['self::equalToIgnoringCase(\'abc\')', 'axz', false]; + yield 'equalToIgnoringCase(\'abc\') on true' => ['self::equalToIgnoringCase(\'abc\')', true, true]; + yield 'equalToIgnoringCase(\'abc\') on null' => ['self::equalToIgnoringCase(\'abc\')', null, false]; + yield 'equalToIgnoringCase(\'abc\') on []' => ['self::equalToIgnoringCase(\'abc\')', [], false]; + yield 'equalToCanonicalizing([1, 2]) on [2, 1]' => ['self::equalToCanonicalizing([1, 2])', [2, 1], true]; + yield 'equalToCanonicalizing([1, 2]) on [1, 2]' => ['self::equalToCanonicalizing([1, 2])', [1, 2], true]; + yield 'equalToCanonicalizing([1, 2]) on [\'s\', \'t\']' => ['self::equalToCanonicalizing([1, 2])', ['s', 't'], false]; + yield 'equalToCanonicalizing([1, 2]) on true' => ['self::equalToCanonicalizing([1, 2])', true, false]; + yield 'equalToCanonicalizing([1, 2]) on 5' => ['self::equalToCanonicalizing([1, 2])', 5, false]; + yield 'isEmpty() on []' => ['self::isEmpty()', [], true]; + yield 'isEmpty() on \'\'' => ['self::isEmpty()', '', true]; + yield 'isEmpty() on \'0\'' => ['self::isEmpty()', '0', true]; + yield 'isEmpty() on new \\ArrayObject([])' => ['self::isEmpty()', new \ArrayObject([]), true]; + yield 'isEmpty() on new \\ArrayObject([\'k\' => 1])' => ['self::isEmpty()', new \ArrayObject(['k' => 1]), false]; + yield 'isEmpty() on [1, 2]' => ['self::isEmpty()', [1, 2], false]; + yield 'isList() on [1, 2]' => ['self::isList()', [1, 2], true]; + yield 'isList() on [\'k\' => 1]' => ['self::isList()', ['k' => 1], false]; + yield 'isList() on \'abc\'' => ['self::isList()', 'abc', false]; + yield 'isJson() on \'{"a":1}\'' => ['self::isJson()', '{"a":1}', true]; + yield 'isJson() on \'null\'' => ['self::isJson()', 'null', true]; + yield 'isJson() on \'{bad\'' => ['self::isJson()', '{bad', false]; + yield 'isJson() on \'\'' => ['self::isJson()', '', false]; + yield 'isJson() on 5' => ['self::isJson()', 5, false]; + yield 'countOf(2) on [1, 2]' => ['self::countOf(2)', [1, 2], true]; + yield 'countOf(2) on []' => ['self::countOf(2)', [], false]; + yield 'countOf(2) on \'ab\'' => ['self::countOf(2)', 'ab', false]; + yield 'countOf(2) on new \\ArrayObject([\'k\' => 1])' => ['self::countOf(2)', new \ArrayObject(['k' => 1]), false]; + yield 'stringContains(\'x\') on \'axz\'' => ['self::stringContains(\'x\')', 'axz', true]; + yield 'stringContains(\'x\') on \'abc\'' => ['self::stringContains(\'x\')', 'abc', false]; + yield 'stringContains(\'x\') on []' => ['self::stringContains(\'x\')', [], false]; + yield 'stringContains(\'x\') on 5' => ['self::stringContains(\'x\')', 5, false]; + yield 'stringContains(\'X\', true) on \'axz\'' => ['self::stringContains(\'X\', true)', 'axz', true]; + yield 'stringContains(\'X\', true) on \'abc\'' => ['self::stringContains(\'X\', true)', 'abc', false]; + yield 'stringContains(\'X\', true) on null' => ['self::stringContains(\'X\', true)', null, false]; + yield 'stringContains(\'\') on \'abc\'' => ['self::stringContains(\'\')', 'abc', true]; + yield 'stringContains(\'\') on 5' => ['self::stringContains(\'\')', 5, true]; + yield 'stringContains(\'\') on []' => ['self::stringContains(\'\')', [], true]; + yield 'stringStartsWith(\'a\') on \'abc\'' => ['self::stringStartsWith(\'a\')', 'abc', true]; + yield 'stringStartsWith(\'a\') on \'ABC\'' => ['self::stringStartsWith(\'a\')', 'ABC', false]; + yield 'stringStartsWith(\'a\') on []' => ['self::stringStartsWith(\'a\')', [], false]; + yield 'stringEndsWith(\'z\') on \'axz\'' => ['self::stringEndsWith(\'z\')', 'axz', true]; + yield 'stringEndsWith(\'z\') on \'abc\'' => ['self::stringEndsWith(\'z\')', 'abc', false]; + yield 'stringEndsWith(\'z\') on null' => ['self::stringEndsWith(\'z\')', null, false]; + yield 'matchesRegularExpression(\'/^a/\') on \'abc\'' => ['self::matchesRegularExpression(\'/^a/\')', 'abc', true]; + yield 'matchesRegularExpression(\'/^a/\') on \'ABC\'' => ['self::matchesRegularExpression(\'/^a/\')', 'ABC', false]; + yield 'matchesRegularExpression(\'/^a/\') on [1, 2]' => ['self::matchesRegularExpression(\'/^a/\')', [1, 2], false]; + yield 'arrayHasKey(\'k\') on [\'k\' => 1]' => ['self::arrayHasKey(\'k\')', ['k' => 1], true]; + yield 'arrayHasKey(\'k\') on [1, 2]' => ['self::arrayHasKey(\'k\')', [1, 2], false]; + yield 'arrayHasKey(\'k\') on new \\ArrayObject([\'k\' => 1])' => ['self::arrayHasKey(\'k\')', new \ArrayObject(['k' => 1]), true]; + yield 'arrayHasKey(\'k\') on new \\ArrayObject([])' => ['self::arrayHasKey(\'k\')', new \ArrayObject([]), false]; + yield 'arrayHasKey(\'k\') on \'k\'' => ['self::arrayHasKey(\'k\')', 'k', false]; + yield 'containsEqual(\'1\') on [1, 2]' => ['self::containsEqual(\'1\')', [1, 2], true]; + yield 'containsEqual(\'1\') on [\'s\', \'t\']' => ['self::containsEqual(\'1\')', ['s', 't'], false]; + yield 'containsEqual(\'1\') on \'1\'' => ['self::containsEqual(\'1\')', '1', false]; + yield 'containsIdentical(1) on [1, 2]' => ['self::containsIdentical(1)', [1, 2], true]; + yield 'containsIdentical(1) on [\'1\']' => ['self::containsIdentical(1)', ['1'], false]; + yield 'containsIdentical(1) on new \\ArrayObject([1])' => ['self::containsIdentical(1)', new \ArrayObject([1]), true]; + yield 'containsOnlyString() on [\'s\', \'t\']' => ['self::containsOnlyString()', ['s', 't'], true]; + yield 'containsOnlyString() on [1, 2]' => ['self::containsOnlyString()', [1, 2], false]; + yield 'containsOnlyString() on []' => ['self::containsOnlyString()', [], true]; + yield 'containsOnlyString() on \'s\'' => ['self::containsOnlyString()', 's', false]; + yield 'containsOnlyInstancesOf(\\ArrayObject::class) on [new \\ArrayObject()]' => ['self::containsOnlyInstancesOf(\\ArrayObject::class)', [new \ArrayObject()], true]; + yield 'containsOnlyInstancesOf(\\ArrayObject::class) on [new \\stdClass()]' => ['self::containsOnlyInstancesOf(\\ArrayObject::class)', [new \stdClass()], false]; + yield 'containsOnlyInstancesOf(\\ArrayObject::class) on []' => ['self::containsOnlyInstancesOf(\\ArrayObject::class)', [], true]; + yield 'isInstanceOf(\\ArrayObject::class) on new \\ArrayObject([])' => ['self::isInstanceOf(\\ArrayObject::class)', new \ArrayObject([]), true]; + yield 'isInstanceOf(\\ArrayObject::class) on new \\stdClass()' => ['self::isInstanceOf(\\ArrayObject::class)', new \stdClass(), false]; + yield 'isInstanceOf(\\ArrayObject::class) on \'ArrayObject\'' => ['self::isInstanceOf(\\ArrayObject::class)', 'ArrayObject', false]; + yield 'isInt() on 5' => ['self::isInt()', 5, true]; + yield 'isInt() on \'5\'' => ['self::isInt()', '5', false]; + yield 'isInt() on 5.05' => ['self::isInt()', 5.05, false]; + yield 'isNumeric() on \'5\'' => ['self::isNumeric()', '5', true]; + yield 'isNumeric() on 5.05' => ['self::isNumeric()', 5.05, true]; + yield 'isNumeric() on \'abc\'' => ['self::isNumeric()', 'abc', false]; + yield 'isScalar() on \'abc\'' => ['self::isScalar()', 'abc', true]; + yield 'isScalar() on null' => ['self::isScalar()', null, false]; + yield 'isScalar() on []' => ['self::isScalar()', [], false]; + yield 'isIterable() on []' => ['self::isIterable()', [], true]; + yield 'isIterable() on new \\ArrayObject([])' => ['self::isIterable()', new \ArrayObject([]), true]; + yield 'isIterable() on \'abc\'' => ['self::isIterable()', 'abc', false]; + yield 'isNan() on NAN' => ['self::isNan()', NAN, true]; + yield 'isNan() on 5' => ['self::isNan()', 5, false]; + yield 'isNan() on \'abc\'' => ['self::isNan()', 'abc', false]; + yield 'isNan() on []' => ['self::isNan()', [], false]; + yield 'isFinite() on 5' => ['self::isFinite()', 5, true]; + yield 'isFinite() on INF' => ['self::isFinite()', INF, false]; + yield 'isFinite() on \'5\'' => ['self::isFinite()', '5', false]; + yield 'isFinite() on null' => ['self::isFinite()', null, false]; + yield 'isInfinite() on INF' => ['self::isInfinite()', INF, true]; + yield 'isInfinite() on 5' => ['self::isInfinite()', 5, false]; + yield 'isInfinite() on \'abc\'' => ['self::isInfinite()', 'abc', false]; + yield 'fileExists() on __FILE__' => ['self::fileExists()', __FILE__, true]; + yield 'fileExists() on \'/nope/nope\'' => ['self::fileExists()', '/nope/nope', false]; + yield 'fileExists() on 5' => ['self::fileExists()', 5, false]; + yield 'fileExists() on []' => ['self::fileExists()', [], false]; + yield 'directoryExists() on __DIR__' => ['self::directoryExists()', __DIR__, true]; + yield 'directoryExists() on __FILE__' => ['self::directoryExists()', __FILE__, false]; + yield 'directoryExists() on null' => ['self::directoryExists()', null, false]; + yield 'isReadable() on __FILE__' => ['self::isReadable()', __FILE__, true]; + yield 'isReadable() on \'/nope/nope\'' => ['self::isReadable()', '/nope/nope', false]; + yield 'isReadable() on []' => ['self::isReadable()', [], false]; + yield 'callback(fn ($x) => $x === 1) on 1' => ['self::callback(fn ($x) => $x === 1)', 1, true]; + yield 'callback(fn ($x) => $x === 1) on \'1\'' => ['self::callback(fn ($x) => $x === 1)', '1', false]; + yield 'logicalNot(self::equalTo(5)) on 5' => ['self::logicalNot(self::equalTo(5))', 5, false]; + yield 'logicalNot(self::equalTo(5)) on 9' => ['self::logicalNot(self::equalTo(5))', 9, true]; + yield 'logicalNot(self::equalTo(5)) on \'5\'' => ['self::logicalNot(self::equalTo(5))', '5', false]; + yield 'logicalOr(self::isNull(), self::greaterThan(5)) on null' => ['self::logicalOr(self::isNull(), self::greaterThan(5))', null, true]; + yield 'logicalOr(self::isNull(), self::greaterThan(5)) on 9' => ['self::logicalOr(self::isNull(), self::greaterThan(5))', 9, true]; + yield 'logicalOr(self::isNull(), self::greaterThan(5)) on 1' => ['self::logicalOr(self::isNull(), self::greaterThan(5))', 1, false]; + yield 'logicalAnd(self::greaterThan(1), self::lessThan(9)) on 5' => ['self::logicalAnd(self::greaterThan(1), self::lessThan(9))', 5, true]; + yield 'logicalAnd(self::greaterThan(1), self::lessThan(9)) on 9' => ['self::logicalAnd(self::greaterThan(1), self::lessThan(9))', 9, false]; + yield 'logicalAnd(self::greaterThan(1), self::lessThan(9)) on 1' => ['self::logicalAnd(self::greaterThan(1), self::lessThan(9))', 1, false]; + yield 'logicalXor(self::isTrue(), self::isNull()) on true' => ['self::logicalXor(self::isTrue(), self::isNull())', true, true]; + yield 'logicalXor(self::isTrue(), self::isNull()) on null' => ['self::logicalXor(self::isTrue(), self::isNull())', null, true]; + yield 'logicalXor(self::isTrue(), self::isNull()) on false' => ['self::logicalXor(self::isTrue(), self::isNull())', false, false]; + } + + private static function parse(string $constraint): Expr + { + $statement = (new ParserFactory())->createForNewestSupportedVersion()->parse("expr; + } +} diff --git a/bridge/rector/tests/Unit/ReturnValueMapTest.php b/bridge/rector/tests/Unit/ReturnValueMapTest.php new file mode 100644 index 00000000..b2cfe3e9 --- /dev/null +++ b/bridge/rector/tests/Unit/ReturnValueMapTest.php @@ -0,0 +1,58 @@ +prettyPrintExpr($resolver) . ';'); + + Assert::same($closure(...$arguments), $expected); + } + + #[DataSet(['$args'], 'map named like the variadic parameter')] + #[DataSet(['[[$row, 1]]'], 'map reading a variable named like the row parameter')] + public function mapShadowedByTheResolverHasNoResolver(string $map): void + { + Assert::null(ReturnValueMap::resolver(self::parse($map))); + } + + private static function parse(string $code): Expr + { + $statement = (new ParserFactory())->createForNewestSupportedVersion()->parse("expr; + } +} 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 b7c448ee..579b0622 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::*`/`satisfies`, `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`, and unmappable constraints like `logicalAnd`/`equalToWithDelta`) are left for a finishing pass, so one is mandatory. +a typo. With a mock set added (Stage A2) it also converts the common `createMock`/`createStub` chains onto Double or Mockery. Forms with no faithful target (`prophesize`, `getMockForAbstractClass`, `addMethods`, `withConsecutive`, a variable invocation matcher) stay 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;"`). @@ -31,7 +31,9 @@ Scaffold a disposable config scoped to the migration paths (do **not** clobber a --path=tests/Unit # repeat --path for each in-scope dir ``` -- `--set=phpunit-to-testo` is the default; pass `--set=` only to override. +- `--set=phpunit-to-testo` is the default. `--set` repeats: when the scope uses PHPUnit mocks, list the + mock set for the library the user picked — `--set=phpunit-to-testo --set=phpunit-to-double` (Double, + PHP 8.3+) or `--set=phpunit-to-mockery` (Mockery, PHP 8.2). Without one, mocks stay PHPUnit. - Write the file at the **project root** (it uses `__DIR__`-relative paths). - The script verifies the set exists under `vendor/` before writing and prints the next commands. 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 6ca0781c..c0d04a10 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, `getMockBuilder(…)->disableOriginalConstructor()->getMock()`) + `expects`/`method`/`will*` (incl. `willReturnSelf`)/`with` constraints (incl. `greaterThan`/`stringContains`/`logicalNot`/`logicalOr` → `satisfies`/`not`/`any`) → 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 `logicalAnd`/`equalToWithDelta`), `assertThat` constraints, `expectExceptionMessageMatches` (regex) | +| assert calls (+ arg-order swap), bare `expectException`, `markTestSkipped`, `setUp`/`tearDown` → attributes, `@dataProvider`/`#[DataProvider]`, `@group`/`#[Group]`, `#[CoversClass]` → `#[Covers]`, `#[DoesNotPerformAssertions]`, with a mock set added: `createMock`/`createStub` (+ intersection, `getMockBuilder(…)->disableOriginalConstructor()->getMock()`) + `expects`/`method`/`will*`/`with` constraints → Double (`phpunit-to-double`) or Mockery (`phpunit-to-mockery`) | **remove `extends TestCase` + reconcile discovery**, the mock forms with no Double/Mockery target (`prophesize`, `getMockForAbstractClass`, `addMethods`, `withConsecutive`, a variable invocation matcher), `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')` | `#[Skip('reason')]` from **`Testo\Skip`** when the call opens the test unconditionally — the test then never enters the pipeline (no hooks, no provider, no retries). A guarded call, one deeper in the body, or a non-literal message stays runtime: `throw new \Testo\Core\Exception\SkipTest('reason')` from the test body. | | `$this->markTestIncomplete('reason')` | No "incomplete" status. Port to `throw new SkipTest('TODO: reason')`, or leave the body empty → `Status::Risky`. | | `#[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 (also `willReturnSelf` → `returns()`, `getMockBuilder(X)->disableOriginalConstructor()->getMock()` → `Double::for(X)`, and comparison/string/`logicalNot`/`logicalOr` constraints via `Argument::satisfies`/`not`/`any`); only `willReturnMap`, `prophesize`, a `getMockBuilder` step beyond `disableOriginalConstructor`, and `with()` constraints with no faithful form (`logicalAnd`, `equalToWithDelta`, case-insensitive `stringContains`) 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 `phpunit-to-double` Rector set does all this automatically — also `willReturnSelf`/`willReturnMap`, `createConfiguredMock`, partial mocks (`createPartialMock`, `onlyMethods`) as `passthru()` doubles, and every constraint without a dedicated matcher as an `Argument::satisfies()` predicate that reproduces PHPUnit's verdict; only `prophesize`, `getMockForAbstractClass`, `addMethods`, `withConsecutive` and a variable invocation matcher stay manual. Prefer Mockery (or PHP 8.2)? The `phpunit-to-mockery` set converts the same chains to `\Mockery::mock(Foo::class)->shouldIgnoreMissing()` + `shouldReceive()`; 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`. A suite already on Mockery moves to Double with the `mockery-to-double` set. **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-migrate-from-phpunit/scripts/precheck.php b/skills/testo-migrate-from-phpunit/scripts/precheck.php index 3a0aee0f..318049c1 100644 --- a/skills/testo-migrate-from-phpunit/scripts/precheck.php +++ b/skills/testo-migrate-from-phpunit/scripts/precheck.php @@ -62,7 +62,7 @@ $rectorReady = $bridgeInstalled && $rectorInstalled; // Locate the bridge's conversion sets, if present, so the scaffolder can wire them. -$setDir = $path('vendor', 'testo', 'bridge-rector', 'config', 'sets'); +$setDir = $path('vendor', 'testo', 'bridge-rector', 'config'); $sets = $bridgeInstalled && \is_dir($setDir) ? \array_map(static fn(string $f): string => \basename($f, '.php'), \glob($setDir . '/*.php') ?: []) : []; diff --git a/skills/testo-migrate-from-phpunit/scripts/scaffold-rector-config.php b/skills/testo-migrate-from-phpunit/scripts/scaffold-rector-config.php index baf887ec..2a982919 100644 --- a/skills/testo-migrate-from-phpunit/scripts/scaffold-rector-config.php +++ b/skills/testo-migrate-from-phpunit/scripts/scaffold-rector-config.php @@ -10,12 +10,13 @@ * paths and expects `__DIR__` to be the project root. * * Usage: - * php scaffold-rector-config.php --path=DIR [--path=DIR]... [--set=NAME] [--root=PATH] [--force] + * php scaffold-rector-config.php --path=DIR [--path=DIR]... [--set=NAME]... [--root=PATH] [--force] * * Config file to write, e.g. rector-testo-migration.php (kept separate from any * existing rector.php so it can be deleted after the migration). * --path=DIR Path Rector should process (repeatable). Required. Use the scope agreed in Phase 2. - * --set=NAME Conversion set basename (default: phpunit-to-testo). Must exist under the bridge. + * --set=NAME Conversion set basename (repeatable; default: phpunit-to-testo). Must exist under the + * bridge. Add a mock set next to it: --set=phpunit-to-testo --set=phpunit-to-double. * --root=PATH Project root (default: cwd) — used to locate vendor/ and to write the file. * --force Overwrite if it already exists. * @@ -25,14 +26,14 @@ $out = null; $paths = []; -$set = 'phpunit-to-testo'; +$sets = []; $root = null; $force = false; foreach (\array_slice($argv, 1) as $arg) { if (\preg_match('/^--path=(.+)$/', $arg, $m)) { $paths[] = \str_replace('\\', '/', \rtrim($m[1], "/\\")); } elseif (\preg_match('/^--set=(.+)$/', $arg, $m)) { - $set = \basename($m[1], '.php'); + $sets[] = \basename($m[1], '.php'); } elseif (\preg_match('/^--root=(.+)$/', $arg, $m)) { $root = $m[1]; } elseif ($arg === '--force') { @@ -47,7 +48,7 @@ $root = \rtrim($root ?? \getcwd(), "/\\"); if ($out === null || $paths === []) { - \fwrite(\STDERR, "Usage: php scaffold-rector-config.php --path=DIR [--path=DIR]... [--set=NAME] [--force]\n"); + \fwrite(\STDERR, "Usage: php scaffold-rector-config.php --path=DIR [--path=DIR]... [--set=NAME]... [--force]\n"); exit(1); } @@ -73,25 +74,36 @@ exit(2); } -// Set basename -> constant name: phpunit-to-testo -> PHPUNIT_TO_TESTO. -$setConst = \strtoupper(\str_replace('-', '_', $set)); -$setListConst = "{$setListClass}::{$setConst}"; -if (!\defined($setListConst)) { - $available = \array_map( - static fn(string $name): string => \strtolower(\str_replace('_', '-', $name)), - \array_keys((new \ReflectionClass($setListClass))->getConstants()), - ); - \fwrite(\STDERR, "Set not found: {$set}\nAvailable sets: " . \implode(', ', $available) . "\n"); - exit(2); -} +$sets = $sets === [] ? ['phpunit-to-testo'] : \array_values(\array_unique($sets)); +$setConsts = []; +foreach ($sets as $set) { + // Set basename -> constant name: phpunit-to-testo -> PHPUNIT_TO_TESTO. + $setConst = \strtoupper(\str_replace('-', '_', $set)); + $setListConst = "{$setListClass}::{$setConst}"; + if (!\defined($setListConst)) { + $available = \array_map( + static fn(string $name): string => \strtolower(\str_replace('_', '-', $name)), + \array_keys((new \ReflectionClass($setListClass))->getConstants()), + ); + \fwrite(\STDERR, "Set not found: {$set}\nAvailable sets: " . \implode(', ', $available) . "\n"); + exit(2); + } -// The constant is an absolute path resolved by the installed bridge; make sure it exists before -// writing a config that would fatal at runtime. -$setPath = (string) \constant($setListConst); -if (!\is_file($setPath)) { - \fwrite(\STDERR, "Set constant {$setConst} resolves to a missing file: {$setPath}\n"); - exit(2); + // The constant is an absolute path resolved by the installed bridge; make sure it exists before + // writing a config that would fatal at runtime. + $setPath = (string) \constant($setListConst); + if (!\is_file($setPath)) { + \fwrite(\STDERR, "Set constant {$setConst} resolves to a missing file: {$setPath}\n"); + exit(2); + } + + $setConsts[] = $setConst; } +$setNames = \implode(', ', $sets); +$setLines = \implode("\n", \array_map( + static fn(string $const): string => " TestoRectorSetList::{$const},", + $setConsts, +)); // Render --path values as __DIR__-relative entries (config lives at the project root). $pathLines = \implode("\n", \array_map( @@ -104,16 +116,16 @@ $config = " Testo migration (set: {$set}).\n" + . " * Rector config for the PHPUnit -> Testo migration (sets: {$setNames}).\n" . " *\n" . " * Generated by the testo-migrate-from-phpunit skill. This file is disposable: once the\n" . " * migration is verified under `vendor/bin/testo`, delete it (and the bridge from\n" . " * require-dev if you no longer need it).\n" . " *\n" - . " * The set performs the MECHANICAL conversions only (assert calls + arg-order, lifecycle\n" + . " * The sets perform the MECHANICAL conversions only (assert calls + arg-order, lifecycle\n" . " * methods -> attributes, data providers, groups, #[CoversClass] -> #[Covers], bare\n" - . " * expectException, markTestSkipped). It does NOT remove `extends TestCase`, reconcile test\n" - . " * discovery, or convert mocks -- finish those with an AI/human pass (see the skill).\n" + . " * expectException, markTestSkipped; mocks only with phpunit-to-double/-mockery listed).\n" + . " * What they leave behind is finished by an AI/human pass (see the skill).\n" . " *\n" . " * Dry-run: vendor/bin/rector process --config={$out} --dry-run\n" . " * Apply: vendor/bin/rector process --config={$out}\n" @@ -125,13 +137,13 @@ . "{$pathLines}\n" . " ])\n" . " ->withSets([\n" - . " TestoRectorSetList::{$setConst},\n" + . "{$setLines}\n" . " ]);\n"; \file_put_contents($outAbs, $config); echo "Wrote {$out}\n"; -echo " set: {$set}\n"; +echo " sets: {$setNames}\n"; echo " paths: " . \implode(', ', $paths) . "\n\n"; echo "Next:\n"; echo " vendor/bin/rector process --config={$out} --dry-run # preview\n"; diff --git a/skills/testo-test-doubles/SKILL.md b/skills/testo-test-doubles/SKILL.md index c7b87295..c9f56caa 100644 --- a/skills/testo-test-doubles/SKILL.md +++ b/skills/testo-test-doubles/SKILL.md @@ -107,12 +107,14 @@ before writing the test (steps in the reference). 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`. +- **Migrating doubles?** `testo/bridge-rector` converts them mechanically: PHPUnit `createMock()` chains + to Double (`PHPUNIT_TO_DOUBLE`) or Mockery (`PHPUNIT_TO_MOCKERY`), and Mockery to Double + (`MOCKERY_TO_DOUBLE`), all in `TestoRectorSetList`. The rest of a PHPUnit migration 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. +- `testo-migrate-from-phpunit` — Rector-assisted PHPUnit migration, mocks included.