Stu Mason
Stu Mason
Guide

Testing Laravel Apps with Pest: Real Patterns from Real Projects

Stuart Mason7 min read

I'll be straight with you: I didn't always write tests. For years I was the "I'll test it manually" developer. Then I shipped a bug to production that cost a client money, and I became a testing conve

Testing Laravel Apps with Pest: Real Patterns from Real Projects

I'll be straight with you: I didn't always write tests. For years I was the "I'll test it manually" developer. Then I shipped a bug to production that cost a client money, and I became a testing convert overnight. Nothing motivates quite like the fear of doing that again.

Now I test everything. Not because I love writing tests (I don't — they're boring), but because I love sleeping at night more than I hate writing tests.

The Testing Pyramid for Laravel

Here's how I distribute test effort:

         /\
        /  \       Browser tests (Pest 4)
       / 5% \      Critical user flows only
      /------\
     /        \    Feature tests
    /   70%    \   HTTP requests, database, full stack
   /------------\
  /              \  Unit tests
 /     25%        \ Pure logic, Actions, DTOs
/------------------\

Feature tests are the workhorse. They hit real routes, interact with the database, and test complete request-response cycles. Most of your tests should be feature tests.

Unit tests are for isolated business logic. Actions, DTOs, helper functions — anything you can test without HTTP or database.

Browser tests are for critical flows where JavaScript behaviour matters: checkout processes, multi-step forms, drag-and-drop interfaces.

Factory Patterns That Don't Make You Want to Die

The default factory pattern works, but it gets unwieldy fast. Here's how I structure factories for readability:

// database/factories/UserFactory.php
class UserFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'password' => Hash::make('password'),
            'email_verified_at' => now(),
        ];
    }

    // States for common scenarios
    public function unverified(): static
    {
        return $this->state(fn () => [
            'email_verified_at' => null,
        ]);
    }

    public function admin(): static
    {
        return $this->state(fn () => [
            'role' => UserRole::Admin,
        ]);
    }

    public function withSubscription(string $plan = 'pro'): static
    {
        return $this->afterCreating(function (User $user) use ($plan) {
            Subscription::factory()
                ->for($user)
                ->active()
                ->create(['plan' => $plan]);
        });
    }

    public function withProjects(int $count = 3): static
    {
        return $this->afterCreating(function (User $user) use ($count) {
            Project::factory()
                ->count($count)
                ->for($user)
                ->create();
        });
    }
}

Now your tests read like English:

it('shows the dashboard with projects', function () {
    $user = User::factory()
        ->withSubscription('pro')
        ->withProjects(5)
        ->create();

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertSuccessful()
        ->assertInertia(fn ($page) => $page
            ->component('Dashboard/Index')
            ->has('projects', 5)
        );
});

Compare that to setting up each model manually with 15 lines of factory calls. States are the secret weapon.

Datasets for Validation Testing

If you're testing form validation and you've got a test per rule, you're doing it wrong. Use datasets:

dataset('invalid project data', [
    'missing name' => [['name' => ''], 'name'],
    'name too long' => [['name' => str_repeat('a', 256)], 'name'],
    'missing description' => [['description' => ''], 'description'],
    'invalid status' => [['status' => 'bollocks'], 'status'],
    'invalid url' => [['url' => 'not-a-url'], 'url'],
    'negative budget' => [['budget' => -100], 'budget'],
]);

it('rejects invalid project data', function (array $data, string $errorField) {
    $user = User::factory()->create();
    $validData = [
        'name' => 'Valid Project',
        'description' => 'A valid description',
        'status' => 'active',
        'url' => 'https://example.com',
        'budget' => 1000,
    ];

    $this->actingAs($user)
        ->post('/projects', array_merge($validData, $data))
        ->assertSessionHasErrors($errorField);
})->with('invalid project data');

One test function, six test cases. Add a new validation rule? Add a line to the dataset. No duplication.

Testing Inertia Responses

Inertia provides testing helpers that make response assertions clean:

it('passes correct data to the dashboard page', function () {
    $user = User::factory()
        ->withProjects(3)
        ->create();

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertSuccessful()
        ->assertInertia(fn ($page) => $page
            ->component('Dashboard/Index')
            ->has('projects', 3)
            ->has('projects.0', fn ($project) => $project
                ->has('id')
                ->has('name')
                ->has('slug')
                ->where('status', 'active')
            )
            ->has('stats', fn ($stats) => $stats
                ->has('totalProjects')
                ->has('activeProjects')
            )
        );
});

The assertInertia callback lets you verify the exact shape of data being sent to your frontend. This catches issues where a controller change breaks the frontend contract — the test fails before TypeScript even has a chance to complain.

Testing Actions

Actions are the easiest things to test because they're just classes with methods:

// app/Actions/Article/PublishArticle.php
class PublishArticle
{
    public function execute(Article $article): void
    {
        throw_if(
            $article->published_at !== null,
            ArticleAlreadyPublishedException::class
        );

        $article->update([
            'published_at' => now(),
            'status' => ArticleStatus::Published,
        ]);

        ArticlePublished::dispatch($article);
    }
}

// tests/Unit/Actions/Article/PublishArticleTest.php
it('publishes a draft article', function () {
    $article = Article::factory()->draft()->create();

    Event::fake();

    app(PublishArticle::class)->execute($article);

    expect($article->fresh())
        ->published_at->not->toBeNull()
        ->status->toBe(ArticleStatus::Published);

    Event::assertDispatched(ArticlePublished::class);
});

it('throws when publishing an already published article', function () {
    $article = Article::factory()->published()->create();

    app(PublishArticle::class)->execute($article);
})->throws(ArticleAlreadyPublishedException::class);

it('dispatches the ArticlePublished event', function () {
    Event::fake();
    $article = Article::factory()->draft()->create();

    app(PublishArticle::class)->execute($article);

    Event::assertDispatched(ArticlePublished::class, function ($event) use ($article) {
        return $event->article->id === $article->id;
    });
});

Three tests, three concerns: the happy path, the error path, and the side effect. That's the pattern for every Action.

Testing DTOs

DTOs are data containers. Test the fromModel method:

it('creates ArticleData from a model', function () {
    $article = Article::factory()->create([
        'title' => 'Test Article',
        'slug' => 'test-article',
        'excerpt' => 'A test excerpt',
        'published_at' => Carbon::parse('2026-01-15 10:00:00'),
    ]);

    $dto = ArticleData::fromModel($article);

    expect($dto)
        ->id->toBe($article->id)
        ->title->toBe('Test Article')
        ->slug->toBe('test-article')
        ->excerpt->toBe('A test excerpt')
        ->publishedAt->toEqual(Carbon::parse('2026-01-15 10:00:00'));
});

Simple, fast, and catches mismatched field mappings.

Browser Testing with Pest 4

Pest 4 introduced browser testing, and it's a genuine step up from Dusk. Here's what a real browser test looks like:

it('completes the onboarding flow', function () {
    $user = User::factory()->unverified()->create();

    $this->actingAs($user);

    $page = visit('/onboarding');

    $page->assertSee('Welcome')
        ->assertNoJavascriptErrors()
        ->fill('company_name', 'Acme Ltd')
        ->fill('company_size', '10-50')
        ->click('Next Step')
        ->assertSee('Tell us about your project')
        ->fill('project_name', 'My SaaS')
        ->select('category', 'saas')
        ->click('Complete Setup')
        ->assertUrlIs('/dashboard')
        ->assertSee('Welcome to your dashboard');
});

This tests the actual user journey through a real browser. JavaScript executes. Page transitions happen. Forms submit. If the onboarding flow breaks for any reason — a JavaScript error, a missing form field, a broken redirect — this test catches it.

Use browser tests sparingly. They're slower than feature tests (seconds vs milliseconds). Reserve them for:

  • Multi-step forms where JavaScript state matters
  • Critical user flows (signup, checkout, onboarding)
  • Interactions that require JavaScript (drag-and-drop, modals, dynamic forms)

Smoke Testing

Pest 4's smoke testing is brilliantly simple:

it('loads all public pages without errors', function () {
    $pages = visit([
        '/',
        '/about',
        '/services',
        '/articles',
        '/contact',
    ]);

    $pages->assertNoJavascriptErrors()
        ->assertNoConsoleLogs();
});

This catches broken pages, JavaScript errors, and console warnings across your entire public-facing site in one test. Run it in CI and you'll never deploy a page with a React error boundary showing.

What Not to Test

Not everything needs a test. Here's what I skip:

  • Eloquent relationship definitions. If $user->projects() returns projects, I trust Laravel's ORM. I test the behaviour that uses the relationship, not the relationship itself.
  • Simple getters/setters. If a method just returns a property, it doesn't need a test.
  • Framework code. Don't test that Auth::check() works. Laravel tests that. Test that your code behaves correctly when the user is/isn't authenticated.
  • Third-party package behaviour. Don't test that Stripe's SDK charges a card. Mock it and test that your code calls it correctly.

Mocking External Services

When your Action calls a third-party API, mock it:

it('syncs the repository from GitHub', function () {
    $user = User::factory()->create();
    $repository = Repository::factory()->for($user)->create([
        'github_url' => 'https://github.com/stumason/example',
    ]);

    $mock = $this->mock(GitHubService::class);
    $mock->shouldReceive('getRepository')
        ->with('stumason', 'example')
        ->once()
        ->andReturn([
            'name' => 'example',
            'description' => 'An example repo',
            'stars' => 42,
        ]);

    app(SyncRepository::class)->execute($repository);

    expect($repository->fresh())
        ->description->toBe('An example repo')
        ->stars->toBe(42);
});

Mock the boundary, not the internals. Your test should verify that your code does the right thing with the data it receives, not that the HTTP client sent the right headers.

Running Tests Efficiently

During development, run only what's relevant:

# Run tests in a specific file
php artisan test --compact tests/Feature/ArticleTest.php

# Run a specific test by name
php artisan test --compact --filter="publishes a draft article"

# Run the full suite before pushing
php artisan test --compact

The --compact flag gives concise output. Use --filter to run individual tests while developing. Run the full suite before committing.

The Honest Truth About Testing

Testing doesn't prevent all bugs. It prevents regression — the same bug happening twice. It gives you confidence to refactor. It documents expected behaviour.

The tests I write aren't perfect. They don't achieve 100% coverage. But they cover the important paths: the happy path, the error path, and the edge cases that have bitten me before.

Write tests that would have caught your last bug. That's the best heuristic I've found.


I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.