Skip to content

Pest PHP

Overview

Pest is a delightful PHP testing framework with a focus on simplicity and beautiful syntax. Built on top of PHPUnit, it provides an elegant testing experience while maintaining full compatibility with the PHPUnit ecosystem.


Why Pest?

Pest provides several benefits over traditional PHPUnit:

  • Elegant syntax - More readable and expressive tests
  • Better error messages - Clear, actionable feedback
  • Zero configuration - Works out of the box
  • Expectation API - Fluent assertions with expect()
  • Higher-order tests - Test multiple scenarios easily with datasets
  • Plugins - Laravel, Livewire, Faker integrations
  • Parallel testing - Faster test execution
  • Type coverage - Check type declaration coverage

Installation

Install Pest

bash
# Install Pest
composer require pestphp/pest --dev

# Install Pest plugin for Laravel
composer require pestphp/pest-plugin-laravel --dev

# Initialize Pest (creates tests/Pest.php)
./vendor/bin/pest --init

Optional Plugins

bash
# Faker integration
composer require pestphp/pest-plugin-faker --dev

# Watch mode
composer require pestphp/pest-plugin-watch --dev

# Type coverage
composer require pestphp/pest-plugin-type-coverage --dev

Configuration

tests/Pest.php

This file configures Pest for your entire test suite:

php
<?php

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

// Use TestCase for all tests
uses(TestCase::class)->in('Feature', 'Unit');

// Use RefreshDatabase for feature tests
uses(RefreshDatabase::class)->in('Feature');

// Global test helpers
function actingAsUser(): \App\Models\User
{
    $user = \App\Models\User::factory()->create();
    test()->actingAs($user);
    return $user;
}

function actingAsAdmin(): \App\Models\User
{
    $admin = \App\Models\User::factory()->create(['role' => 'admin']);
    test()->actingAs($admin);
    return $admin;
}

// Custom expectations
expect()->extend('toBeUpperCase', function () {
    return $this->toBe(strtoupper($this->value));
});

phpunit.xml

Pest uses the same configuration as PHPUnit:

xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>app</directory>
        </include>
    </source>
</phpunit>

Writing Tests

Basic Test

php
<?php

test('basic test example', function () {
    expect(true)->toBeTrue();
});

// Alternative syntax
it('can do something', function () {
    expect(2 + 2)->toBe(4);
});

Using TestCase Methods

php
<?php

use App\Models\User;

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

    $response = $this->post('/login', [
        'email' => 'john@example.com',
        'password' => 'password',
    ]);

    $response->assertRedirect('/dashboard');
    $this->assertAuthenticatedAs($user);
});

Expectations API

Basic Expectations

php
<?php

test('demonstrates basic expectations', function () {
    expect(true)->toBeTrue();
    expect(false)->toBeFalse();
    expect(null)->toBeNull();
    expect('')->toBeEmpty();
    expect(42)->toBe(42);
    expect('hello')->toBe('hello');
});

Type Expectations

php
<?php

test('type expectations', function () {
    expect('string')->toBeString();
    expect(123)->toBeInt();
    expect(3.14)->toBeFloat();
    expect(true)->toBeBool();
    expect([])->toBeArray();
    expect(new User)->toBeObject();
    expect(fn () => true)->toBeCallable();

    $user = User::factory()->create();
    expect($user)->toBeInstanceOf(User::class);
});

Collection Expectations

php
<?php

test('collection expectations', function () {
    expect([1, 2, 3])->toHaveCount(3);
    expect(['a', 'b', 'c'])->toContain('b');
    expect([])->toBeEmpty();
    expect([1, 2, 3])->not->toBeEmpty();

    expect([1, 2, 3])->each->toBeInt();
    expect(['a', 'b'])->sequence(
        fn ($item) => $item->toBe('a'),
        fn ($item) => $item->toBe('b'),
    );
});

String Expectations

php
<?php

test('string expectations', function () {
    expect('hello world')->toContain('world');
    expect('test@example.com')->toEndWith('.com');
    expect('https://example.com')->toStartWith('https://');
    expect('HELLO')->toBeUpperCase();
    expect('hello')->toBeLowerCase();
    expect('   ')->toBeEmpty();
});

Number Expectations

php
<?php

test('number expectations', function () {
    expect(10)->toBeGreaterThan(5);
    expect(3)->toBeLessThan(10);
    expect(5)->toBeGreaterThanOrEqual(5);
    expect(5)->toBeLessThanOrEqual(10);
    expect(5)->toBeBetween(1, 10);

    expect(2)->toBeEven();
    expect(3)->toBeOdd();
    expect(5)->toBePositive();
    expect(-5)->toBeNegative();
});

Negation

php
<?php

test('negation', function () {
    expect('hello')->not->toBe('world');
    expect([1, 2, 3])->not->toBeEmpty();
    expect(null)->not->toBeTrue();
});

Datasets

Inline Datasets

php
<?php

use App\Services\Calculator;

test('calculates discount', function (float $price, float $discount, float $expected) {
    $calculator = new Calculator();

    $result = $calculator->applyDiscount($price, $discount);

    expect($result)->toBe($expected);
})->with([
    [100, 10, 90],
    [200, 20, 180],
    [50, 5, 47.5],
]);

Named Datasets

php
<?php

test('validates email format', function (string $email, bool $isValid) {
    $result = filter_var($email, FILTER_VALIDATE_EMAIL);

    expect((bool) $result)->toBe($isValid);
})->with([
    'valid email' => ['john@example.com', true],
    'invalid - no @' => ['johnexample.com', false],
    'invalid - no domain' => ['john@', false],
    'valid - subdomain' => ['john@mail.example.com', true],
]);

Shared Datasets

Create tests/Datasets/Users.php:

php
<?php

dataset('user_roles', [
    'admin' => ['admin'],
    'user' => ['user'],
    'moderator' => ['moderator'],
    'guest' => ['guest'],
]);

dataset('valid_emails', [
    'john@example.com',
    'jane@test.org',
    'user@company.co.uk',
]);

Use in tests:

php
<?php

test('user has correct role', function (string $role) {
    $user = User::factory()->create(['role' => $role]);

    expect($user->role)->toBe($role);
})->with('user_roles');

Test Organization

Using describe()

php
<?php

describe('UserService', function () {
    it('creates a user', function () {
        // Test user creation
    });

    it('updates a user', function () {
        // Test user update
    });

    it('deletes a user', function () {
        // Test user deletion
    });
});

Nested describe()

php
<?php

describe('Post API', function () {
    describe('GET /api/posts', function () {
        it('returns all posts', function () {
            // ...
        });

        it('filters by status', function () {
            // ...
        });
    });

    describe('POST /api/posts', function () {
        it('creates a post', function () {
            // ...
        });

        it('validates required fields', function () {
            // ...
        });
    });
});

Hooks

beforeEach / afterEach

php
<?php

describe('DiscountService', function () {
    beforeEach(function () {
        $this->service = new DiscountService();
    });

    afterEach(function () {
        // Cleanup
    });

    test('calculates percentage discount', function () {
        $result = $this->service->calculatePercentage(100, 10);
        expect($result)->toBe(10.0);
    });
});

beforeAll / afterAll

php
<?php

beforeAll(function () {
    // Runs once before all tests in the file
});

afterAll(function () {
    // Runs once after all tests in the file
});

Running Tests

Basic Commands

bash
# Run all tests
./vendor/bin/pest

# Run specific file
./vendor/bin/pest tests/Unit/UserServiceTest.php

# Run specific test
./vendor/bin/pest --filter="user can login"

# Run specific suite
./vendor/bin/pest --testsuite=Feature

# Parallel execution
./vendor/bin/pest --parallel

# Stop on first failure
./vendor/bin/pest --stop-on-failure

Coverage

bash
# Run with coverage
./vendor/bin/pest --coverage

# Minimum coverage requirement
./vendor/bin/pest --coverage --min=80

# Coverage for specific paths
./vendor/bin/pest --coverage --min=80 app/Services

Watch Mode

bash
# Watch files and re-run tests on changes
./vendor/bin/pest --watch

Type Coverage

bash
# Check type coverage
./vendor/bin/pest --type-coverage

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

Advanced Features

Custom Expectations

php
<?php

// In tests/Pest.php
expect()->extend('toBeValidEmail', function () {
    $isValid = filter_var($this->value, FILTER_VALIDATE_EMAIL) !== false;

    expect($isValid)->toBeTrue(
        "Expected '{$this->value}' to be a valid email address"
    );

    return $this;
});

// Use in tests
test('validates email', function () {
    expect('john@example.com')->toBeValidEmail();
});

Custom Helpers

php
<?php

// In tests/Pest.php
function createPost(array $attributes = []): Post
{
    return Post::factory()->create($attributes);
}

function createUser(array $attributes = []): User
{
    return User::factory()->create($attributes);
}

// Use in tests
test('user can create post', function () {
    $user = createUser();
    $post = createPost(['user_id' => $user->id]);

    expect($post->user_id)->toBe($user->id);
});

Annotations

php
<?php

test('slow running test', function () {
    // Test implementation
})->group('slow');

test('requires database', function () {
    // Test implementation
})->group('database');

test('skipped test', function () {
    // Test implementation
})->skip('Not implemented yet');

test('todo test', function () {
    // Test implementation
})->todo();

Laravel Integration

HTTP Testing

php
<?php

use App\Models\User;

test('user can view dashboard', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->get('/dashboard');

    $response->assertStatus(200)
        ->assertViewIs('dashboard')
        ->assertSee('Welcome');
});

Database Testing

php
<?php

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

    $this->assertDatabaseHas('users', [
        'email' => 'john@example.com',
    ]);

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

Faking

php
<?php

use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;

test('sends welcome email', function () {
    Mail::fake();

    $user = User::factory()->create();
    Mail::to($user)->send(new WelcomeEmail($user));

    Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});

Best Practices

1. Descriptive Test Names

php
// ✅ Good
test('user can update their profile with valid data')
test('returns 404 when post not found')
test('validates email is required')

// ❌ Bad
test('profile update')
test('post test')
test('validation')

2. Arrange-Act-Assert

php
test('user can create post', function () {
    // Arrange
    $user = User::factory()->create();

    // Act
    $post = Post::create([
        'user_id' => $user->id,
        'title' => 'Test Post',
    ]);

    // Assert
    expect($post->user_id)->toBe($user->id);
});

3. Use Datasets for Similar Tests

php
// Instead of multiple similar tests
test('price discount 10%', function () { /* ... */ });
test('price discount 20%', function () { /* ... */ });

// Use datasets
test('calculates discount', function ($price, $percent, $expected) {
    // ...
})->with([
    [100, 10, 90],
    [100, 20, 80],
]);

4. Keep Tests Independent

php
// ✅ Good - Each test sets up its own data
test('creates user', function () {
    $user = User::factory()->create();
    expect($user)->toBeInstanceOf(User::class);
});

test('updates user', function () {
    $user = User::factory()->create();
    $user->update(['name' => 'New Name']);
    expect($user->name)->toBe('New Name');
});

CI/CD Integration

GitHub Actions

yaml
name: Tests

on: [push, pull_request]

jobs:
  tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.2
          extensions: dom, curl, libxml, mbstring, zip
          coverage: xdebug

      - name: Install Dependencies
        run: composer install --prefer-dist

      - name: Run Pest
        run: ./vendor/bin/pest --parallel --coverage --min=80


Quick Reference

Common Commands

bash
# Run tests
./vendor/bin/pest

# With coverage
./vendor/bin/pest --coverage --min=80

# Watch mode
./vendor/bin/pest --watch

# Parallel
./vendor/bin/pest --parallel

# Filter
./vendor/bin/pest --filter="test name"

Common Expectations

php
expect($value)->toBe($expected)
expect($value)->toEqual($expected)
expect($value)->toBeTrue()
expect($value)->toBeFalse()
expect($value)->toBeNull()
expect($array)->toHaveCount(3)
expect($string)->toContain('text')
expect($number)->toBeGreaterThan(5)
expect($value)->toBeInstanceOf(User::class)

CPR - Clinical Patient Records