Skip to content
Tech

Testing Laravel Applications with Pest: A Practical Starting Point

An introduction to writing Laravel feature and unit tests with Pest, including datasets, expectations and what's actually worth testing.

Testing Laravel Applications with Pest: A Practical Starting Point
Testing Laravel Applications with Pest: A Practical Starting Point
In this article

    Pest wraps PHPUnit in a lighter syntax built around functions instead of classes, and it has become the default choice for new Laravel projects. The underlying test runner is the same PHPUnit that's always been there, which means every assertion, mock, and Laravel testing helper you already know still works — Pest just changes how you write the test.

    Installing Pest in a Laravel Project

    composer require pestphp/pest pestphp/pest-plugin-laravel --dev
    php artisan pest:install

    This scaffolds a tests/Pest.php configuration file and wires Pest's Laravel plugin so feature tests get the same application bootstrapping that TestCase provided before.

    A Feature Test, Written Plainly

    use App\Models\Invoice;
    use App\Models\User;
    
    it('returns a paginated list of invoices for an authenticated user', function () {
        $user = User::factory()->create();
        Invoice::factory()->count(15)->for($user)->create();
    
        $response = $this->actingAs($user)->getJson('/api/v1/invoices');
    
        $response->assertOk()
            ->assertJsonCount(15, 'data')
            ->assertJsonStructure(['data' => [['id', 'total', 'status']]]);
    });
    
    it('rejects unauthenticated requests', function () {
        $this->getJson('/api/v1/invoices')->assertUnauthorized();
    });

    No class, no public function test... boilerplate — it() and test() are just functions, and the closure is the test body. $this inside them still refers to the bound Laravel test case, so every actingAs, assertDatabaseHas, and HTTP testing method behaves exactly as it does in PHPUnit.

    Datasets for Repetitive Cases

    When the same test should run against several inputs, a dataset avoids copy-pasting the test body.

    it('marks an invoice overdue correctly', function (string $dueAt, bool $expected) {
        $invoice = Invoice::factory()->make(['due_at' => $dueAt, 'paid_at' => null]);
    
        expect($invoice->is_overdue)->toBe($expected);
    })->with([
        ['2020-01-01', true],
        ['2099-01-01', false],
    ]);

    Each row in the array runs as its own test case, with its own pass/fail result in the output — useful for catching an edge case like a due date that falls exactly on the boundary.

    Tip: Name dataset keys instead of leaving them as plain arrays — 'past date' => [...] — so a failing case shows up in the test output as something readable, not dataset #3.

    Expectations Read Like Assertions, Not Replace Them

    Pest's expect() API is fluent but maps directly onto familiar assertions:

    expect($invoice->total)->toBeGreaterThan(0);
    expect($invoice->customer)->toBeInstanceOf(Customer::class);
    expect(fn () => $invoice->void())->toThrow(InvalidStateException::class);

    There's no hidden behavior here worth memorizing beyond the method names — it's the same testing mental model as PHPUnit, with less punctuation.

    What's Actually Worth Testing

    A test suite that mirrors your controllers one-to-one often tests the framework, not your application.
    • Test business rules that would cost something if they broke silently: discount calculations, permission checks, state transitions.
    • Test API contracts — response shape and status codes — especially on endpoints external clients depend on.
    • Skip testing Eloquent casts, Laravel's own validation rules, or anything the framework already guarantees; you'd just be re-testing Laravel.

    Running Tests in CI

    php artisan test --parallel --coverage --min=70

    --parallel splits the suite across processes, which matters once a feature suite touching a real database starts taking minutes instead of seconds. --min fails the build outright if coverage drops below a threshold, which is a cheap way to stop a large untested change from merging unnoticed.

    Pest doesn't change what makes a test suite valuable — that still comes from testing behavior people rely on, not implementation details. What it changes is how much friction there is to writing the next test, and friction is usually what determines whether that test gets written at all.

    Written by Rajasekar

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.