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 --initOptional 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 --devConfiguration
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-failureCoverage
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/ServicesWatch Mode
bash
# Watch files and re-run tests on changes
./vendor/bin/pest --watchType Coverage
bash
# Check type coverage
./vendor/bin/pest --type-coverage
# Minimum type coverage
./vendor/bin/pest --type-coverage --min=80Advanced 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=80Related Documentation
- Testing Overview - General testing concepts
- Unit Testing - Unit testing guide
- Feature Testing - Feature testing guide
- API Testing - API testing guide
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)