Skip to content

Pest vs PHPUnit in 2026: What I Reach For (and When I Don’t)

Pest vs PHPUnit in 2026: What I Reach For (and When I Don’t)

Short version for the impatient: Pest is genuinely nicer to write tests in, the migration is less scary than the docs make it look, but if you’ve got a 4,000-test PHPUnit suite that already runs fine, you don’t have to rip it out. I half-migrated a Laravel app last winter and stopped on purpose. This post is what I’d tell myself before I started.

I’d been writing PHPUnit since around PHP 5.3, which is a long time to have opinions. When Pest started showing up in Laravel circles I dismissed it as syntax sugar. That was lazy. The actual difference is smaller than the marketing makes it sound and bigger than the skeptics admit.

What Pest actually is (and what it isn’t)

Pest is a testing framework built on top of PHPUnit. Not next to it, on top of it. When you run vendor/bin/pest, Pest delegates to the same PHPUnit kernel underneath, which is why you can run a mixed suite where half the files are Pest’s it() style and half are old class FooTest extends TestCase style. Both pass through the same runner.

That’s the bit nobody emphasized when I was deciding. I thought I had to choose. I didn’t.

What Pest actually adds:

  • A flat it('does the thing', function () { ... }) and test('...') API instead of class methods.
  • Higher-order test composition via ->group(), ->skip(), ->depends(), and (the killer feature) datasets via ->with().
  • A dataset() helper that lets you share datasets across test files without writing a dataProvider method.
  • An expectation API (expect($x)->toBe(1)->toBeInt()->toBeLessThan(10)) that chains better than PHPUnit’s assertSame() style.
  • First-party plugins for architecture tests, mutation tests, parallel runs, and stress testing — the arch testing plugin is the one that’s actually changed how I think about tests, more on that below.

What Pest isn’t: it isn’t faster than PHPUnit in any meaningful way (because it’s literally PHPUnit under the hood), it isn’t going to fix your test suite if your tests are slow because they hit a real database 4,000 times, and it isn’t a different paradigm. It’s a better front end on the same engine.

The same test, two ways

Here’s a small test from a Laravel app — making sure a User model’s fullName() accessor handles a missing last name. This is the PHPUnit version I had:

<?php

namespace Tests\Unit\Models;

use App\Models\User;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

class UserTest extends TestCase
{
    #[DataProvider('nameCases')]
    public function test_full_name_handles_missing_parts(
        ?string $first,
        ?string $last,
        string $expected,
    ): void {
        $u = new User(['first_name' => $first, 'last_name' => $last]);
        $this->assertSame($expected, $u->fullName());
    }

    public static function nameCases(): array
    {
        return [
            'both present' => ['Ada', 'Lovelace', 'Ada Lovelace'],
            'no last' => ['Ada', null, 'Ada'],
            'no first' => [null, 'Lovelace', 'Lovelace'],
            'neither' => [null, null, ''],
        ];
    }
}

The Pest version of the same logic:

<?php

use App\Models\User;

it('builds a full name from the parts that exist', function (
    ?string $first, ?string $last, string $expected,
) {
    $u = new User(['first_name' => $first, 'last_name' => $last]);
    expect($u->fullName())->toBe($expected);
})->with([
    'both present' => ['Ada', 'Lovelace', 'Ada Lovelace'],
    'no last'      => ['Ada', null, 'Ada'],
    'no first'     => [null, 'Lovelace', 'Lovelace'],
    'neither'      => [null, null, ''],
]);

The Pest one is shorter, but that’s not the part I care about. The part I care about is that the dataset lives next to the test, named cases come for free, and the failure output reads like it builds a full name from the parts that exist with data set "no first" instead of a class-method-name-with-underscores. When you’ve got a flaky CI log to read at 11pm, that matters.

Where Pest genuinely wins

Architecture tests. This is the one I keep coming back to. Pest ships an arch() API that lets you assert structural rules about your code: “nothing in App\Models may import anything from App\Http”, “all controllers extend the base controller”, “no dd() or dump() left in src”. You can read the arch testing docs for the full grammar. It’s saved me from at least three accidental Eloquent-from-controller leaks already this year, and a dd() shipping to production once. Here’s the rule I keep in every project:

arch('controllers do not call models directly')
    ->expect('App\Http\Controllers')
    ->not->toUse('App\Models');

arch('no debug helpers in production code')
    ->expect(['dd', 'dump', 'ray'])
    ->not->toBeUsed();

That’s two tests that catch a class of bug I used to find in code review.

Datasets. I write more parameterized tests in Pest than I ever did in PHPUnit. The friction is just lower. Sharing a dataset across files is a one-line dataset('users', fn () => User::factory()->count(5)->make()) in Pest.php and you reach it from any test with ->with('users').

Less ceremony. No class wrapping, no public function test_ prefix, no extends TestCase. The Laravel preset includes a uses(TestCase::class)->in('Feature') line in Pest.php that does the inheritance for you. For Feature tests this means a file is sometimes 8 lines instead of 25.

Mutation testing. Pest 3 added a built-in mutation testing plugin (pest --mutate). I’ve been impressed. It found two stale assertions in my suite where the test would have passed even if the production code returned the wrong value. PHPUnit has Infection for the same job, but the integration is rougher.

Where PHPUnit still wins

Tooling that assumes class-based tests. Some IDE plugins and CI reporters still index PHPUnit test classes by name. PHPStorm handles Pest fine in 2026. VS Code’s PHP test explorers are hit-and-miss. If your team’s editor setup matters, check before you migrate.

Very large existing suites. I’ve seen a 12,000-test enterprise codebase with a custom abstract TestCase hierarchy four levels deep. Migrating that to Pest is technically possible (Pest supports class-based tests) but the value is nearly zero — you’re not going to rewrite 12k tests, and a mixed suite where half is in classes is slightly worse than just staying on PHPUnit. The threshold for “worth migrating” sits somewhere around “smaller than 2,000 tests” for me, and even then only if the team wants to.

Static analysis on dataProviders. PHPStorm and PHPStan understand #[DataProvider] better than they understand ->with() callbacks, in my experience. With Pest, the closure parameters sometimes lose their types when the dataset comes from a separate file. It’s a paper cut, not a blocker, but it’s real.

Custom assertion classes. If you’ve built up a library of custom assertions extending PHPUnit’s Assert class, those still work in Pest, but expectations (expect()->toX()) are a different system. You either re-export the assertions as expectation extensions, or accept that two styles coexist in your suite. I went with the second.

If testing tradeoffs more broadly are on your mind, I covered the JS-side equivalent (Vitest vs Jest) in my Vitest vs Jest 2026 post — the structural arguments are similar, even though the ecosystems aren’t.

How I migrate without rewriting everything

My actual playbook, which has worked twice:

  1. composer require pestphp/pest --dev --with-all-dependencies and php artisan pest:install (the official installer for Laravel).
  2. Run vendor/bin/pest once. It picks up your existing PHPUnit tests because Pest is PHPUnit. Confirm the suite still passes before you change anything.
  3. Pick one feature test file to convert. Not a unit test, not a critical integration test. Convert it by hand (skip the auto-converters; they’re fine but you learn more by doing one yourself). Commit.
  4. Add the arch() tests for your two or three biggest architectural rules. These are pure value, no migration cost.
  5. Add a CI check: pest --parallel for speed, pest --coverage --min=NN for the threshold you already had.
  6. Stop. Live with a mixed suite for a quarter. Convert files only when you’re already editing them for another reason. New tests get written in Pest by default.

If after a quarter you’re still happy with the mix, you can stop converting and call it done. That’s where I am with the project I migrated last winter. About 35% Pest, 65% PHPUnit, both run from the same vendor/bin/pest command, and nobody on the team minds.

The Laravel docs page on testing covers the framework-specific helpers that work identically in either style, which is most of what you actually use day-to-day. PHPUnit’s own docs are still the reference for the underlying assertion library and CLI flags, since Pest passes those through.

What I’d tell past me

The migration question is the wrong question. The right question is: do I want my new tests to read like Pest tests? If yes, install it and stop migrating. The mixed suite is fine. The benefit you actually get from Pest is from the tests you haven’t written yet, not from the ones you already have.

If you want to see how I handle the surrounding PHP code these tests cover, my PHP 8.4 property hooks post covers a related shift in how I write models — and I write tests for that style differently in Pest than I would have in PHPUnit because expectation chaining works better against rich property objects.

One thing to do this week

If you’re on Laravel 11 or 12 and you haven’t tried Pest, run php artisan pest:install on a side branch, copy one of your existing Feature tests into a new *.php file using the it() style, and commit two arch() rules — one to forbid dd() in src and one to enforce a layering rule that matters in your codebase. That’s twenty minutes of work, and it’s enough to know whether the rest of the migration is worth it for your team. If you’d rather not run the experiment alone, the work I do is mostly Laravel and adjacent — happy to compare notes.