From PHPUnit to Pest 5: TIA Made the Migration a No-Brainer
Draft Disclaimer: Please note that this article is currently in draft form and may undergo revisions before final publication. The content, including information, opinions, and recommendations, is subject to change and may not represent the final version. We appreciate your understanding and patience as we work to refine and improve the quality of this article. Your feedback is valuable in shaping the final release.
Language Mismatch Disclaimer: Please be aware that the language of this article may not match the language settings of your browser or device.
Do you want to read articles in English instead ?
TL;DR
| Configuration | Wall clock | Runner-reported |
|---|---|---|
| PHPUnit 11, incumbent, 1,010-test Unit suite, 15 parallel processes | 37.1 to 38.8s | - |
| Pest 5.0.2, same tests unconverted, coexistence mode | 42.2 to 43.7s | - |
| Pest 5, suite converted to Pest syntax | 38.5 to 39.7s | 32.8 to 34.6s |
| TIA cold run, one-time graph build, PCOV | 62.2s | 47.8s |
| TIA warm rerun, nothing changed | about 13s | 1.7s, 1,014 of 1,014 replayed |
| TIA warm rerun, one source file changed | about 17s | 3.3s, 10 tests re-run, 1,004 replayed |
The raw runner is basically speed-neutral, slightly slower if anything. The benefit came from the TIA engine, Test Impact Analysis. That is the feature that turned this from a syntax preference into a throughput decision.
Those first numbers were from the Unit suite. After the full migration landed, the more important measurement was not "Pest versus PHPUnit" anymore. It was "fresh agent worktree with no TIA graph" versus "fresh agent worktree hydrated from a repo baseline."
| Full-suite agent gate | Wall clock | What happened |
|---|---|---|
| Plain full run before repo hydration | about 990s | Everything runs live |
| Cold TIA graph build in a fresh worktree | about 989s | First TIA run records the graph |
Repo-hydrated make test |
about 8s | Graph is copied from the repo, then TIA selects affected tests |
Repo-hydrated make preflight |
about 12s | Baseline verify, lint, analyze, warm TIA, and direct edge-exempt tests |
That is the practical gain of the committed baseline method: the first useful verification in a new worktree no longer pays the cold graph cost. The tracked artifact is small, about 167 KB compressed and 1.43 MB raw, but it saves roughly 981 seconds the first time an agent lane asks "is this safe?"
I Was Already Sold on Pest. My Codebase Wasn't.
I liked Pest long before I adopted it. I even had a proof-of-concept branch back in the Pest 4 era. Coexistence mode works. You can install Pest, leave your PHPUnit-style classes in place, and convert gradually.
The problem was cost versus benefit. A nicer syntax alone does not justify touching roughly 2,000 tests in a Laravel monorepo with 23 internal packages and two databases under test. I liked the ergonomics. I did not have the arithmetic.
Pest 5 changed two things at once.
- It forced the prerequisite upgrade path into the open. Pest 5 requires PHP 8.4, PHPUnit 13, and
symfony/process ^8.1. In Laravel terms, that means Laravel 13 is the first realistic landing zone. I learned this the empirical way: I first tried the install on a stale worktree still carrying Laravel 11, and Composer refused flat. If you are behind on framework majors, that is your real migration. Pest 5 is the reward at the end of it. - It shipped a reason that survives cost-benefit scrutiny. Test Impact Analysis. A syntax upgrade does not pay for a 1,600-file conversion. A verification loop that drops from roughly 38 seconds to a replay-heavy rerun does.
What TIA Actually Does
The first instrumented run, with PCOV or Xdebug, records which files every test touches and builds a dependency graph. After that:
- Nothing changed: every result replays from cache. On my 1,010-test suite that meant 1.7 seconds runner time.
- One file changed: only the tests that actually execute that file re-run. In my probe, editing one enum re-ran exactly the two test files covering it, for 3.3 seconds runner time.
- Change detection is based on normalized hashing, not just git status. Comment-only edits, a formatter pass, or a docblock tweak do not trigger reruns.
- Replayed results carry the exact line and branch coverage they recorded, so a replayed run reports the same coverage as a full one.
- Safety valves exist.
.envfiles, docker-compose, fixtures, lockfiles, and config TIA cannot reason about force a full rerun.--tia --freshrebuilds the graph after big refactors.
I tried to break the selection logic before trusting it. A deliberately broken method surfaced as a real failure. An unrelated change replayed everything. A formatting-only edit triggered zero reruns. It held.
One sharp edge matters a lot: TIA refuses to run if any test in scope is still a PHPUnit class. Coexistence mode gets you onto the Pest runner, but TIA is all-or-nothing for the executed scope. That is what turns "gradual adoption" into "actual migration."
This Matters Beyond Pest Syntax
One reason I did not want to frame this as a pure Pest story is that the underlying win is bigger than Pest's syntax.
Jason McCreary's phpunit-tia repo is also worth watching. It pushes TIA into PHPUnit land, not just Pest. If that effort matures, the strategic lesson here gets broader: the real breakthrough is not it() versus test*(). It is making full-suite confidence cheap enough to stay inside the inner loop.
That is why I think TIA deserves its own category in the testing conversation. Syntax is taste. Selective reruns with trustworthy replay economics change how often you verify, and that changes what kind of speed you can safely buy.
TDD Is Where It Compounds
The per-run saving looks small until you count runs.
A real TDD loop is dozens of red-green-refactor cycles an hour, and the honest cost of each cycle is suite confidence, not just the one test you are staring at. Before TIA, there is always a compromise:
- run the focused test fast, but be blind to collateral damage, or
- run the suite, keep confidence high, and pay the waiting cost every time.
TIA removes most of that compromise. Every iteration starts to feel like a full-suite run at focused-test price, because most of the suite replays.
I still want one more week of live feature work before I put a single before-versus-after TDD anecdote in print, but the shape is already obvious. The benefit is not the cold benchmark. The benefit is what happens on run twelve, not run one.
AI Agents Make the Math Even Better
Most of my implementation work runs through coding agents. Claude Code subagents, parallel worktrees, review loops, repeated verification. Agents multiply the number of test runs far beyond what a single human does.
That matters because every implementer verifies, every reviewer re-verifies, and every fix iteration runs again. On a shared dev machine, the bottleneck stops being the tests themselves and becomes the combined load of all the lanes waiting to prove they are safe.
TIA changes the economics twice over:
- Each agent's verification drops from minutes to seconds, so a fix-review cycle that used to burn several suite runs now costs much less test time.
- The warm baseline has to travel with the repo, not just sit in one local cache. In my real worktree and cloud-agent setup, a new checkout could still pay the cold graph build. The fix was to promote a clean
maingraph intotests/.pest/tia-baseline/, then hydrate it into Pest's runtime cache when a lane starts. - The dependency graph becomes a review signal too. If a behavior-changing diff affects zero tests, that is a missing-coverage red flag before a human review even starts.
There is a trust boundary here. I do not just copy any graph into the cache. The repo baseline is verified before it is used: checksum, compressed and raw size caps, source SHA ancestry, lockfile and config hashes, secret and local-path scans, and a check that newly added *Test.php files are not silently absent from the graph. A small list of tests Pest does not record as graph edges is explicit in the manifest, and my no-target TIA run executes those files directly after the TIA-selected pass.
That is what made the method worth shipping. Without the repo baseline, every new cloud agent or worktree could spend about 16.5 minutes doing graph archaeology before it even starts giving useful feedback. With it, the first gate is back in the same time range as a normal inner-loop check.
The Migration Itself: Drift Does 95 Percent, the Last 5 Percent Bites
Pest ships Drift, the official PHPUnit-to-Pest converter. On my 139-file Unit suite it converted everything in seconds, and about 95 percent of it was clean.
The remaining 5 percent is where the green suite gets earned.
The residue classes I hit, in ascending order of nastiness:
- Helper-name collisions. Drift hoists private class methods into top-level functions. Two files with a
cloudWatch()helper become a fatal redeclare. Helpers namedroute(),policy(), orresolve()collide with Laravel globals. - Hoisted constants.
private constends up at file top level, which is a parse error, whileself::Xreferences stay behind. - Half-converted files. A leftover class wrapper around already-converted
test()calls. - Dropped
setUp()state. Tests still reference$this->thresholds, but nobeforeEachever recreates it. - A silent API mis-mapping.
assertArrayHasKey($key, $array, $message)becomestoHaveKey($key, $message), but Pest'stoHaveKeytakes its second parameter as an expected value, not a message. The existence check silently turns into the wrong equality check. The fix istoHaveKey($key, message: $message). - The dangerous one, silently deleted tests. Files whose tests come from a trait, for example
use SharedPolicyTests;with aprotected string $policy, got converted to a bareuses(Trait::class)line. Pest'suses()does not register trait test methods. The tests do not fail. They disappear. Exit code 0. In one 27-file directory, 10 of 91 tests vanished while everything still looked green.
That last class is the one that changes your migration discipline.
The only guard that reliably catches it is boring bookkeeping: record the exact test and assertion counts per suite before converting, and refuse any batch where the numbers move. My baseline said 91 tests and 111 assertions. The converted run said 81 and 101. That delta was the entire signal.
I have since turned that into a committed census meta-test so silent test loss fails loudly forever, migration or not.
I still owe the final migration stats, total files converted, manual fixes per residue class, and the final human-plus-agent time spent. But the failure modes are already stable enough to document.
Honest Caveats
- TIA still labels itself Experimental, and some of the replay accounting is cosmetically rough. I saw a stray risky flag and replayed counts that drifted slightly from the original live run.
- A cache that presents itself as test evidence has to earn trust. My ship gates still run the full suite. TIA is the inner-loop and pre-gate tool.
- The cold instrumented run costs roughly 60 percent more than a plain run, and structural changes, lockfiles, phpunit.xml, PHP upgrades, can force a rebuild.
- A repo-hydrated graph is only safe if it is treated like a build artifact with a manifest. If the source SHA, lockfiles, config, or test census drift, hydration should fail closed and Pest should rebuild locally.
- Migration is genuinely all-or-nothing per suite if you want TIA. Budget for the residue classes above and count your tests before and after every conversion batch.
None of that invalidates the benefit. It just tells you where the trust boundary still is.
Verdict
Pest 4 was a syntax I wanted. Pest 5 is a capability I can measure.
On this codebase, the raw runner did not save me. TIA did. That is the difference between a tool I admire and a tool I reorganize my workflow around.
The migration has real edges, but they are enumerable and guardable. With the framework prerequisites already paid, the decision stopped being about taste and became arithmetic.
That is why I finally moved.