Migrate an existing PHPUnit (or Pest) test suite to Testo — via the Rector bridge, via AI-agent rewriting, or a combination. Use when the user says "migrate from PHPUnit", "port phpunit tests", "convert TestCase to Testo", or has files extending PHPUnit\Framework\TestCase that need to move to Testo's attribute-based style.
Installs into .claude/skills of the current project.
Are you the author of Testo Migrate From Phpunit?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/php-testo-testo-migrate-from-phpunit)
---
name: testo-migrate-from-phpunit
description: Migrate an existing PHPUnit (or Pest) test suite to Testo — via the Rector bridge, via AI-agent rewriting, or a combination. Use when the user says "migrate from PHPUnit", "port phpunit tests", "convert TestCase to Testo", or has files extending PHPUnit\Framework\TestCase that need to move to Testo's attribute-based style.
---
# Migrating PHPUnit → Testo
Testo is similar in spirit to PHPUnit but **not source-compatible**: assertion argument order flips,
discovery is attribute-based, and there is no base class. This skill orchestrates the migration as
**phases**, then routes to one of two detailed approach references:
- **Approach A — Rector-assisted** (`references/migrate-with-rector.md`): `testo/bridge-rector` does
the mechanical bulk deterministically; an AI/human pass finishes the structural part. *Recommended
when the bridge can be installed.*
- **Approach B — AI-agent rewrite** (`references/migrate-with-agents.md`): subagents port each file
end-to-end using the mapping. For when Rector is unavailable, the suite is small, or tests are
too unusual for the rules.
The PHPUnit → Testo construct mapping (shared by both) lives in `references/phpunit-to-testo-map.md`.
Run every command from the project root (never `cd` into `vendor/bin`). `<skillDir>` is this skill's
own directory (the folder holding `SKILL.md`).
> Pest → Testo: the bridge ships only `expect()->toX()` → `Assert::*`; the `test()`/`it()`
> functional→class restructuring is not automatable. Treat a Pest suite as Approach B (AI rewrite)
> with that one Rector set as a helper. The phases below still apply.
## Phase 1 — Restore point (BLOCKING — do this first)
**Migrating tests is a breaking change to the test suite. Your very first action is to make sure
there is a restore point, then tell the user — and WAIT for their acknowledgement before changing any
test.** A migration can leave the suite red; the user must be able to roll back cleanly.
1. Check the working tree: `git status --short`.
2. If it is **not clean**, tell the user and ask them to commit or stash first — do not migrate on top
of unrelated uncommitted work (a rollback would also discard theirs).
3. If it is clean, state the rollback plan explicitly, e.g. *"I'll work on a branch `migrate-to-testo`;
if the migration fails you can `git checkout <previous-branch>` to discard everything."* Prefer a
dedicated branch (`git checkout -b migrate-to-testo`) or, at minimum, confirm the current commit is
a safe point to reset to. Check `git rev-parse --verify migrate-to-testo` first: a branch left by an
earlier attempt sits on an old base. When it exists, ask the user whether to pick a new name, delete
the old branch, or continue on it; before continuing, compare its merge base with the current head of
the main branch and tell the user when main has moved ahead.
4. Say plainly: **the test suite may be red during and after migration; the restore point is how we
undo it if it doesn't pan out.**
If the project is **not** under version control, this is blocking: tell the user a migration without a
restore point is unsafe and ask them to initialise git (or make a backup copy) before proceeding.
Do not continue to Phase 2 until the user has acknowledged the restore point.
## Phase 2 — Choose the scope
Decide *which* tests migrate in this run. Offer the user these options (and pick a sensible default
from the layout if they're indifferent):
- **Unit tests only** — the safe, common starting point: pure, mock-light tests port cleanly. Good
first cutover.
- **A named directory / suite** — e.g. `tests/Unit/Domain`, one bounded slice.
- **The whole test suite** — everything at once. Heaviest; only for small suites or a committed cutover.
Migrate one slice at a time and keep the rest on PHPUnit until each slice is green — **don't run both
runners against the same tests in CI**. Carry the chosen scope (as directory paths) into every later
phase and into the `--scope` / `--path` flags of the scripts.
Record the baseline before touching any file: `<php> vendor/bin/phpunit --log-junit=<out>/phpunit-junit.xml`
over the same scope. Phase 5 compares the migrated run against it test by test.
## Phase 3 — Detect tooling & choose the approach
### 1. Resolve the PHP binary
Pin `<php>` to an absolute path so it's stable across the run and across subagents:
```bash
php -r "echo PHP_BINARY;" # e.g. C:\php\php.exe or /usr/bin/php
```
### 2. Run the pre-flight check
```bash
<php> <skillDir>/scripts/precheck.php --scope=<dir> # repeat --scope per in-scope dir
```
It reports whether `testo/bridge-rector` (and therefore Rector) is installed — **`testo/bridge-rector`
alone is enough, it pulls Rector in** — and surveys the test surface (files, ~tests, and the
hard-to-convert constructs per directory). **Read its output before choosing.**
It also lists what `phpunit.xml(.dist)` sets that Testo would silently lose (`<php>` ini/env/const,
`bootstrap`, `<testsuites>`, groups, extensions, source and coverage scope), each with its Testo
counterpart. Keep the list: `testo.php` is written from it (a lost `intl.default_locale` fails tests
that passed under PHPUnit), with one `SuiteConfig` per `<testsuite>` and the `<php>` settings at the
top of the file. For an alternate config such as `phpunit-without-intl.xml`, re-run with
`--phpunit-config=<file>`.
Compare the project's minimum PHP (`require.php` in its `composer.json`) with the one `testo/testo`
requires (PHP 8.2; the constraint is in `vendor/testo/testo/composer.json`). When the project supports
older versions, ask the user how to handle them: raise the project's minimum, keep a PHPUnit job for
the old versions during the transition, or knowingly accept the gap. The CI matrix follows that answer.
### 3. Present the approach options and let the user choose
Based on the pre-flight result, offer:
- **Rector-assisted (Approach A)** — *recommended when the bridge is available.* Fast, deterministic
bulk conversion + a finishing AI pass. → `references/migrate-with-rector.md`.
- **AI-agent rewrite (Approach B)** — everything ported by subagents. Use when the bridge isn't
installable, the suite is tiny, or tests are too unusual for the rules. →
`references/migrate-with-agents.md`.
- **Combine** — Rector-assisted for the mechanical-heavy directories, AI-only for the gnarly ones
(heavy mocking, custom constraints). Run the two references per slice.
State the trade-off honestly: **Rector ≠ a complete migration.** It detaches classes from `TestCase`
and marks their tests, but mocks and PHPUnit-only constructs are left over, so *every* approach ends
with an AI/human structural pass and a full-suite verification. The choice is mainly *how much of the mechanical rewriting Rector does for you* vs. the
AI doing it (where the AI must apply the argument-order flip by hand, more error-prone).
If `precheck.php` says **RECTOR PATH: NOT READY**, default to recommending the bridge install
(one `composer require --dev testo/bridge-rector`) unless the user declines — then use Approach B.
## Phase 4 — Execute the chosen approach
Follow the matching reference verbatim — each has its own stages, gates, and troubleshooting:
| Choice | Reference |
|---|---|
| Rector-assisted | `references/migrate-with-rector.md` |
| AI-agent rewrite | `references/migrate-with-agents.md` |
| Combine | both, applied per directory slice |
Both routes use the same building blocks:
- **Mapping:** `references/phpunit-to-testo-map.md` — the authoritative construct table + pitfalls.
- **Work-list script:** `scripts/scan-residuals.php` — ranks files and writes batched work-lists
(`<outDir>/migration-report.md` + `<outDir>/migration-batches/NNN.json`).
- **Porting subagent:** `references/subagent-port-prompt.md` — the fixed, verbatim template for
porting/finishing one file.
### Subagent concurrency (important — differs from sibling skills)
Migration subagents each rewrite a **different, independent test file** and run only that file
(`--path=<file>`), so a batch **may be dispatched in PARALLEL** — one subagent per file in the batch,
in a single message, then wait for all verdicts before the next batch. This is the opposite of
`testo-mutation-testing` / `testo-increase-coverage`, where subagents share source/reports and must
run strictly sequentially. Keep two things out of the parallel subagents' hands: **`testo.php`**
(configured once by you, not per file) and any **shared fake/helper** two files need (create it once,
up front). Process one batch at a time, in order; read only the current batch.
When filling the subagent template, map the batch entry + your resolved paths to its placeholders:
| Placeholder | Source |
|---|---|
| `{{FILE}}` | batch entry `path` |
| `{{NEEDS}}` | batch entry `needs[]`, rendered one item per line |
| `{{HINTS}}` | batch entry `hints{}`, rendered `key: hint` per line |
| `{{MAP}}` | absolute path to `<skillDir>/references/phpunit-to-testo-map.md` |
| `{{TEST_CMD}}` | `<php> vendor/bin/testo` — just the binary; the template adds `--json --path=` itself |
| `{{TEST_DIR}}` | the test directory the file lives under |
## Phase 5 — Verify & retire the old harness
After the chosen approach's stages complete, run the shared final pass (detailed in each reference):
1. **Full in-scope run:** `vendor/bin/testo --json --suite=<name>`. **Gate:** `status: "passed"`.
Investigate every `failures[]` — the usual culprits are a flipped comparison, a class still
`extends TestCase` (undiscovered), or a provider method mistaken for a test.
2. **Same tests:** run `<php> vendor/bin/testo --suite=<name> --log-junit=<out>/testo-junit.xml`, then
`<php> <skillDir>/scripts/compare-junit.php <out>/phpunit-junit.xml <out>/testo-junit.xml`.
**Gate:** exit 0 (`IDENTICAL`): every test method ran as many times as under PHPUnit. Matching
totals are not enough, a dropped test can hide behind an extra one. Add `--strip-test-prefix` if
methods lost their `test` prefix and `--map='Old\Ns\=New\Ns\x27` if tests moved namespace. An
undiscovered test fails nothing, it just drops out. The assertion count is not comparable between
the two runners; both JUnit reports carry it (`assertions` on each `<testcase>` and `<testsuite>`),
so a large per-test gap is a lead to inspect, never a gate.
3. **Polish (needs `testo/bridge-rector`):** run the `testo-polish` Rector set over the scope as its own
pass, per Stage A6 of `references/migrate-with-rector.md`, then repeat steps 1–2: `compare-junit.php`
must stay `IDENTICAL`.
4. **Retire the old harness — only once green:** delete the disposable Rector configs (Approach A);
when the whole project is migrated, remove `phpunit.xml`, `phpunit/phpunit` from `composer.json`,
and any `tests/bootstrap.php`. Optionally drop `testo/bridge-rector` from `require-dev`. Classes
carrying a `#[Skip]` that names a PHPUnit base from vendor come first: rewrite that base for Testo
and remove the attributes. Remove `phpunit/phpunit` only after that, since without it the vendor
base no longer loads and test discovery fails.
**CI:** add a job that runs `vendor/bin/testo` per suite (with the PHP extensions each suite
needs) next to the existing one, on the PHP versions agreed in Phase 3 step 2. When the project
calls a shared reusable workflow that runs PHPUnit, ask the user before touching the call: replace
it with a job of its own, keep it and add a Testo job next to it, or pass the Testo command to the
shared workflow if it takes one. The shared workflow itself stays as it is.
5. **Report** the before/after to the user: files migrated, tests now green under Testo, anything
left on PHPUnit (list each explicitly: `phpunit.xml*` files, `phpunit/phpunit` and PHPUnit add-ons
such as watchers in `composer.json`, composer scripts calling `phpunit`, CI jobs, an
`infection.json*` still set up for PHPUnit, bases from `vendor/` that pull PHPUnit in
transitively), and any files a subagent reported `blocked` (needs a human decision — usually
heavy mocking or a PHPUnit-only constraint).
If a slice cannot be made green, **fall back to the Phase-1 restore point** and report to the user —
do not leave a half-migrated, red suite in place.
## Related skills
- `testo-configure` — writing `testo.php` (suites, finder, plugins).
- `testo-write-tests` — `#[Test]`, `Assert`, `Expect`, lifecycle detail.
- `testo-data-driven` — choosing `#[DataSet]` / `#[DataProvider]` / `#[DataZip]` / `#[DataCross]`.
- `testo-coverage` — `#[Covers]` policy and coverage wiring.