In this guide, we'll explore how to write maintainable, reliable tests using Pest PHP in your Laravel applications, with detailed explanations of why certain practices work better than others.
Table of Contents
Understanding the Testing Pyramid
The testing pyramid is a fundamental concept that helps us balance different types of tests in our Laravel applications. Let's break down each layer and understand why it's important:
Unit Tests
These form the base of your testing pyramid and should be the most numerous. They're:
- Fast to execute
- Test individual components in isolation
- Help pinpoint exact failure points
- Easier to maintain
Integration Tests
The middle layer tests how components work together:
- Verify correct interaction between services
- Test database operations
- Ensure proper event handling
- Validate middleware functionality
Feature Tests
These test complete features from end to end:
- Simulate actual HTTP requests
- Test entire user workflows
- Verify JSON responses
- Ensure proper view rendering
Browser Tests
The tip of the pyramid using Laravel Dusk:
- Test JavaScript interactions
- Verify real browser behavior
- Should be used sparingly due to slower execution
- Essential for complex UI interactions
Common Testing Mistakes and How to Fix Them
1. Testing Implementation Instead of Behavior
❌ Bad Practice:
test('user service creates user', function () {
$userService = new UserService();
expect($userService->createUser(['name' => 'John']))
->toBeInstanceOf(User::class)
->name->toBe('John');
});
Why This Is Problematic:
- Tightly couples tests to implementation details
- Breaks when refactoring internal code
- Doesn't test the actual user experience
- Makes tests brittle and harder to maintain
✅ Better Approach:
test('new users can register through the application', function () {
$response = post('/register', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'password123'
]);
$response->assertStatus(201);
expect(User::whereEmail('[email protected]')->exists())->toBeTrue();
// Test actual business outcomes
expect(User::whereEmail('[email protected]')->first())
->name->toBe('John Doe')
->email->toBe('[email protected]');
});
Why This Is Better:
- Tests the actual user-facing behavior
- Verifies the entire registration flow
- Remains stable during internal refactoring
- Tests what the code does, not how it does it
2. Not Using Database Transactions
❌ Bad Practice:
test('user can create a post', function () {
$user = User::factory()->create();
actingAs($user)->post('/posts', [
'title' => 'My Post',
'content' => 'Content'
]);
// Test passes but leaves data in the database
});
Why This Is Problematic:
- Tests can interfere with each other
- Database state becomes unpredictable
- Can cause false positives/negatives
- Makes tests order-dependent
✅ Better Approach:
uses(RefreshDatabase::class);
test('user can create a post', function () {
$user = User::factory()->create();
$response = actingAs($user)->post('/posts', [
'title' => 'My Post',
'content' => 'Content'
]);
$response->assertStatus(201);
expect(Post::count())->toBe(1);
expect(Post::first())
->title->toBe('My Post')
->content->toBe('Content')
->user_id->toBe($user->id);
})->group('posts');
Why This Is Better:
- Each test starts with a clean database
- Tests are isolated and independent
- More comprehensive assertions
- Groups related tests for better organization
With OtterWise, you can track Code Coverage, test performance, contributor stats, code quality, and much more.
Free for open source
3. Overusing Mocks
❌ Bad Practice:
test('notification is sent', function () {
$notificationMock = mock(NotificationService::class);
$notificationMock->shouldReceive('send')->once();
app()->instance(NotificationService::class, $notificationMock);
$this->artisan('send:notifications');
});
Why This Is Problematic:
- Creates brittle tests that break easily
- Tests implementation rather than behavior
- Can give false confidence
- Makes refactoring difficult
✅ Better Approach:
test('user receives welcome email after registration', function () {
Notification::fake();
Mail::fake();
$userData = [
'name' => 'Jane Doe',
'email' => '[email protected]',
'password' => 'password123'
];
$response = post('/register', $userData);
$user = User::whereEmail($userData['email'])->first();
// Test the actual outcomes
$response->assertStatus(201);
expect($user)->not->toBeNull();
// Verify notifications
Notification::assertSentTo(
$user,
WelcomeNotification::class,
function ($notification) use ($user) {
return $notification->user->id === $user->id;
}
);
Mail::assertSent(WelcomeEmail::class);
});
Why This Is Better:
- Uses Laravel's built-in testing helpers
- Tests actual business requirements
- More maintainable and readable
- Better reflects real application behavior
Best Practices for Laravel Testing with Pest
1. Use Descriptive Test Names
// ❌ Bad naming
test('post creation', function () {
// Test logic
});
// ✅ Good naming
test('authenticated users can create posts with valid data', function () {
// Test logic
});
// ✅ Even better with contexts
describe('Post Creation', function () {
test('succeeds with valid data when user is authenticated', function () {
// Happy path
});
test('fails with proper validation errors when title is missing', function () {
// Validation testing
});
test('prevents creation when user is not authenticated', function () {
// Authentication testing
});
});
Why Good Naming Matters:
- Makes test failures more understandable
- Serves as documentation
- Helps identify test coverage gaps
- Makes test reports more useful
2. Leverage Pest's Higher-Order Testing
it('validates post creation requirements')
->tap(fn () => post('/posts', ['content' => 'Test']))
->assertStatus(422)
->assertJsonValidationErrors(['title'])
->tap(fn () => post('/posts', ['title' => 'Test']))
->assertStatus(422)
->assertJsonValidationErrors(['content']);
it('creates posts with valid data')
->expectToHaveCount(Post::class, 0)
->tap(fn () => post('/posts', [
'title' => 'Test Post',
'content' => 'Content'
]))
->assertStatus(201)
->expectToHaveCount(Post::class, 1);
Benefits of Higher-Order Testing:
- More concise and readable tests
- Chains assertions fluently
- Reduces boilerplate code
- Makes test intent clearer
3. Custom Expectations for Domain Logic
expect()->extend('toBeValidPost', function () {
return $this->toBeInstanceOf(Post::class)
->and($this->value)
->title->not->toBeEmpty()
->content->not->toBeEmpty()
->user_id->not->toBeNull()
->created_at->not->toBeNull();
});
expect()->extend('toHaveBeenPublished', function () {
return $this->and($this->value)
->published_at->not->toBeNull()
->status->toBe('published');
});
test('publishing a post sets correct attributes', function () {
$post = Post::factory()->create(['status' => 'draft']);
$post->publish();
expect($post)
->toBeValidPost()
->toHaveBeenPublished();
});
Benefits of Custom Expectations:
- Encapsulates domain-specific assertions
- Makes tests more readable
- Reduces duplication
- Easier to maintain common assertions
4. Testing Database Relationships and Complex Queries
test('feed shows posts from followed users in correct order', function () {
$user = User::factory()->create();
$followedUsers = User::factory()
->count(3)
->has(Post::factory()->count(2))
->create();
// Setup relationships
$followedUsers->each(fn ($followedUser) =>
$user->following()->attach($followedUser)
);
$response = actingAs($user)->get('/feed');
$response->assertOk();
// Test order and content
expect($response->json('data'))
->toHaveCount(6)
->sequence(
fn ($post) => $post->created_at->toBe($followedUsers->last()->posts->last()->created_at),
fn ($post) => $post->user_id->toBe($followedUsers->last()->id),
// ... more sequence checks
);
});
Why This Approach Works:
- Tests complex data relationships
- Verifies correct ordering
- Ensures proper eager loading
- Tests real-world scenarios
With OtterWise, you can track Code Coverage, test performance, contributor stats, code quality, and much more.
Free for open source
Advanced Testing Patterns
1. Test Data Builders
class PostBuilder
{
private array $attributes = [];
public static function aPost(): self
{
return new self();
}
public function published(): self
{
$this->attributes['status'] = 'published';
$this->attributes['published_at'] = now();
return $this;
}
public function withComments(int $count = 1): self
{
$this->attributes['comments'] = Comment::factory()->count($count);
return $this;
}
public function create(): Post
{
return Post::factory()
->has($this->attributes['comments'] ?? [])
->create($this->attributes);
}
}
test('published posts appear in feed', function () {
$post = PostBuilder::aPost()
->published()
->withComments(3)
->create();
// Test logic
});
Benefits:
- Makes test setup more expressive
- Encapsulates complex object creation
- Improves test readability
- Reduces duplication
2. State-Based Testing
test('post transitions through correct states', function () {
$post = Post::factory()->create(['status' => 'draft']);
expect($post)->status->toBe('draft');
$post->submit();
expect($post)->status->toBe('pending');
$post->approve();
expect($post)
->status->toBe('published')
->published_at->not->toBeNull();
$post->archive();
expect($post)->status->toBe('archived');
});
Why State Testing Matters:
- Verifies business rules
- Tests state transitions
- Ensures data consistency
- Documents expected behavior
Conclusion
Writing effective tests with Pest in Laravel is about:
- Understanding the Why: Know why certain practices are better than others
- Following Patterns: Use established patterns that promote maintainability
- Testing Behavior: Focus on what your code does, not how it does it
- Using the Right Tools: Leverage Pest's features to write clearer tests
- Thinking Long-Term: Write tests that help, not hinder, future development
Remember: The goal isn't just to have tests, but to have tests that:
- Give confidence in your code
- Make refactoring easier
- Serve as documentation
- Catch bugs early
- Are easy to maintain