Skip to content
Lantorian

Laravel14 min read

Testing with Pest 4

A test proves your code does what the ticket asks. A PR without tests doesn't get reviewed here: this is how to write good ones, fast.

Which tests to write

In a Laravel API, Feature tests give the most confidence per effort: they go through the route, validation, Action and database. Unit tests cover pure logic, architecture tests set the guardrails.

Width = share of tests in the suite

Feature

A real HTTP request against the app, with an in-memory SQLite database. Most of the suite.

Time per test
~80 ms
Typical volume
200+
Folder
tests/Feature
Hover a layer. Width shows its share of the test suite.

Install and configure

Laravel 13 ships Pest by default. Shared setup lives in tests/Pest.php: the RefreshDatabase trait and shared helpers.

terminal
# New project: Pest is the default test runner
laravel new invoicing --pest

# Existing project
composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
./vendor/bin/pest --init

# Browser testing (Pest 4, Playwright under the hood)
composer require pestphp/pest-plugin-browser --dev
npm install playwright@latest && npx playwright install
tests/Pest.php
<?php

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

pest()->extend(TestCase::class)
    ->use(RefreshDatabase::class)
    ->in('Feature', 'Browser');

/** Valid payload shared by every invoice test. Override only what the test is about. */
function invoicePayload(array $overrides = []): array
{
    return array_replace_recursive([
        'customer_id' => \App\Models\Customer::factory()->create()->id,
        'due_at' => now()->addMonth()->toDateString(),
        'lines' => [
            ['label' => 'Audit UX', 'quantity' => 2, 'unit_price' => 45_000],
        ],
    ], $overrides);
}

function signIn(?User $user = null): User
{
    $user ??= User::factory()->create();
    test()->actingAs($user);

    return $user;
}

Red, green, refactor

For a bug, always start with a red test that reproduces it. For a feature, write at least the happy-path test before the code.

Write the test for the expected behavior and run it: it must fail, for the right reason.

$ pest --filter="rejects overdue"

Click a phase to stop on it. A loop takes minutes, not a day.

Unit test: pure logic

No database, no HTTP. Datasets (->with()) cover several cases in one readable test.

tests/Unit/InvoiceDataTest.php
<?php

use App\Data\InvoiceData;
use App\Enums\InvoiceStatus;

it('computes the total in cents', function () {
    // Arrange
    $data = InvoiceData::fromArray([
        'customer_id' => 1,
        'due_at' => '2026-10-01',
        'lines' => [
            ['label' => 'Design', 'quantity' => 2, 'unit_price' => 10_000],
            ['label' => 'Dev', 'quantity' => 1, 'unit_price' => 25_050],
        ],
    ]);

    // Act + Assert
    expect($data->total())->toBe(45_050);
});

it('only allows forward status transitions', function (InvoiceStatus $from, InvoiceStatus $to, bool $allowed) {
    expect($from->canTransitionTo($to))->toBe($allowed);
})->with([ 
    'draft to sent' => [InvoiceStatus::Draft, InvoiceStatus::Sent, true],
    'sent to paid' => [InvoiceStatus::Sent, InvoiceStatus::Paid, true],
    'draft to paid' => [InvoiceStatus::Draft, InvoiceStatus::Paid, false],
    'paid to draft' => [InvoiceStatus::Paid, InvoiceStatus::Draft, false],
]);

Feature test: the whole endpoint

For each endpoint, cover at least: the happy path, validation, authentication and authorization. Group them with describe().

tests/Feature/Invoice/CreateInvoiceTest.php
<?php

use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Models\User;

describe('POST /api/v1/invoices', function () {
    it('creates a draft invoice', function () {
        $user = signIn();

        $this->postJson('/api/v1/invoices', invoicePayload())
            ->assertCreated()
            ->assertJsonPath('data.status', InvoiceStatus::Draft->value)
            ->assertJsonPath('data.total', 90_000);

        $this->assertDatabaseHas('invoices', [
            'author_id' => $user->id,
            'total' => 90_000,
        ]);
    });

    it('rejects invalid payloads', function (array $overrides, string $field) {
        signIn();

        $this->postJson('/api/v1/invoices', invoicePayload($overrides))
            ->assertUnprocessable()
            ->assertJsonValidationErrors($field); 
    })->with([
        'past due date' => [['due_at' => '2020-01-01'], 'due_at'],
        'no lines' => [['lines' => []], 'lines'],
        'zero quantity' => [['lines' => [['quantity' => 0]]], 'lines.0.quantity'],
    ]);

    it('requires authentication', function () {
        $this->postJson('/api/v1/invoices', invoicePayload())->assertUnauthorized();
    });

    it('forbids users without permission', function () {
        signIn(User::factory()->readOnly()->create());

        $this->postJson('/api/v1/invoices', invoicePayload())->assertForbidden();
        expect(Invoice::count())->toBe(0);
    });
});

Testing an Action directly

When the Action is reused outside HTTP (command, job), test it directly. Side effects are isolated with fakes.

tests/Feature/Invoice/CreateInvoiceActionTest.php
<?php

use App\Actions\Invoice\CreateInvoice;
use App\Data\InvoiceData;
use App\Events\InvoiceCreated;
use App\Models\User;
use Illuminate\Support\Facades\Event;

it('dispatches InvoiceCreated once the invoice is stored', function () {
    Event::fake([InvoiceCreated::class]); 
    $author = User::factory()->create();

    $invoice = app(CreateInvoice::class)->handle(
        $author,
        InvoiceData::fromArray(invoicePayload()),
    );

    expect($invoice)
        ->lines->toHaveCount(1)
        ->author_id->toBe($author->id);

    Event::assertDispatched(
        InvoiceCreated::class,
        fn (InvoiceCreated $event) => $event->invoice->is($invoice),
    );
});

Factories and states

Never rely on seeders or existing data. Each test creates what it needs with a factory, and states name business situations.

database/factories/InvoiceFactory.php
<?php

namespace Database\Factories;

use App\Enums\InvoiceStatus;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<\App\Models\Invoice> */
class InvoiceFactory extends Factory
{
    public function definition(): array
    {
        return [
            'customer_id' => Customer::factory(),
            'author_id' => User::factory(),
            'status' => InvoiceStatus::Draft,
            'total' => fake()->numberBetween(10_000, 500_000),
            'due_at' => now()->addMonth(),
        ];
    }

    public function sent(): static
    {
        return $this->state(['status' => InvoiceStatus::Sent]);
    }

    public function overdue(): static
    {
        return $this->sent()->state(['due_at' => now()->subWeek()]);
    }
}

Isolating the outside world

A test must never send a real email, call a real API or wait a real day.

NeedLaravel tool
Check an event is dispatchedEvent::fake()
Check a job is queuedQueue::fake()
Check an email is sentMail::fake()
Simulate an external APIHttp::fake()
Write files without a real diskStorage::fake('s3')
Move forward in time$this->travel(5)->days()
tests/Feature/Invoice/RemindersTest.php
<?php

use App\Jobs\SendInvoiceReminder;
use App\Mail\InvoiceOverdue;
use App\Models\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;

it('queues a reminder for overdue invoices', function () {
    Queue::fake();
    $invoice = Invoice::factory()->sent()->create(['due_at' => now()->subDay()]);

    $this->artisan('invoices:remind')->assertSuccessful();

    Queue::assertPushed(SendInvoiceReminder::class, fn ($job) => $job->invoice->is($invoice));
});

it('mails the customer 30 days after the due date', function () {
    Mail::fake();
    $invoice = Invoice::factory()->sent()->create(['due_at' => now()]);

    $this->travel(30)->days(); 
    (new SendInvoiceReminder($invoice))->handle();

    Mail::assertQueued(InvoiceOverdue::class);
});

it('marks the invoice paid when the payment provider confirms', function () {
    Http::preventStrayRequests(); 
    Http::fake([
        'api.payments.test/*' => Http::response(['status' => 'succeeded'], 200),
    ]);

    $invoice = Invoice::factory()->sent()->create();

    signIn();
    $this->postJson("/api/v1/invoices/{$invoice->id}/pay")->assertOk();

    expect($invoice->fresh()->status->value)->toBe('paid');
});

Architecture tests

The rules from the Architecture page become executable. Pest presets cover the basics, and we add our conventions on top.

tests/Arch/ArchTest.php
<?php

arch()->preset()->php();      // no dd(), dump(), var_dump()...
arch()->preset()->security(); // no eval(), md5(), unserialize()...
arch()->preset()->laravel();  // Laravel naming and folder conventions

arch('controllers stay thin')
    ->expect('App\Http\Controllers')
    ->not->toUse(['Illuminate\Support\Facades\DB', 'Illuminate\Support\Facades\Mail']);

arch('actions are final and expose handle()')
    ->expect('App\Actions')
    ->toBeFinal()
    ->toHaveMethod('handle');

arch('DTOs are immutable')
    ->expect('App\Data')
    ->toBeReadonly();

arch('env() is only read in config files')
    ->expect('env')
    ->not->toBeUsed(); // config/ is not scanned, app/ is

Browser tests (Pest 4)

New in Pest 4: tests in a real browser (Playwright), with the same syntax and the same factories. Keep them for critical journeys in Laravel-rendered apps. For a Next.js front end, use Playwright on the front-end side.

tests/Browser/PayInvoiceTest.php
<?php

use App\Models\Invoice;

it('lets an accountant mark an invoice as paid', function () {
    $user = signIn();
    $invoice = Invoice::factory()->sent()->for($user, 'author')->create();

    visit("/invoices/{$invoice->id}")
        ->assertSee('En attente de paiement')
        ->click('Marquer comme payée')
        ->assertSee('Payée')
        ->assertNoJavascriptErrors();
});

it('has no smoke on public pages', function () {
    visit(['/', '/login', '/forgot-password'])->assertNoSmoke(); 
});

Running tests

While developing, run only what you touch. Before pushing, run everything.

terminal
php artisan test --parallel        # everything, on all CPU cores
./vendor/bin/pest --filter="draft"  # only matching tests
./vendor/bin/pest --dirty           # only tests touched by uncommitted changes
./vendor/bin/pest --bail            # stop at the first failure
./vendor/bin/pest --profile         # list the slowest tests

./vendor/bin/pest --coverage --min=80   # fail under 80% line coverage
./vendor/bin/pest --type-coverage --min=95
./vendor/bin/pest --mutate --parallel   # do your tests really catch bugs?

In CI

The same suite runs on every PR, split into parallel shards. Coverage under 80% blocks the merge.

.github/workflows/api-tests.yml
name: api-tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3]
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: pcov

      - uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - run: composer install --no-interaction --prefer-dist
      - run: cp .env.example .env && php artisan key:generate

      - run: ./vendor/bin/pint --test
      - run: ./vendor/bin/phpstan analyse --memory-limit=1G
      - run: ./vendor/bin/pest --parallel --coverage --min=80 --shard=${{ matrix.shard }}/3

A good test

Avoid

tests/Feature/InvoiceTest.php
test('invoice', function () {
    $user = User::first();           // depends on seeded data
    $response = $this->post('/api/v1/invoices', [/* ... */]);

    $this->assertTrue($response->status() == 201);
    $this->assertTrue(Invoice::count() > 0);

    // ...then tests update, delete and pay in the same test
});

Do

tests/Feature/Invoice/CreateInvoiceTest.php
it('creates a draft invoice', function () {
    $user = signIn();

    $this->postJson('/api/v1/invoices', invoicePayload())
        ->assertCreated()
        ->assertJsonPath('data.status', 'draft');

    $this->assertDatabaseHas('invoices', ['author_id' => $user->id]);
});
  • One behavior per test, with a name that reads like a sentence: it('rejects an invoice without lines').
  • Arrange, Act, Assert: set up, act, verify, separated by a blank line.
  • Independent: it passes alone, in any order, in parallel.
  • Test behavior, not implementation: check the response and the database, not which methods were called.
  • Precise assertions: assertCreated() rather than assertTrue($status == 201).
  • Mutation testing now and then: pest --mutate reveals tests that catch nothing.