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.
These numbers are from the Unit suite only. I still owe the full-suite numbers for the Feature and package suites, and I will add them once the migration fully lands. But the Unit suite already tells the story clearly enough: Pest by itself did not justify the move. TIA did.
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 TIA cache is shared across git worktrees, keyed by the repo's remote URL, so one lane's warm baseline helps every other lane on the same repository.
- 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.
I want another week or two of logs before I quantify the pre-TIA and post-TIA fleet savings in one table. But even without the final rollup, the directional result is clear: when agents are the ones burning through verification loops, shaving the loop matters even more than it does for a single developer.
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.
- 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.