Laravel Developer's Guide to Writing Better Pest Tests

Testing is often seen as a necessary evil in web development, but with the right approach and tools, it can become an invaluable part of your development workflow.

24. Oct 2024 · by Lasse Foo-Rafn
·

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

Monitor Your Test Quality

With OtterWise, you can track Code Coverage, test performance, contributor stats, code quality, and much more.

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

Monitor Your Test Quality

With OtterWise, you can track Code Coverage, test performance, contributor stats, code quality, and much more.

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:

  1. Understanding the Why: Know why certain practices are better than others
  2. Following Patterns: Use established patterns that promote maintainability
  3. Testing Behavior: Focus on what your code does, not how it does it
  4. Using the Right Tools: Leverage Pest's features to write clearer tests
  5. 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

Additional Resources

Improve code quality today_

With OtterWise, you can track Code Coverage, test performance, contributor stats, code health, and much more.