Skip to main content
paulund

5 min read

#laravel #testing #pest #php

Testing in Laravel

Most Laravel developers know they should write more tests. The question is where to start and what's actually worth the effort. This post covers the practical patterns I use in real Laravel projects.

Pest vs PHPUnit

Laravel ships with both PHPUnit and Pest. Pest is the modern choice. It's built on top of PHPUnit but gives you a cleaner syntax, better output, and useful Laravel-specific helpers out of the box.

Install Pest if it's not already there:

composer require pestphp/pest pestphp/pest-plugin-laravel --dev
./vendor/bin/pest --init

Tests live in two directories:

  • tests/Unit/ — pure PHP, no framework boot
  • tests/Feature/ — full Laravel application, database, HTTP

Feature tests

Feature tests are the ones worth writing first. They exercise the full stack: routing, middleware, controllers, models, and database. If a feature test passes, you know the feature works.

Test HTTP responses directly:

it('shows the dashboard to authenticated users', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertOk()
        ->assertSee($user->name);
});

it('redirects guests away from the dashboard', function () {
    $this->get('/dashboard')
        ->assertRedirect('/login');
});

Test form submissions:

it('creates a post when valid data is submitted', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post('/posts', [
            'title' => 'My first post',
            'body' => 'Some content here.',
        ])
        ->assertRedirect('/posts');

    $this->assertDatabaseHas('posts', [
        'title' => 'My first post',
        'user_id' => $user->id,
    ]);
});

it('rejects posts with missing title', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post('/posts', ['body' => 'No title here.'])
        ->assertSessionHasErrors(['title']);
});

Unit tests

Unit tests cover isolated logic: helper functions, service classes, value objects, anything that doesn't need the framework to run.

it('formats a price in pence to pounds', function () {
    expect(formatPrice(1999))->toBe('£19.99');
    expect(formatPrice(100))->toBe('£1.00');
    expect(formatPrice(0))->toBe('£0.00');
});

Service class example:

it('calculates the total with tax', function () {
    $calculator = new OrderTotalCalculator(vatRate: 0.20);

    $total = $calculator->calculate(items: [
        ['price' => 1000, 'quantity' => 2],
        ['price' => 500, 'quantity' => 1],
    ]);

    expect($total)->toBe(3000); // (2000 + 500) * 1.20 = 3000
});

Factories

Model factories are how you create test data. Laravel generates them with Artisan:

php artisan make:factory PostFactory

A good factory covers the most common states your model can be in:

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => $this->faker->sentence(),
            'body' => $this->faker->paragraphs(3, asText: true),
            'published_at' => null,
            'user_id' => User::factory(),
        ];
    }

    public function published(): static
    {
        return $this->state(['published_at' => now()]);
    }

    public function draft(): static
    {
        return $this->state(['published_at' => null]);
    }
}

Use states in tests:

it('only shows published posts', function () {
    Post::factory()->count(3)->published()->create();
    Post::factory()->count(2)->draft()->create();

    $this->get('/posts')
        ->assertOk()
        ->assertJsonCount(3, 'data');
});

Database testing

Laravel resets your database between tests by default. Use RefreshDatabase or the Pest equivalent to ensure a clean slate:

// In tests/Pest.php
uses(RefreshDatabase::class)->in('Feature');

If test speed matters, LazilyRefreshDatabase only resets if the test actually touches the database:

uses(LazilyRefreshDatabase::class)->in('Feature');

For read-only tests (just asserting on existing records), wrap in a database transaction instead:

uses(DatabaseTransactions::class)->in('Feature/ReadOnly');

Testing jobs and events

Don't let jobs or events actually fire during tests. Fake them:

it('dispatches a welcome email job on registration', function () {
    Queue::fake();

    $this->post('/register', [
        'name' => 'Paul',
        'email' => '[email protected]',
        'password' => 'password',
        'password_confirmation' => 'password',
    ])->assertRedirect();

    Queue::assertPushed(SendWelcomeEmail::class);
});

Same pattern for events:

it('fires a UserRegistered event', function () {
    Event::fake();

    $this->post('/register', $validData)->assertRedirect();

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

Testing notifications and mail

it('emails the user after password reset', function () {
    Mail::fake();

    $user = User::factory()->create();

    $this->post('/forgot-password', ['email' => $user->email]);

    Mail::assertQueued(PasswordResetMail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});

What to actually test

The tests most worth writing, in rough priority order:

  1. Authentication and authorisation flows — wrong access control causes real damage
  2. Form submissions — validation rules, success paths, failure paths
  3. Any business logic with branching conditions
  4. Anything that touches money or user data
  5. Jobs and scheduled tasks that run in the background
  6. API responses consumed by a frontend or external service

What you can skip for most projects:

  • Trivial CRUD with no logic (getters, setters, basic model relationships)
  • Third-party package behaviour (test your code, not theirs)
  • Pure UI rendering with no data logic

Running tests

# Run all tests
./vendor/bin/pest

# Run a specific file
./vendor/bin/pest tests/Feature/PostTest.php

# Run tests matching a name
./vendor/bin/pest --filter="creates a post"

# Run with coverage (requires Xdebug or PCOV)
./vendor/bin/pest --coverage

Add a composer script to keep it short:

"scripts": {
    "test": "pest"
}

Then: composer test.

A note on test-driven development

You don't have to write tests before you write code for every feature. But for anything non-trivial, writing the test first forces you to think about the interface before the implementation. What input does this function need? What should it return? What should it refuse?

Even a rough feature test written before you start coding — one that fails and that you work towards making pass — tends to produce cleaner code than testing after the fact.

Related notes


Newsletter

A weekly newsletter on backend architecture, AI-assisted development, and engineering. No spam, unsubscribe any time.