Pest PHP Advanced Patterns: Higher-Order Tests, Custom Expectations, and Parallel Testing

Pest PHP Advanced Patterns: Higher-Order Tests, Custom Expectations, and Parallel Testing

Pest PHP goes far beyond basic test syntax. This post explores higher-order tests, custom expectations, datasets, lifecycle hooks, and parallel test execution — the patterns that make large Laravel test suites fast and maintainable.

Pest PHP has become the default testing framework for many Laravel teams, not just because of its cleaner syntax, but because of the powerful abstractions it offers. Once you move past it() and expect(), a whole ecosystem of patterns opens up that can dramatically reduce test boilerplate while increasing expressiveness.

This post covers the advanced features that experienced Pest users rely on daily.

Higher-Order Tests

Higher-order tests let you chain assertions directly on the test description without writing a closure. They shine when you're testing the same behavior across many similar objects.

// Basic higher-order test
test('guests cannot access dashboard')
    ->get('/dashboard')
    ->assertRedirect('/login');

test('authenticated users see dashboard')
    ->actingAs(User::factory()->create())
    ->get('/dashboard')
    ->assertOk()
    ->assertSee('Welcome');

This isn't just syntactic sugar — the test runner can infer structure from the chain, making output cleaner and failures more precise.

Higher-order tests really shine with ->uses() for shared state:

uses(RefreshDatabase::class)->in('Feature');

// Now every test in Feature/ gets a fresh database automatically
test('user can be created')
    ->expect(fn() => User::factory()->create())
    ->toBeInstanceOf(User::class);

You can also combine higher-order tests with beforeEach setup:

beforeEach(function () {
    $this->user = User::factory()->create();
    $this->actingAs($this->user);
});

test('profile page loads')->get('/profile')->assertOk();
test('can update name')->patch('/profile', ['name' => 'Jane'])->assertRedirect();

Custom Expectations

The expect() API is extensible. You can add your own matchers that read like natural language and can be reused across your entire test suite.

// In tests/Pest.php
expect()->extend('toBePublished', function () {
    return $this->toHaveKey('status', 'published')
                ->toHaveKey('published_at');
});

expect()->extend('toBeValidEmail', function () {
    return $this->toMatch('/^[^\s@]+@[^\s@]+\.[^\s@]+$/');
});

expect()->extend('toHaveTimestamps', function () {
    return $this->toHaveKey('created_at')
                ->toHaveKey('updated_at');
});

Usage becomes remarkably clean:

it('creates a published post', function () {
    $post = Post::factory()->published()->create();

    expect($post->toArray())
        ->toBePublished()
        ->toHaveTimestamps();
});

it('registers user with valid email', function () {
    $response = post('/register', [
        'email' => 'jane@example.com',
        'password' => 'secret123',
    ]);

    expect($response->json('user.email'))->toBeValidEmail();
});

Custom expectations can also receive arguments and access the underlying value through $this->value:

expect()->extend('toHaveCountGreaterThan', function (int $min) {
    $count = is_countable($this->value) ? count($this->value) : 0;
    
    if ($count <= $min) {
        throw new ExpectationFailedException(
            "Expected count greater than {$min}, got {$count}"
        );
    }

    return $this;
});

it('returns paginated results', function () {
    Post::factory()->count(25)->create();
    
    $response = get('/api/posts')->json('data');
    
    expect($response)->toHaveCountGreaterThan(0);
});

Datasets

Datasets eliminate the repetition of testing the same logic with multiple inputs. They're Pest's equivalent of PHPUnit data providers, but more ergonomic.

// Simple array dataset
it('validates email format', function (string $email, bool $valid) {
    $validator = validator(['email' => $email], ['email' => 'email']);
    
    expect($validator->passes())->toBe($valid);
})->with([
    ['valid@example.com', true],
    ['also.valid+tag@sub.domain.org', true],
    ['not-an-email', false],
    ['missing@tld', false],
    ['@nodomain.com', false],
]);

Named datasets make test output much more readable:

dataset('admin roles', [
    'super admin' => ['super_admin'],
    'admin'       => ['admin'],
    'moderator'   => ['moderator'],
]);

dataset('restricted roles', [
    'regular user' => ['user'],
    'guest'        => ['guest'],
]);

it('allows access to admin panel', function (string $role) {
    $user = User::factory()->withRole($role)->create();
    
    actingAs($user)
        ->get('/admin')
        ->assertOk();
})->with('admin roles');

it('blocks access to admin panel', function (string $role) {
    $user = User::factory()->withRole($role)->create();
    
    actingAs($user)
        ->get('/admin')
        ->assertForbidden();
})->with('restricted roles');

Datasets can be combined for matrix testing:

it('sends notification via all channels', function (string $channel, string $event) {
    Notification::fake();
    
    $user = User::factory()->create();
    event(new $event($user));
    
    Notification::assertSentTo($user, function ($notification) use ($channel) {
        return in_array($channel, $notification->via($user));
    });
})->with([
    ['mail', OrderShipped::class],
    ['database', OrderShipped::class],
    ['mail', PaymentReceived::class],
]);

Lifecycle Hooks

Pest provides beforeEach, afterEach, beforeAll, and afterAll hooks. Unlike PHPUnit's setUp/tearDown, these compose naturally with uses() and can be scoped to specific test files or directories.

// tests/Feature/Orders/OrderTest.php
uses(RefreshDatabase::class);

beforeAll(function () {
    // Runs once before all tests in this file
    // $this is not available here — use static state
    Cache::tags('products')->flush();
});

beforeEach(function () {
    $this->customer = User::factory()->create();
    $this->product = Product::factory()->inStock()->create();
    $this->actingAs($this->customer);
});

afterEach(function () {
    // Clean up any side effects
    Storage::fake('receipts');
});

it('places an order', function () {
    post('/orders', ['product_id' => $this->product->id])
        ->assertCreated();

    expect(Order::count())->toBe(1);
});

it('decrements stock after order', function () {
    $initialStock = $this->product->stock;
    
    post('/orders', ['product_id' => $this->product->id]);

    expect($this->product->fresh()->stock)->toBe($initialStock - 1);
});

For shared setup across an entire directory, use Pest.php in that directory:

// tests/Feature/Api/Pest.php
uses(RefreshDatabase::class, WithFaker::class)->in(__DIR__);

beforeEach(function () {
    $this->user = User::factory()->create();
    $this->token = $this->user->createToken('test')->plainTextToken;
});

function apiHeaders(): array
{
    return [
        'Authorization' => 'Bearer ' . test()->token,
        'Accept' => 'application/json',
    ];
}

Custom Helpers and Shared Functions

Pest encourages extracting reusable logic into helper functions defined in Pest.php:

// tests/Pest.php
function asAdmin(): TestCase
{
    $admin = User::factory()->admin()->create();
    return test()->actingAs($admin);
}

function asGuest(): TestCase
{
    return test();
}

function createPostWithComments(int $commentCount = 3): Post
{
    return Post::factory()
        ->has(Comment::factory()->count($commentCount))
        ->create();
}

These helpers make tests read almost like specifications:

it('admin can delete any post', function () {
    $post = createPostWithComments(5);
    
    asAdmin()
        ->delete("/posts/{$post->id}")
        ->assertNoContent();
    
    expect(Post::find($post->id))->toBeNull();
    expect(Comment::where('post_id', $post->id)->count())->toBe(0);
});

Parallel Testing

Pest's parallel testing support lets you run your test suite across multiple CPU cores, cutting execution time dramatically.

Enable it in phpunit.xml or pest.xml:

<phpunit>
    <extensions>
        <bootstrap class="Pest\Parallel\ParallelPlugin"/>
    </extensions>
</phpunit>

Run with the --parallel flag:

./vendor/bin/pest --parallel
./vendor/bin/pest --parallel --processes=8

Parallel testing requires tests to be isolated. The most common pitfall is shared state. Use these patterns to avoid flaky parallel tests:

// Use unique identifiers per test to avoid collisions
it('creates user with unique email', function () {
    $email = 'user-' . str()->random(8) . '@example.com';
    
    $user = User::create(['email' => $email, 'name' => 'Test']);
    
    expect(User::where('email', $email)->exists())->toBeTrue();
});

// Use RefreshDatabase — each process gets its own transaction
uses(RefreshDatabase::class);

// Avoid global config mutations — use Config::set() inside tests
it('handles custom cache driver', function () {
    Config::set('cache.default', 'array');
    
    cache()->put('key', 'value', 60);
    
    expect(cache('key'))->toBe('value');
});

For tests that genuinely cannot run in parallel (e.g., they write to a shared file), mark them:

it('exports report to shared location', function () {
    // This test uses a shared resource
})->skip(fn() => getenv('PARALLEL') === '1', 'Cannot run in parallel');

Architecture Testing with Pest

Pest's arch() helper lets you enforce architectural rules as tests:

arch('controllers only depend on requests and services')
    ->expect('App\Http\Controllers')
    ->not->toUse('App\Models');

arch('models do not depend on requests')
    ->expect('App\Models')
    ->not->toUse('Illuminate\Http\Request');

arch('no debug functions in production code')
    ->expect('App')
    ->not->toUse(['dd', 'dump', 'var_dump', 'print_r', 'ray']);

arch('services are final or abstract')
    ->expect('App\Services')
    ->toBeFinal();

These tests run as part of your normal suite and catch architectural drift automatically.

Putting It Together: A Complete Test File

Here's what a well-structured Pest test file looks like using all these patterns:

<?php

use App\Models\{Post, User, Comment};
use App\Notifications\PostPublished;
use Illuminate\Support\Facades\Notification;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->author = User::factory()->create();
    $this->actingAs($this->author);
});

dataset('invalid post data', [
    'missing title'   => [['body' => 'Content here']],
    'missing body'    => [['title' => 'A title']],
    'title too short' => [['title' => 'Hi', 'body' => 'Content']],
]);

it('creates a post', function () {
    $response = post('/posts', [
        'title' => 'My First Post',
        'body' => 'This is the post content.',
    ]);

    $response->assertCreated();
    expect(Post::count())->toBe(1);
});

it('rejects invalid post data', function (array $data) {
    post('/posts', $data)->assertUnprocessable();
    
    expect(Post::count())->toBe(0);
})->with('invalid post data');

it('notifies followers when post is published', function () {
    Notification::fake();
    
    $follower = User::factory()->create();
    $this->author->followers()->attach($follower);
    
    post('/posts', ['title' => 'New post', 'body' => 'Content', 'status' => 'published']);
    
    Notification::assertSentTo($follower, PostPublished::class);
});

Key Takeaways

Pest's advanced features compound on each other. Higher-order tests reduce boilerplate for simple HTTP assertions. Custom expectations create a domain-specific language for your test assertions. Datasets eliminate copy-paste for multi-input scenarios. Hooks keep setup DRY. Parallel execution keeps your suite fast as it grows.

The investment in setting these patterns up early pays off significantly — a well-structured Pest suite reads like documentation and runs in seconds rather than minutes.

Read more

Start now free