Migrating Between PHPUnit And Pest
Changing test syntax is a maintenance project, not a rewrite of application behaviour. The objective is to preserve every useful scenario, assertion, fixture, and workflow while changing how tests are expressed.
Pest runs on PHPUnit and can execute existing PHPUnit-style test classes, so a PHPUnit-to-Pest migration can be incremental. That interoperability is useful, but it does not make conversion free. Complex lifecycle hooks, custom base classes, data providers, test doubles, attributes, extensions, and CI configuration all need deliberate review.
Establish A Baseline
Start from a green branch and record evidence before editing tests:
./vendor/bin/phpunit
./vendor/bin/phpunit --coverage-text
Record the number of tests and assertions, skipped or incomplete tests, deprecations, runtime, and required CI artifacts. If the project uses a wrapper command such as composer test, keep that as the public interface unless there is a reason to change it.
Do not combine migration with production refactoring or dependency upgrades. When behaviour and syntax change together, a reviewer cannot easily tell whether a missing assertion is intentional.
Install Without Deleting PHPUnit Tests
Install and initialise a Pest version compatible with the project's PHP and PHPUnit constraints:
composer require pestphp/pest --dev --with-all-dependencies
./vendor/bin/pest --init
Review the resulting phpunit.xml and tests/Pest.php. Framework projects often need their existing base test case applied to feature tests:
<?php
pest()
->extend(Tests\TestCase::class)
->in('Feature');
Run Pest before converting anything. Existing PHPUnit tests should still be discovered and pass. This proves installation and runner configuration independently from syntax conversion.
Choose A Representative Slice
Migrate one bounded directory, not the easiest single test and not the entire repository. A useful trial includes some of the patterns the broader suite depends on, such as:
- shared setup and teardown;
- a data provider;
- an expected exception;
- a mock or framework fake;
- a custom assertion or helper;
- unit and framework-bootstrapped tests.
Avoid beginning with the most unusual legacy test. The trial should reveal common migration cost without becoming a separate rescue project.
Convert Mechanically, Then Review Semantically
Pest documents the Drift plugin for automated conversion:
composer require pestphp/pest-plugin-drift --dev
./vendor/bin/pest --drift tests/Unit/Pricing
Use a clean Git branch and inspect every changed file. Drift can perform much of the mechanical conversion, but automated output still needs review. Check that:
- every original test case still exists;
- strict assertions remain strict;
- expected exception type, message, and timing are preserved;
- data-provider rows and readable case names survive;
setUp()andtearDown()behaviour maps to appropriate hooks;- custom base-class methods remain available where needed;
- mocks retain call-count and argument expectations;
- skipped, incomplete, grouped, or process-isolated tests keep their meaning;
- attributes and coverage metadata have not silently disappeared.
A passing converted test is not sufficient if it now asserts less.
Compare One Test Before And After
A simple PHPUnit test might be:
<?php
use PHPUnit\Framework\TestCase;
final class DiscountTest extends TestCase
{
public function testRejectsANegativePercentage(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Percentage cannot be negative');
Discount::percentage(-5);
}
}
The equivalent Pest test should preserve both expectations:
<?php
it('rejects a negative percentage', function () {
expect(fn () => Discount::percentage(-5))
->toThrow(
InvalidArgumentException::class,
'Percentage cannot be negative',
);
});
Shorter output is not the goal. Behavioural equivalence is.
Preserve Commands And Reports
During the trial, document and compare:
./vendor/bin/pest tests/Unit/Pricing
./vendor/bin/pest --filter='rejects a negative percentage'
./vendor/bin/pest --coverage
Verify the exact CI command, exit codes, JUnit output, code coverage format, environment variables, and bootstrap files. Confirm test discovery and debugging in every editor the team supports.
If the public command changes, update composer.json, CI configuration, contributor documentation, and editor run configurations in the same bounded migration step.
Define Acceptance And Stop Conditions
A trial can continue when:
- the same scenarios and meaningful assertions remain;
- the complete mixed suite passes reliably;
- coverage and CI reports remain available;
- filtering and debugging work for the team;
- converted tests are easier to maintain for a demonstrated reason;
- migration time is acceptable for the remaining suite.
Stop when conversion loses metadata, breaks essential tooling, creates two confusing setup systems, or produces no benefit beyond preference. Keeping PHPUnit is a valid outcome.
Work In Reviewable Units
Convert one directory or feature per pull request. Keep each change small enough that a reviewer can compare old and new tests. Run both the converted slice and the complete suite, then compare the recorded baseline.
Do not convert tests in reverse merely to enforce uniformity if Pest-style tests are already clear and supported. A Pest-to-PHPUnit migration follows the same principles, but there is no assumption that every Pest-specific feature has a direct class-based equivalent. Architecture tests, snapshots, custom expectations, and plugin APIs may require redesign rather than mechanical translation.
The migration is complete only when the repository has a documented convention, no accidental loss of coverage, and one dependable command that runs the whole suite.
Practice
Practice: Plan A Small Pest Trial
A PHP application has 600 PHPUnit tests. The tests/Unit/Pricing directory contains 24 tests using setUp(), one data provider, expected exceptions, and a mocked tax service. CI publishes JUnit and Clover reports, while developers use PhpStorm to run individual tests.
Design a PHPUnit-to-Pest trial limited to that directory.
Your plan must:
- record a test, assertion, coverage, runtime, and report baseline;
- install Pest without deleting the existing PHPUnit tests;
- prove the complete mixed suite runs before conversion;
- show the Drift command scoped to
tests/Unit/Pricing; - identify semantic checks for hooks, provider rows, exceptions, and mock expectations;
- preserve individual-test debugging and CI artifacts;
- separate test conversion from production-code changes;
- define measurable acceptance and stop conditions;
- keep the work reviewable and reversible.
Explain why a green converted suite alone does not prove a correct migration.
Show solution
Create a dedicated branch and run the existing public test command plus coverage. Record test and assertion counts, runtime, skipped tests, failures, Clover output, and the JUnit artifact consumed by CI. Commit that clean baseline before changing dependencies.
Install and initialise a compatible Pest release:
composer require pestphp/pest --dev --with-all-dependencies
./vendor/bin/pest --init
./vendor/bin/pest
The last command must run all 600 existing PHPUnit tests before any file is converted. Configure tests/Pest.php only where the application base test case is required.
Install Drift and limit conversion to the trial directory:
composer require pestphp/pest-plugin-drift --dev
./vendor/bin/pest --drift tests/Unit/Pricing
Review the diff test by test. Map setup to the correct hook without broadening its scope, preserve every named provider row and expected value, retain exception class and message checks, and verify the tax-service mock still checks the same arguments and call count. Compare the list of scenarios and meaningful assertions with the baseline; automated conversion can produce passing tests that omit metadata or weaken an assertion.
Run the converted directory, an individual filtered test, and the complete mixed suite. Generate the same Clover and JUnit files expected by CI, and confirm PhpStorm discovers, runs, and debugs the converted tests.
Accept the trial only when scenario and assertion coverage is equivalent, all 600 tests remain deterministic, reports and editor workflow still work, and maintainers identify a concrete readability or maintenance improvement. Stop and revert the trial branch if coverage falls, essential metadata cannot be represented, CI or debugging regresses, or the estimated remaining conversion cost has no corresponding benefit.
Keep production code unchanged and submit only the bounded directory for review. That leaves the trial easy to compare, revert, or use as the standard for a later directory-by-directory migration.