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
- One concept per test - Test one thing only
- Use descriptive names - Should explain what's being tested
- Keep tests DRY - Use datasets and hooks
- Test behavior, not implementation - Focus on outputs
- Use factories - For realistic test data
- Mock external services - Don't call real APIs
- 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.