Context
A real-world PHPUnit → Testo migration through testo/bridge-rector left 18 assertion call sites unconverted:
- 17 calls with no Rector rule at all:
assertGreaterThan, assertLessThan, assertLessThanOrEqual, assertArrayHasKey, assertArrayNotHasKey, assertEmpty — all of them also need the expected/actual swap, so a naive manual rewrite is easy to get backwards;
- 1 call with no Testo counterpart at all:
assertEqualsCanonicalizing — rewritten by hand as sort() + Assert::same().
Why they are missing
AssertCallToTestoRector::MAP (AssertCallToTestoRector.php:47) only holds flat 1:1 facade calls (assertSame → Assert::same). Every assertion in the list above needs a typed head + matcher on the Testo side (Assert::array($a)->hasKeys(...)), which is a different node shape — so they were never added.
The directions are asymmetric: Testo → PHPUnit already covers exactly these in TypedAssertChainRector.php:213-228 (greaterThan, lessThanOrEqual, hasKeys, doesNotHaveKeys, notEmpty, between). Only the PHPUnit → Testo side is blank.
Part A — Rector rules (phpunit-to-testo)
| PHPUnit |
Testo |
Note |
assertGreaterThan($e, $a) |
Assert::numeric($a)->greaterThan($e) |
blocked by B1 |
assertGreaterThanOrEqual($e, $a) |
Assert::numeric($a)->greaterThanOrEqual($e) |
blocked by B1 |
assertLessThan($e, $a) |
Assert::numeric($a)->lessThan($e) |
blocked by B1 |
assertLessThanOrEqual($e, $a) |
Assert::numeric($a)->lessThanOrEqual($e) |
blocked by B1 |
assertArrayHasKey($k, $a) |
Assert::array($a)->hasKeys($k) |
ready today |
assertArrayNotHasKey($k, $a) |
Assert::array($a)->doesNotHaveKeys($k) |
ready today |
assertEmpty($a) |
Assert::blank($a) |
⚠️ not equivalent — see B2 |
assertNotEmpty($a) |
— |
no counterpart at all — see B2 |
assertEqualsCanonicalizing($e, $a) |
— |
no counterpart at all — see B3 |
Implementation notes:
- No coalescing needed. Unlike the reverse direction, this is a 1:1 statement rewrite — emit one typed chain per call. The existing
MergeAssertChainRector already folds adjacent chains sharing an identical typed head, so two consecutive assertArrayHasKey calls on the same variable end up as one chain for free.
- No subject hoisting needed either: the subject moves from an argument position to the head argument, still evaluated exactly once (the reverse direction needs a
$value local precisely because it fans 1→N).
- Where to put it: either extend
AssertCallToTestoRector::MAP with a third "typed head" shape, or add a sibling rule (e.g. TypedAssertCallToTestoRector). A sibling rule keeps the flat MAP readable and mirrors the reverse direction's separation (AssertCallToPhpUnitRector vs TypedAssertChainRector); the same "only inside a class" gate applies.
- Open residual — the
$message argument. ArrayType::hasKeys(int|string ...$keys) and doesNotHaveKeys() are variadic with no $message parameter, so PHPUnit's trailing message has nowhere to go and would be silently dropped. Options: accept the loss and document it, keep the message as a trailing comment, or add a message-carrying overload to the array matchers. Needs a decision before the array rules land.
Part B — Assert API gaps
B1. Assert::numeric() is a stub — blocker for the whole comparison group
Assert.php:395 is throw new \LogicException('Not implemented yet') (marked @deprecated To be implemented), and AssertNumeric has no validateAndCreate() unlike every sibling (AssertInt, AssertFloat, AssertArray, …). So a comparison rule has nothing safe to emit today: Assert::int() / Assert::float() do work and both expose the NumericType matchers, but picking between them requires PHPStan type inference at the call site, and assertGreaterThan(0, $n) gives no static hint.
Implementing numeric() (int | float | numeric-string, per its own docblock) unblocks the rule with zero inference.
Behaviour residual worth documenting on the rule: PHPUnit's assertGreaterThan compares anything comparable (strings, DateTime, arrays), while Assert::numeric() narrows the domain — a non-numeric subject still fails, but as a type assertion rather than a comparison, so the message changes and the pass/fail outcome does not.
B2. assertEmpty / assertNotEmpty — semantics don't line up
Assert::blank() deliberately does not treat false, 0, "0" as blank ("they represent valid data"), so converting assertEmpty → blank() blindly changes meaning. And there is currently no Assert::notBlank() at all — the only "not empty" available is Assert::iterable($v)->notEmpty(), which is iterables-only. Pick one:
- (a) add an explicit
Assert::empty() / Assert::notEmpty() pair with PHP empty() semantics — a faithful conversion, but at odds with the plugin's stance on falsy values;
- (b) (recommended) keep
blank() as the only notion, add the missing Assert::notBlank() for symmetry, and convert assertEmpty/assertNotEmpty only when the inferred scope type rules out false/0/'0'; otherwise leave the call untouched and record it as a documented stub rule, so the unconverted line stays visible instead of being silently mistranslated.
Assert::notBlank() is missing either way — worth adding independently of the Rector work.
B3. assertEqualsCanonicalizing — no counterpart
Proposal: ArrayType::sameElementsAs(iterable $expected, string $message = '') (naming echoes the existing IterableType::sameSizeAs()) — order-insensitive comparison, i.e. canonicalize both sides then compare loosely, matching PHPUnit's semantics. With that in place both Rector directions become mechanical, and the hand-written sort() + Assert::same() workaround goes away.
Part C — follow-ups once the above lands
- Reverse rules in
TypedAssertChainRector for every new matcher: sameElementsAs → assertEqualsCanonicalizing, notBlank/notEmpty → assertNotEmpty.
- Co-located
*.php.inc fixtures per case in the rule directory (incl. argument-swap and message-position cases).
- Update
bridge/rector/FEATURE_PARITY.md (the "Basic assertions" and "Fluent / typed chains" rows) and the residuals list in bridge/rector/src/PhpunitToTesto/TODO.md.
- New public
Assert methods are part of the public contract → update the matching skills (testo-write-tests, testo-migrate-from-phpunit; see skills/README.md).
Checklist
Context
A real-world PHPUnit → Testo migration through
testo/bridge-rectorleft 18 assertion call sites unconverted:assertGreaterThan,assertLessThan,assertLessThanOrEqual,assertArrayHasKey,assertArrayNotHasKey,assertEmpty— all of them also need the expected/actual swap, so a naive manual rewrite is easy to get backwards;assertEqualsCanonicalizing— rewritten by hand assort()+Assert::same().Why they are missing
AssertCallToTestoRector::MAP(AssertCallToTestoRector.php:47) only holds flat 1:1 facade calls (assertSame→Assert::same). Every assertion in the list above needs a typed head + matcher on the Testo side (Assert::array($a)->hasKeys(...)), which is a different node shape — so they were never added.The directions are asymmetric: Testo → PHPUnit already covers exactly these in
TypedAssertChainRector.php:213-228(greaterThan,lessThanOrEqual,hasKeys,doesNotHaveKeys,notEmpty,between). Only the PHPUnit → Testo side is blank.Part A — Rector rules (
phpunit-to-testo)assertGreaterThan($e, $a)Assert::numeric($a)->greaterThan($e)assertGreaterThanOrEqual($e, $a)Assert::numeric($a)->greaterThanOrEqual($e)assertLessThan($e, $a)Assert::numeric($a)->lessThan($e)assertLessThanOrEqual($e, $a)Assert::numeric($a)->lessThanOrEqual($e)assertArrayHasKey($k, $a)Assert::array($a)->hasKeys($k)assertArrayNotHasKey($k, $a)Assert::array($a)->doesNotHaveKeys($k)assertEmpty($a)Assert::blank($a)assertNotEmpty($a)assertEqualsCanonicalizing($e, $a)Implementation notes:
MergeAssertChainRectoralready folds adjacent chains sharing an identical typed head, so two consecutiveassertArrayHasKeycalls on the same variable end up as one chain for free.$valuelocal precisely because it fans 1→N).AssertCallToTestoRector::MAPwith a third "typed head" shape, or add a sibling rule (e.g.TypedAssertCallToTestoRector). A sibling rule keeps the flat MAP readable and mirrors the reverse direction's separation (AssertCallToPhpUnitRectorvsTypedAssertChainRector); the same "only inside a class" gate applies.$messageargument.ArrayType::hasKeys(int|string ...$keys)anddoesNotHaveKeys()are variadic with no$messageparameter, so PHPUnit's trailing message has nowhere to go and would be silently dropped. Options: accept the loss and document it, keep the message as a trailing comment, or add a message-carrying overload to the array matchers. Needs a decision before the array rules land.Part B —
AssertAPI gapsB1.
Assert::numeric()is a stub — blocker for the whole comparison groupAssert.php:395isthrow new \LogicException('Not implemented yet')(marked@deprecated To be implemented), andAssertNumerichas novalidateAndCreate()unlike every sibling (AssertInt,AssertFloat,AssertArray, …). So a comparison rule has nothing safe to emit today:Assert::int()/Assert::float()do work and both expose theNumericTypematchers, but picking between them requires PHPStan type inference at the call site, andassertGreaterThan(0, $n)gives no static hint.Implementing
numeric()(int | float | numeric-string, per its own docblock) unblocks the rule with zero inference.Behaviour residual worth documenting on the rule: PHPUnit's
assertGreaterThancompares anything comparable (strings,DateTime, arrays), whileAssert::numeric()narrows the domain — a non-numeric subject still fails, but as a type assertion rather than a comparison, so the message changes and the pass/fail outcome does not.B2.
assertEmpty/assertNotEmpty— semantics don't line upAssert::blank()deliberately does not treatfalse,0,"0"as blank ("they represent valid data"), so convertingassertEmpty→blank()blindly changes meaning. And there is currently noAssert::notBlank()at all — the only "not empty" available isAssert::iterable($v)->notEmpty(), which is iterables-only. Pick one:Assert::empty()/Assert::notEmpty()pair with PHPempty()semantics — a faithful conversion, but at odds with the plugin's stance on falsy values;blank()as the only notion, add the missingAssert::notBlank()for symmetry, and convertassertEmpty/assertNotEmptyonly when the inferred scope type rules outfalse/0/'0'; otherwise leave the call untouched and record it as a documented stub rule, so the unconverted line stays visible instead of being silently mistranslated.Assert::notBlank()is missing either way — worth adding independently of the Rector work.B3.
assertEqualsCanonicalizing— no counterpartProposal:
ArrayType::sameElementsAs(iterable $expected, string $message = '')(naming echoes the existingIterableType::sameSizeAs()) — order-insensitive comparison, i.e. canonicalize both sides then compare loosely, matching PHPUnit's semantics. With that in place both Rector directions become mechanical, and the hand-writtensort()+Assert::same()workaround goes away.Part C — follow-ups once the above lands
TypedAssertChainRectorfor every new matcher:sameElementsAs→assertEqualsCanonicalizing,notBlank/notEmpty→assertNotEmpty.*.php.incfixtures per case in the rule directory (incl. argument-swap and message-position cases).bridge/rector/FEATURE_PARITY.md(the "Basic assertions" and "Fluent / typed chains" rows) and the residuals list inbridge/rector/src/PhpunitToTesto/TODO.md.Assertmethods are part of the public contract → update the matching skills (testo-write-tests,testo-migrate-from-phpunit; seeskills/README.md).Checklist
Assert::numeric()+AssertNumeric::validateAndCreate()Assert::notBlank()(andempty()/notEmpty()if option (a) wins)ArrayType::sameElementsAs()$messageresidual for the array matchersassertGreaterThan/GreaterThanOrEqual/LessThan/LessThanOrEqual)assertArrayHasKey/assertArrayNotHasKeyassertEmpty/assertNotEmptyassertEqualsCanonicalizing/assertNotEqualsCanonicalizingFEATURE_PARITY.md,TODO.md, skills