DEV Community

Cover image for Pest vs PHPUnit for Use-Case Tests: Which One Hurts Less
Gabriel Anhaia
Gabriel Anhaia

Posted on

Pest vs PHPUnit for Use-Case Tests: Which One Hurts Less


A team I talked to had the same fight every Monday standup: should the new service use Pest or PHPUnit. One side had Pest 3 muscle memory from a Laravel codebase. The other side had years of PHPUnit data providers and didn't want to relearn how to write a test. They kept stalling on the question, and meanwhile the use-case layer they were building had no tests at all.

Both runners are good. Both run on the same engine. Pest 3 is a layer on top of PHPUnit 11. The choice depends on what your tests actually look like. Fashion has nothing to do with it. Use-case tests with in-memory adapters are the most common shape in a hex codebase, and the two runners feel different there.

Same domain, same port, same in-memory adapter, side by side. You can read both versions and pick the one you'd rather maintain at 3 AM.

The use case under test

The example is a ConfirmOrder use case from a checkout service. The domain has an Order aggregate, an OrderRepository port, and a Clock port. The use case loads an order, marks it confirmed, and persists it back. The whole thing is ~30 lines of PHP 8.3.

<?php

declare(strict_types=1);

namespace App\Checkout\UseCase;

use App\Checkout\Domain\Order;
use App\Checkout\Port\Clock;
use App\Checkout\Port\OrderRepository;
use App\Checkout\Port\OrderNotFound;

final readonly class ConfirmOrder
{
    public function __construct(
        private OrderRepository $orders,
        private Clock $clock,
    ) {}

    public function __invoke(string $orderId): Order
    {
        $order = $this->orders->byId($orderId)
            ?? throw new OrderNotFound($orderId);

        $confirmed = $order->confirm($this->clock->now());
        $this->orders->save($confirmed);

        return $confirmed;
    }
}
Enter fullscreen mode Exit fullscreen mode

The in-memory adapter is the test fixture that makes the whole thing fast:

<?php

declare(strict_types=1);

namespace App\Tests\Support;

use App\Checkout\Domain\Order;
use App\Checkout\Port\OrderRepository;

final class InMemoryOrders implements OrderRepository
{
    /** @var array<string, Order> */
    private array $rows = [];

    public function byId(string $id): ?Order
    {
        return $this->rows[$id] ?? null;
    }

    public function save(Order $order): void
    {
        $this->rows[$order->id()] = $order;
    }

    public function seed(Order ...$orders): void
    {
        foreach ($orders as $o) {
            $this->rows[$o->id()] = $o;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

No database. No transactions. No Doctrine bootstrap. The test runs in single-digit milliseconds because nothing leaves the process. That's the payoff of writing ports in the first place. Both runners get to share it.

Side-by-side test pyramid showing in-memory adapters at the base

PHPUnit 11: attributes everywhere

PHPUnit 11 finished the migration from doc-block annotations to PHP attributes. The same use case under PHPUnit looks like this:

<?php

declare(strict_types=1);

namespace App\Tests\UseCase;

use App\Checkout\Domain\Order;
use App\Checkout\Port\OrderNotFound;
use App\Checkout\UseCase\ConfirmOrder;
use App\Tests\Support\FixedClock;
use App\Tests\Support\InMemoryOrders;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class ConfirmOrderTest extends TestCase
{
    private InMemoryOrders $orders;
    private FixedClock $clock;
    private ConfirmOrder $confirm;

    protected function setUp(): void
    {
        $this->orders = new InMemoryOrders();
        $this->clock  = new FixedClock('2026-05-18T10:00:00Z');
        $this->confirm = new ConfirmOrder(
            $this->orders,
            $this->clock,
        );
    }

    #[Test]
    #[TestDox('confirms a pending order and stamps the time')]
    public function it_confirms_pending(): void
    {
        $this->orders->seed(Order::pending('ord_1'));

        $result = ($this->confirm)('ord_1');

        self::assertTrue($result->isConfirmed());
        self::assertSame(
            '2026-05-18T10:00:00+00:00',
            $result->confirmedAt()->format(DATE_ATOM),
        );
    }

    #[Test]
    public function it_throws_when_missing(): void
    {
        $this->expectException(OrderNotFound::class);

        ($this->confirm)('ord_missing');
    }

    /** @return iterable<string, array{string}> */
    public static function badIds(): iterable
    {
        yield 'empty'      => [''];
        yield 'whitespace' => ['   '];
        yield 'too long'   => [str_repeat('a', 65)];
    }

    #[Test]
    #[DataProvider('badIds')]
    public function it_rejects_bad_ids(string $id): void
    {
        $this->expectException(\InvalidArgumentException::class);

        ($this->confirm)($id);
    }
}
Enter fullscreen mode Exit fullscreen mode

It reads exactly how PHPUnit always read: a class with named methods, a setUp, attributes where doc-blocks used to be. The attributes are a real upgrade: IDEs see them, refactors rename them, and #[TestDox] produces human-readable output in the runner.

What stands out, after wiring the same use case through both runners:

  • setUp is verbose but unambiguous. Every collaborator is a typed property on the class. You can Cmd+click into $this->orders and the IDE knows what it is.
  • Data providers are static methods in PHPUnit 11. Non-static providers were deprecated in 10 and removed. The return type hint above is what static analysis wants to see.
  • Attributes mean the test file has imports for Test, DataProvider, TestDox, Group, and friends. The top of every file gets noisy fast.
  • The class shape forces a final class FooTest extends TestCase ritual on every file. That's fine, but it's friction for tests that would otherwise be a single it() block.

Pest 3: the same use case, less ceremony

Pest 3 runs on PHPUnit 11 under the hood. Same engine, different surface. The same use case, again:

<?php

declare(strict_types=1);

use App\Checkout\Domain\Order;
use App\Checkout\Port\OrderNotFound;
use App\Checkout\UseCase\ConfirmOrder;
use App\Tests\Support\FixedClock;
use App\Tests\Support\InMemoryOrders;

beforeEach(function () {
    $this->orders = new InMemoryOrders();
    $this->clock  = new FixedClock('2026-05-18T10:00:00Z');
    $this->confirm = new ConfirmOrder(
        $this->orders,
        $this->clock,
    );
});

it('confirms a pending order and stamps the time', function () {
    $this->orders->seed(Order::pending('ord_1'));

    $result = ($this->confirm)('ord_1');

    expect($result->isConfirmed())->toBeTrue();
    expect($result->confirmedAt()->format(DATE_ATOM))
        ->toBe('2026-05-18T10:00:00+00:00');
});

it('throws when the order is missing', function () {
    expect(fn () => ($this->confirm)('ord_missing'))
        ->toThrow(OrderNotFound::class);
});

it('rejects bad ids', function (string $id) {
    expect(fn () => ($this->confirm)($id))
        ->toThrow(InvalidArgumentException::class);
})->with([
    'empty'      => [''],
    'whitespace' => ['   '],
    'too long'   => [str_repeat('a', 65)],
]);
Enter fullscreen mode Exit fullscreen mode

Same coverage. Same in-memory adapter. Fewer lines, zero extends TestCase ceremony. The dataset hangs off the test with ->with(...) instead of a separate static method.

The differences worth naming:

  • beforeEach does what setUp does, with $this bound by Pest's runtime magic. It works, but $this->orders is untyped from the perspective of static analysis. PHPStan loses the type unless you use Pest's @var hints in a Pest.php file or switch to a class-based test.
  • The expect() API is fluent and reads close to English. expect($x)->toBeTrue() vs self::assertTrue($x) is a small win on every line, and the wins stack up.
  • The dataset block inline with ->with(...) is genuinely nicer than a separate static method. Named keys still show up in the runner output.

Where Pest wins: architecture tests

Pest 3 ships pest/plugins/arch out of the box. This is the feature that makes Pest worth picking for a hex codebase, full stop. The point of hexagonal architecture is that the domain depends on nothing. Pest can enforce that as a test:

<?php

arch('domain depends on nothing infrastructural')
    ->expect('App\Checkout\Domain')
    ->not->toUse([
        'Illuminate',
        'Symfony',
        'Doctrine',
        'PDO',
        'GuzzleHttp',
        'PHPUnit',
    ]);

arch('use cases only depend on ports and domain')
    ->expect('App\Checkout\UseCase')
    ->toOnlyUse([
        'App\Checkout\Port',
        'App\Checkout\Domain',
    ]);

arch('ports are interfaces')
    ->expect('App\Checkout\Port')
    ->toBeInterfaces();

arch('value objects are readonly')
    ->expect('App\Checkout\Domain\ValueObject')
    ->toBeReadonly();
Enter fullscreen mode Exit fullscreen mode

That's four tests that enforce the entire dependency rule of a hexagonal layout. The first one pays for itself the first time it catches an import. A junior engineer slips a use Illuminate\Support\Carbon into the domain because the IDE auto-imported it. The build goes red. Without this, the rule lives in a CONTRIBUTING.md that nobody re-reads.

PHPUnit has no equivalent. You can write the same checks with deptrac or phpat as separate tools. They're good, but they're separate tools with their own config files. With Pest, arch lives next to the unit tests in the same pest invocation.

Diagram showing Pest arch tests enforcing the hexagonal dependency rule

The honest decision table

Concern PHPUnit 11 Pest 3
Engine The engine Wraps PHPUnit 11
Test shape Class + methods + attributes it() blocks + closures
Verbosity per test Higher (class, setUp, asserts) Lower (closure, expect, beforeEach)
IDE support First-class everywhere First-class in PhpStorm; mixed in VS Code
Static analysis Clean (typed properties) Needs hints for $this in closures
Data providers Static methods Inline ->with(...)
Architecture tests External (deptrac, phpat) Built-in (arch())
Mutation testing Infection (external) Mutate (built-in)
Snapshot testing External Built-in
Custom expectations Custom assertion methods expect()->extend()
Learning curve for new hire Familiar to anyone with xUnit Different shape; 30 minutes
Run on existing PHPUnit tests N/A Yes, mixed projects work

A few of those rows deserve a sentence:

Static analysis with PHPStan. PHPUnit gives PHPStan typed properties on a class. Pest gives PHPStan a closure with $this magically containing things. You can fix it. Pest documents how. But you have to remember to fix it, and a fresh contributor will not. If your team runs PHPStan at level 8 by default, PHPUnit is the lower-friction path.

Mixed projects. Pest can run alongside PHPUnit tests in the same project. This is the strongest argument for trying Pest on a brownfield codebase: you don't have to migrate anything. New tests go in the Pest shape; old tests keep running. Both run with vendor/bin/pest.

Mutation testing. Pest's built-in mutate plugin runs mutation testing in-line. PHPUnit users reach for Infection, which is excellent but is another tool with another config. If you've been meaning to add mutation testing and haven't, Pest lowers that hurdle.

When each one is the right pick

Pick PHPUnit 11 when:

  • The team is heavily Symfony, heavily into static analysis at level 8, and $this-magic in closures will cost you in PHPStan time.
  • The codebase has 1000+ existing PHPUnit tests and there is no migration appetite. Don't migrate. PHPUnit 11 with attributes is a fine destination on its own.
  • The test shape you write most is parameterized end-to-end tests with deep setUp chains and traits. The class-based shape carries that better than closures with beforeEach.
  • You're writing a library and want zero opinionated test-runner dependencies in the dev-dependencies of consumers' projects.

Pick Pest 3 when:

  • You're starting a new service or use-case layer, and the unit-tests shape will be many small it() blocks. The reduction in ceremony is real.
  • You want architecture tests on a hex/clean codebase. This is the single biggest swing. arch() is built for exactly this.
  • The team works in Laravel and the rest of the test pyramid already lives in Pest. Consistency across the test types is worth the small static-analysis tax.
  • You want snapshot tests, mutation tests, and arch tests in one runner instead of four tools.

Pick both when:

  • You inherit a PHPUnit codebase and want to write new tests in Pest. This works. The arch tests alone are a reason to add Pest to a Symfony+PHPUnit project even if you keep PHPUnit as the primary shape.

What I'd actually do

For a fresh hex codebase in PHP 8.3, I'd start with Pest 3. The arch tests pay for themselves the first time someone tries to import Eloquent into the domain. The lower ceremony per test means more tests get written, and use-case tests are the ones the team will re-read most. The PHPStan friction is real but solvable with a single Pest.php config.

For a 5-year-old codebase that's already in PHPUnit, the answer is: don't migrate. Upgrade to PHPUnit 11, switch annotations to attributes, and add Pest only if you want the arch tests. The cost of converting 1000 tests is not paid back by a slightly cleaner expect() API.

The thing both runners share is the part that matters: they let you test your use cases against in-memory adapters in milliseconds, without booting a framework. That payoff comes from the ports, not from the runner. Pick the surface that fits your team, and ship the tests.


If this was useful

The test layer is one of the last chapters of Decoupled PHP. By the time you get there, the ports and use cases are already in place and the tests almost write themselves. The book walks the full hexagonal layout in PHP 8.3, with both Laravel and Symfony as adapters, and ends with a real migration playbook for legacy services that need to get there incrementally.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)