Skip to content

feat(rector): PHPUnit↔Testo typed-assert & Repeat/Retry conversions, Assert API gaps (#270) - #292

Merged
roxblnfk merged 7 commits into
1.xfrom
feature/issue-270-typed-assert-conversions
Aug 18, 2026
Merged

feat(rector): PHPUnit↔Testo typed-assert & Repeat/Retry conversions, Assert API gaps (#270)#292
roxblnfk merged 7 commits into
1.xfrom
feature/issue-270-typed-assert-conversions

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Aug 17, 2026

Copy link
Copy Markdown
Member

What was changed

Closes the #270 gap set (comparison / array-key / emptiness assertions and assertEqualsCanonicalizing) and adds the brand-new PHPUnit 13.3 #[Repeat]/#[Retry] attributes to the Rector bridge in both directions.

Assert API (unblocks the conversions): Assert::numeric() is now implemented (int / float / numeric-string, with the string normalised to a real number for the comparison matchers) instead of a throwing stub; Assert::notBlank() is added as the inverse of blank() (keeping the stance that false/0/'0' are valid data); ArrayType::sameElementsAs() is added — order-insensitive, canonicalizing comparison matching PHPUnit's assertEqualsCanonicalizing. Each ships with self-test coverage.

Rector — PHPUnit → Testo: new TypedAssertCallToTestoRector converts the assertions whose faithful Testo form is a typed head + matcher — comparisons (assertGreaterThanAssert::numeric()->greaterThan(), …), array keys (assertArrayHasKey/assertArrayNotHasKeyAssert::array()->hasKeys()/->doesNotHaveKeys()), assertEqualsCanonicalizingsameElementsAs, and assertEmpty/assertNotEmptyblank()/notBlank() only for an array subject (where the two notions of emptiness provably coincide; other subjects are left untouched). The variadic array-key matchers have no message parameter, so a trailing PHPUnit message is dropped there — documented.

Rector — Repeat/Retry (both directions): RepeatRetryToTestoRector (PHPUnit → Testo) and the previously-stubbed RepeatRetryRector (Testo → PHPUnit, method-level only) now convert #[Repeat]/#[Retry]. PHPUnit's failureThreshold (aborting count, default 1) maps to Testo's maxFailures (tolerated count, default 0) off by one; markFlaky and class/function-level targets have no PHPUnit counterpart and are dropped. TypedAssertChainRector gains the reverse sameElementsAsassertEqualsCanonicalizing.

Mutation testing (the PHPUnit mirror): several fixes so infect:phpunit:ci runs green again — it had been failing. (1) phpunit.xml dropped defects from executionOrder: since PHPUnit 13.3, defect ordering requires test-run-history recording, which Infection disables for its initial coverage run, so the runner executed zero tests and Infection aborted with No source code was executed. (2) TypedAssertChainRector now emits self::assert* where $this is unavailable (a static assertion closure was mistranslated to $this->assert* and fatally errored). (3) bin/build-phpunit.php now mirrors non-PHP support data (an empty stub dir kept by a .gitkeep, VCR cassettes, Rector *.php.inc fixtures) next to the relocated tests, so tests that read fixtures by relative path work. (4) SkipUnconvertibleTestMethodRector now skips a test still calling Testo\Expect:: after conversion (the substring matcher withMessageContaining has no PHPUnit form). Verified locally: the mirror suite is green and Infection completes its initial run and proceeds to mutation.

Docs updated: bridge/rector/FEATURE_PARITY.md, both TODO.md files, and the testo-write-tests / testo-migrate-from-phpunit skills.

See commit history for details.

Why?

A real PHPUnit → Testo migration left 18 assertion call sites unconverted (issue #270), and PHPUnit 13.3 introduced native repeat/retry attributes that now have a faithful Testo counterpart. Updating PHPUnit in tools/ to 13.3.1 surfaced the mutation-testing CI failure, whose primary cause and the conversion gaps it unmasked are fixed here.

Checklist

numeric() was a throwing stub; it now validates int, float and numeric strings (the string is normalised to a real number so the NumericTrait comparisons run against a number, not via PHP coercion). notBlank() is the inverse of blank(), keeping the same stance that false/0/'0' are valid data. ArrayType::sameElementsAs() canonicalizes both sides (recursive sort, keys discarded) and compares loosely, matching PHPUnit's assertEqualsCanonicalizing — these unblock the matching Rector conversions.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ssertions and Repeat/Retry

feat(rector): map PHPUnit Repeat/Retry attributes to Testo and back

TypedAssertCallToTestoRector is the sibling of AssertCallToTestoRector for the assertions whose faithful Testo form is a typed head + matcher rather than a flat facade call: comparisons to Assert::numeric()->greaterThan() etc., array keys to Assert::array()->hasKeys()/doesNotHaveKeys(), assertEqualsCanonicalizing to sameElementsAs. assertEmpty/assertNotEmpty map to blank()/notBlank() only for an array subject, where blank()'s and empty()'s treatment of false/0/'0' coincide; other subjects are left untouched. The array-key matchers are variadic with no message parameter, so a trailing PHPUnit message is dropped there. TypedAssertChainRector gains the reverse sameElementsAs to assertEqualsCanonicalizing.

PHPUnit 13.3 added #[Repeat]/#[Retry], so both directions convert now: RepeatRetryToTestoRector (PHPUnit to Testo) and the previously-stubbed RepeatRetryRector (Testo to PHPUnit, method-level only). PHPUnit's failureThreshold (aborting count, default 1) maps to Testo's maxFailures (tolerated count, default 0) off by one; markFlaky and class/function-level targets have no PHPUnit counterpart and are dropped.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Since PHPUnit 13.3 defect ordering requires test-run-history recording, and Infection disables that for its initial coverage run (it injects cacheResult="false"). With executionOrder="depends,defects" the runner then orders by nothing and executes zero tests, so Infection collects empty coverage and aborts with "No source code was executed" — failing infect:phpunit:ci. Ordering by depends alone keeps the mirror running the full suite on any PHPUnit 13.x.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@roxblnfk
roxblnfk requested a review from a team as a code owner August 17, 2026 19:12
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…vailable

TypedAssertChainRector always decomposed a chain into `$this->assert*` statements, but a chain inside a static method, a static closure or a data provider has no $this — the emitted calls then fatal with "using $this when not in object context". Mirror AssertCallToPhpUnitRector: emit `self::assert*` when the scope has no bound $this, `$this->assert*` otherwise. Surfaced by the PHPUnit mutation mirror, where a static assertion closure was mistranslated.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s in the PHPUnit mirror

fix(test): recreate empty stub dirs in the PHPUnit mirror

The mutation mirror only copied .php files, so a support directory read by relative path never reached tests/PhpUnit: an empty suite-location stub kept alive by a .gitkeep (EmptyRunTest), a VCR cassette dir and Rector .php.inc fixtures were all missing, so those tests errored. build-phpunit.php now mirrors any non-PHP support file under a Stub/Fixture/fixtures dir verbatim next to the relocated tests, deriving each plugin/bridge root's mirror base from a sample test file's namespace.

SkipUnconvertibleTestMethodRector now also skips a test whose body still contains a Testo\Expect:: call after conversion — the substring matcher withMessageContaining has no PHPUnit counterpart and aborts the chain, leaving a runtime expectation that would fatal with StateNotFound under PHPUnit. These surfaced once the executionOrder fix let the mirror actually run its tests.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h test method

test(rector): use a genuinely unrelated attribute in the RepeatRetryToTesto no-op fixture

PHPUnit's #[Repeat]/#[Retry] are TARGET_METHOD only, so RepeatRetryRector now pushes a class-level Testo attribute down onto every test method (mirroring how Testo applies it to each test in the class) and removes it from the class, instead of leaving it untouched. A method carrying its own attribute of the same kind keeps it — method-level overrides the class default and is not doubled. Test methods are identified as in TestClassToTestCaseRector: the #[Test]-marked methods, or under a class-level #[\Testo\Test] every public non-static void/never non-lifecycle method. The no-op fixture for the reverse direction swapped PHPUnit's #[Group] (which the full set does convert, via GroupToTestoRector) for #[Small], which has no Testo counterpart, so it actually demonstrates an unconverted attribute.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… skip only truly unconvertible Expect

SkipUnconvertibleTestMethodRector blanked a skipped method's whole body and stripped its data attributes. It now PREPENDS the markTestSkipped() and keeps the original body and attributes, so the unconvertible test stays visible and just reports as skipped — the skip throws before the kept (dead) body runs. Parameters are dropped only when no data source will feed them, so a data-driven test keeps its parameters and its dataset arity still matches (no more "dataset has more arguments than the method accepts" warnings). Lifecycle hooks are no longer emptied — they run their real body around a skipped test harmlessly now that the build mirrors the stub files a former-constructor #[Before] hook reads.

The skip trigger for exception expectations is also made precise: instead of skipping on any leftover Testo\Expect:: call (which, depending on rule order, wrongly caught chains that ExpectExceptionToPhpUnitRector converts fine), it skips only a chain carrying a modifier with no PHPUnit form — anything other than withMessage/withCode/withMessagePattern. That recovers ~24 previously over-skipped tests, which now run and add mutation coverage.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@roxblnfk
roxblnfk merged commit 5cc3e53 into 1.x Aug 18, 2026
15 of 16 checks passed
@roxblnfk
roxblnfk deleted the feature/issue-270-typed-assert-conversions branch August 18, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant