7 min read

Pest PHP 3: Advanced Testing Patterns Every PHP Developer Should Know

Pest PHP 3 brings mutation testing, architecture assertions, and type coverage to PHP. Here are the advanced patterns worth adding to your workflow.

Featured image for "Pest PHP 3: Advanced Testing Patterns Every PHP Developer Should Know"

If you have been writing PHP tests for any amount of time, you have probably heard of Pest. What started as a friendlier syntax layer on top of PHPUnit has matured into something considerably more powerful. Pest 3, released in the summer of 2024, introduced features that move it well past “nice syntax” into genuinely different testing philosophy — mutation testing, architecture constraints, and type coverage metrics are now first-class citizens. If you are still using Pest the way you used PHPUnit, you are leaving the best parts on the table.

Mutation Testing: Testing Your Tests

The most transformative addition in Pest 3 is built-in mutation testing. The premise is simple and slightly unsettling: if your test suite is supposed to catch bugs, what happens when you introduce a controlled bug? Mutation testing modifies your source code in small ways — flipping > to >=, changing true to false, removing a return statement — and then runs your tests against the mutated code. If your tests still pass, the mutation “survived,” which means your tests are not actually verifying that behavior. A surviving mutation is a gap in your test coverage that code coverage percentages will never show you.

Run it with:

./vendor/bin/pest --mutate

Pest will report each mutation, whether it was killed (your tests caught it) or survived (they didn’t), and your overall mutation score. A codebase with 90% code coverage can have a mutation score of 40% — meaning most of the code the tests touch is not actually being verified.

You can scope mutation runs to specific files or paths to keep feedback loops short during development:

./vendor/bin/pest --mutate --path=src/Services/OrderService.php

Aim for a mutation score above 80% on your business logic classes. The exercise of killing surviving mutants will force you to write assertions you probably thought were unnecessary.

Architecture Testing with arch()

Architecture testing is the other marquee feature. The idea is that your codebase has implicit rules — controllers should not call repositories directly, value objects should be readonly, no class in Domain should depend on anything in Infrastructure. These rules live in your team’s collective memory and get violated when someone new joins, or when deadline pressure shortens code review. Pest’s arch() lets you encode them as tests.

test('domain layer has no framework dependencies')
    ->expect('App\Domain')
    ->not->toUse(['Illuminate\Http', 'Illuminate\Database']);

test('controllers do not call repositories directly')
    ->expect('App\Http\Controllers')
    ->not->toUse('App\Repositories');

test('value objects are readonly')
    ->expect('App\Domain\ValueObjects')
    ->toBeReadonly();

test('commands implement ShouldQueue')
    ->expect('App\Jobs')
    ->toImplement(\Illuminate\Contracts\Queue\ShouldQueue::class);

These run fast — they are static analysis over your class graph, not runtime tests — and they fail immediately when someone breaks an architectural boundary. The mental shift is significant: you stop relying on code review to catch architectural drift and let the test suite do it automatically.

Pest ships with preset architecture rules for common patterns. The arch()->preset()->php() preset, for example, enforces sensible base rules that catch common PHP anti-patterns:

arch()->preset()->php();
arch()->preset()->security();
arch()->preset()->laravel();

The Laravel preset knows about controller conventions, model relationships, service providers, and more. Run it once and you will probably discover a handful of violations you did not know existed.

Type Coverage

Pest 3 adds a --type-coverage flag that reports the percentage of your codebase covered by type declarations. This is distinct from code coverage — it measures how much of your code has explicit type information that static analysis tools like PHPStan can reason about.

./vendor/bin/pest --type-coverage --min=80

The --min flag fails the test run if coverage drops below the threshold, making it suitable for CI. Like PHPStan’s baseline approach, you can set the threshold at your current score and raise it incrementally as you add types to legacy code.

Custom Expectations: The extend() API

Pest’s expectation chain is extensible, and this is one of the most underused features. If you find yourself writing the same assertion logic repeatedly, you can define it once as a custom expectation and use it everywhere with the same fluent syntax.

// In tests/Pest.php
expect()->extend('toBeAValidEmail', function () {
    expect(filter_var($this->value, FILTER_VALIDATE_EMAIL))->not->toBeFalse();
    return $this;
});

expect()->extend('toHaveStatus', function (string $status) {
    expect($this->value->status)->toBe($status);
    return $this;
});

Now you can write tests that read as domain language:

test('order confirmation email is valid', function () {
    $order = Order::factory()->create();
    expect($order->customer_email)->toBeAValidEmail();
});

test('order is fulfilled after payment', function () {
    $order = processPayment(Order::factory()->pending()->create());
    expect($order)->toHaveStatus('fulfilled');
});

Custom expectations compose with the rest of Pest’s chain, including not->, so expect($value)->not->toBeAValidEmail() works automatically.

Datasets for Boundary Testing

Pest’s dataset feature is the right tool for boundary conditions — the cases that unit tests miss because developers write the happy path and one or two edge cases. A dataset runs the same test body against every entry in a data set, giving you coverage across a range of inputs with minimal duplication.

dataset('invalid emails', [
    'missing at sign'  => ['notanemail'],
    'missing domain'   => ['user@'],
    'missing tld'      => ['user@domain'],
    'with spaces'      => ['user @example.com'],
    'empty string'     => [''],
]);

test('email validation rejects invalid addresses', function (string $email) {
    expect(validate_email($email))->toBeFalse();
})->with('invalid emails');

Datasets can be lazy (generators) for expensive setup, shared across multiple test files by defining them in tests/Pest.php, and combined with ->with() chaining to create a Cartesian product of scenarios.

describe() Blocks for Test Organization

Large test files become hard to navigate. Pest’s describe() block groups related tests and allows you to share setup using beforeEach() scoped to the block, without affecting tests outside it.

describe('OrderService', function () {
    beforeEach(function () {
        $this->service = new OrderService(
            payments: mock(PaymentGateway::class),
            inventory: mock(InventoryService::class),
        );
    });

    describe('placing orders', function () {
        it('reserves inventory before charging', function () {
            $this->service->payments->shouldReceive('charge')->never();
            $this->service->inventory->shouldReceive('reserve')->once();
            // ...
        });

        it('rolls back inventory if payment fails', function () {
            $this->service->payments->shouldReceive('charge')
                ->andThrow(PaymentException::class);
            $this->service->inventory->shouldReceive('release')->once();
            // ...
        });
    });

    describe('refunds', function () {
        it('restores inventory on full refund', function () {
            // ...
        });
    });
});

The nesting mirrors your domain logic’s structure and keeps setup close to the tests that need it.

The todo() Helper

One small quality-of-life feature worth mentioning: todo(). Writing a test stub that you intend to complete later but want tracked:

todo('order cancellation notifies the warehouse');
todo('expired sessions redirect to login with a flash message');

Running pest --todos lists all outstanding todos. This is not a replacement for your issue tracker, but it keeps test intent visible in the codebase and prevents the “I’ll write that test later” from silently disappearing.

Putting It Together in CI

A mature Pest configuration for a Laravel project in 2026 might look like this in phpunit.xml / pest.xml:

<coverage>
    <report>
        <clover outputFile="coverage.xml"/>
    </report>
</coverage>

And a CI run that covers multiple dimensions:

# Standard test run with coverage
./vendor/bin/pest --coverage --min=80

# Architecture tests (fast, run first)
./vendor/bin/pest --filter=arch

# Type coverage gate
./vendor/bin/pest --type-coverage --min=75

# Mutation testing on changed files (scheduled, not every PR)
./vendor/bin/pest --mutate --path=src/Domain

The architecture and type coverage checks are fast enough to run on every push. Mutation testing is expensive and is better suited to a nightly run or a pre-merge check on feature branches.

Why This Matters

PHP testing culture has historically optimized for getting tests to pass, not for verifying that the tests are meaningful. Mutation testing flips this around and makes the quality of your test assertions measurable. Architecture testing replaces “we agreed to this pattern” with “the build fails if you violate it.” Together these features turn a test suite from a collection of passing checks into a genuine specification of your system’s behavior.

Pest 3 is available now. If you are on Pest 2, the upgrade path is documented at pestphp.com and is straightforward for most projects.

Resources