Pest PHP: Modern Testing Framework for Laravel

· 5 min read

Pest is a elegant PHP testing framework that makes writing tests feel less like work. Built on top of PHPUnit, it offers a more expressive syntax and powerful features while maintaining compatibility with existing tests.

Installation

composer require pestphp/pest pestphp/pest-plugin-laravel --dev
php artisan pest:install

This creates a tests/ directory structure and a Pest.php configuration file.

Basic Test Structure

The simplest test:

// tests/Feature/HomeTest.php
test('homepage loads successfully', function () {
    $response = $this->get('/');
    $response->assertStatus(200);
});

Compare this to PHPUnit:

public function test_homepage_loads_successfully()
{
    $response = $this->get('/');
    $this->assertEquals(200, $response->getStatusCode());
}

Much cleaner!

Describing Tests with Groups

describe('Authentication', function () {
    test('user can register', function () {
        $response = $this->post('/register', [
            'name' => 'John',
            'email' => 'john@example.com',
            'password' => 'password123',
        ]);
        $response->assertRedirect('/dashboard');
    });

    test('user can login', function () {
        $this->post('/login', [
            'email' => 'john@example.com',
            'password' => 'password123',
        ])->assertRedirect('/dashboard');
    });
});

Creates logical groupings in your test output.

Using Datasets

Test multiple scenarios with a single test function:

test('can calculate discount', function (int $price, int $discount, int $expected) {
    $result = calculateDiscount($price, $discount);
    expect($result)->toBe($expected);
})
->with([
    [100, 10, 90],
    [200, 20, 160],
    [50, 5, 47.5],
]);

Or use named datasets:

test('validates email format', function (string $email, bool $valid) {
    expect(isValidEmail($email))->toBe($valid);
})
->with('emails');

dataset('emails', [
    'valid email' => ['john@example.com', true],
    'invalid email' => ['not-an-email', false],
    'empty email' => ['', false],
]);

Hooks and Setup

beforeEach(function () {
    // Run before each test in this file
    $this->user = User::factory()->create();
});

afterEach(function () {
    // Run after each test
    Log::flushEvents();
});

test('user can create post', function () {
    $post = $this->user->posts()->create([
        'title' => 'My First Post',
    ]);
    expect($post)->toBeInstanceOf(Post::class);
});

Higher-Order Tests

Chain assertions for readable test flows:

test('user can delete their own post')
    ->actingAs(User::factory()->create())
    ->post('/posts', [
        'title' => 'My Post',
        'body' => 'Content here',
    ])
    ->assertRedirect()
    ->delete('/posts/1')
    ->assertRedirect('/posts');

Testing Database State

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('user can create post', function () {
    $post = Post::create([
        'title' => 'Test',
        'body' => 'Content',
    ]);

    expect($post)->toBeInstanceOf(Post::class);
    $this->assertDatabaseHas('posts', [
        'title' => 'Test',
    ]);
});

API Testing

test('api returns user data', function () {
    $user = User::factory()->create();

    $this->getJson('/api/users/' . $user->id)
        ->assertOk()
        ->assertJsonPath('data.name', $user->name)
        ->assertJsonStructure([
            'data' => [
                'id',
                'name',
                'email',
            ]
        ]);
});

Testing Exceptions

use Pest\Exceptions\ToThrowException;

test('throws when user not found', function () {
    expect(function () {
        User::findOrFail(999);
    })->toThrow(ModelNotFoundException::class);
});

test('throws with specific message', function () {
    expect(function () {
        throw new Exception('Custom error');
    })->toThrow(Exception::class, 'Custom error');
});

Using Expectations

test('user email is correct', function () {
    $user = User::factory()->create(['email' => 'john@example.com']);

    expect($user->email)->toBe('john@example.com');
    expect($user->email)->toContain('@example.com');
    expect($user->email)->toMatch('/^[^@]+@[^@]+\.[^@]+$/');
});

test('collection has items', function () {
    $users = User::factory(5)->create();

    expect($users)->toHaveCount(5);
    expect($users)->each->toBeInstanceOf(User::class);
});

Mocking

use Illuminate\Support\Facades\Http;

test('fetches data from external api', function () {
    Http::fake([
        'api.example.com/users/*' => Http::response([
            'id' => 1,
            'name' => 'John',
        ], 200),
    ]);

    $response = Http::get('https://api.example.com/users/1');

    expect($response->json())
        ->toHaveKey('name', 'John');
});

Event Testing

use Illuminate\Support\Facades\Event;

test('post created event fires', function () {
    Event::fake();

    Post::factory()->create();

    Event::assertDispatched(PostCreated::class);
});

Testing Queries

test('eager loads author', function () {
    Post::factory()
        ->has(Comment::factory(5))
        ->create();

    $this->assertQueryCount(1, function () {
        $post = Post::with('comments')->first();
        $post->comments->count();
    });
});

Organizing Tests

Standard structure:

tests/
├── Feature/           # Integration tests
│   ├── HomeTest.php
│   ├── PostTest.php
├── Unit/              # Unit tests
│   ├── CalculatorTest.php
└── Pest.php          # Configuration

Running Tests

# Run all tests
php artisan test

# Run specific file
php artisan test tests/Feature/PostTest.php

# Run tests matching pattern
php artisan test --name="post"

# Run with coverage
php artisan test --coverage

# Run in parallel
php artisan test --parallel

Configuration (tests/Pest.php)

uses(
    Tests\TestCase::class,
    RefreshDatabase::class,
)->in('Feature');

uses(Tests\TestCase::class)->in('Unit');

// Global expectations
expect()->extend('toBeActive', function () {
    return $this->toBe(true);
});

Snapshot Testing

test('api response matches snapshot', function () {
    $response = $this->getJson('/api/users/1');

    expect($response->json())->toMatchSnapshot();
});

First run creates the snapshot, subsequent runs verify against it.

Best Practices

  1. One concept per test - Test one thing only
  2. Use descriptive names - Should explain what's being tested
  3. Keep tests DRY - Use datasets and hooks
  4. Test behavior, not implementation - Focus on outputs
  5. Use factories - For realistic test data
  6. Mock external services - Don't call real APIs
  7. Arrange-Act-Assert - Clear test structure
test('user can update profile', function () {
    // Arrange
    $user = User::factory()->create();

    // Act
    $this->actingAs($user)
        ->put("/users/{$user->id}", [
            'name' => 'Jane',
        ]);

    // Assert
    expect($user->fresh()->name)->toBe('Jane');
});

Summary

Pest makes testing enjoyable with its clean, expressive syntax. Key benefits:

  • Readable - Tests read like documentation
  • Concise - Less boilerplate than PHPUnit
  • Powerful - Datasets, hooks, and higher-order tests
  • Compatible - Works alongside PHPUnit
  • Developer-friendly - Encourages better testing practices

Start with Pest and never look back.

Comments