Skip to content

PHPUnit → Testo: no conversions for comparison / array-key / emptiness assertions, and no assertEqualsCanonicalizing counterpart #270

Description

@roxblnfk

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 (assertSameAssert::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 assertEmptyblank() 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: sameElementsAsassertEqualsCanonicalizing, notBlank/notEmptyassertNotEmpty.
  • 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

  • B1 — implement Assert::numeric() + AssertNumeric::validateAndCreate()
  • B2 — decide the emptiness story; add Assert::notBlank() (and empty()/notEmpty() if option (a) wins)
  • B3 — add ArrayType::sameElementsAs()
  • A — decide the $message residual for the array matchers
  • A — PHPUnit → Testo rule for the comparison group (assertGreaterThan/GreaterThanOrEqual/LessThan/LessThanOrEqual)
  • A — PHPUnit → Testo rule for assertArrayHasKey / assertArrayNotHasKey
  • A — PHPUnit → Testo rule (or documented stub) for assertEmpty / assertNotEmpty
  • A — PHPUnit → Testo rule for assertEqualsCanonicalizing / assertNotEqualsCanonicalizing
  • C — reverse rules, fixtures, FEATURE_PARITY.md, TODO.md, skills

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions