+1 (415) 599-8902

Pest 4 Browser Testing in Laravel: End-to-End Tests You Will Actually Keep

Laravel has shipped with Dusk for browser testing since 2016, and for most of that time end-to-end tests were something teams intended to write. The setup was fiddly, ChromeDriver drifted out of sync with Chrome, tests were slow, and failures were hard to read. Pest 4 changed the economics: browser testing is now a first-class part of the same test suite as your unit and feature tests, driven by Playwright, with real assertions, screenshots on failure, and no separate binary to babysit.

This tutorial converts an existing Laravel test suite to Pest 4 browser testing: installation, your first test, how it interacts with the database and factories, mobile and multi-browser runs, visual and accessibility assertions, and how to run it all in CI without flakiness.

Why browser tests at all when you have feature tests?

Laravel's HTTP feature tests are excellent and fast, but they exercise the request lifecycle, not the browser. They cannot tell you that a Livewire component re-renders after a wire:model change, that an Alpine dropdown opens, that a Stripe or Turnstile iframe loads, or that the "Save" button is actually clickable on a 375px viewport. Anything driven by JavaScript is invisible to $this->get('/dashboard').

The pragmatic split we use on client projects:

  • Unit tests for domain logic, calculations, and value objects.
  • Feature tests for routes, authorization, validation rules, jobs, and events — the bulk of the suite.
  • Browser tests for a thin layer of critical journeys: register, log in, checkout, the one report that everyone runs, and any screen where a JS bug would silently cost money.

Ten good browser tests beat two hundred brittle ones.

Prerequisites

  • Laravel 11, 12, or 13 on PHP 8.3+.
  • Pest 3 already in place (pestphp/pest). If you are still on PHPUnit, run Pest's PHPUnit migration first — it is mostly mechanical, and existing PHPUnit test classes keep running under Pest.
  • Node 20+ available locally and in CI (Playwright ships browser binaries through npm).

Step 1: Install Pest 4 and the browser plugin

composer require pestphp/pest:^4.0 --dev --with-all-dependencies
composer require pestphp/pest-plugin-browser --dev
npm install --save-dev playwright
npx playwright install --with-deps chromium

Two upgrade notes that catch people out on the Pest 3 to 4 hop:

  • pestphp/pest-plugin-laravel must be on a matching major; let Composer resolve it with --with-all-dependencies rather than pinning it.
  • Custom expectations registered in tests/Pest.php still work, but Pest 4 is stricter about expect() chains on null. Run the suite once before writing any browser tests so you fix pre-existing breakage separately.

Step 2: Tell Pest which tests are browser tests

Keep browser tests in their own directory so you can run them selectively. In tests/Pest.php:

pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\DatabaseTruncation::class)
    ->in('Browser');

pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature', 'Unit');

This is the single most important decision in the whole setup. Browser tests hit your application over HTTP in a separate process, so a RefreshDatabase transaction opened in the test process is invisible to the server process — your seeded user will not exist when the browser tries to log in. DatabaseTruncation commits, so it works. (If you prefer, a dedicated test database plus php artisan migrate:fresh in CI achieves the same thing.)

Step 3: Your first browser test

tests/Browser/LoginTest.php:

<?php

use App\Models\User;

it('logs a user in with valid credentials', function () {
    $user = User::factory()->create([
        'email' => 'ada@example.com',
        'password' => bcrypt('password'),
    ]);

    $page = visit('/login');

    $page->fill('input[name="email"]', $user->email)
        ->fill('input[name="password"]', 'password')
        ->click('button[type="submit"]')
        ->assertPathIs('/dashboard')
        ->assertSee($user->name)
        ->assertNoJavascriptErrors()
        ->assertNoConsoleLogs();
});

Run it:

./vendor/bin/pest --group=browser
# or simply
./vendor/bin/pest tests/Browser

visit() boots a real server, opens a Playwright page, and returns a fluent object. assertNoJavascriptErrors() and assertNoConsoleLogs() are the two assertions worth adding to almost every test: they turn "the page looked fine" into "the page had no uncaught exceptions", which catches a surprising number of production bugs for free.

Step 4: Smoke-test many pages in one test

The cheapest high-value test in the whole framework:

it('renders every public page without JavaScript errors', function () {
    $pages = visit(['/', '/pricing', '/docs', '/blog', '/contact']);

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

Five URLs, one test, and it will fail the day someone ships a broken bundle or a missing asset manifest. Add it before you write anything more ambitious.

Step 5: Livewire, Alpine, and waiting properly

Never use sleep(). Playwright auto-waits for elements to be actionable, and Pest exposes explicit waits for the cases it cannot infer:

it('filters the invoice table live', function () {
    $user = User::factory()->has(Invoice::factory()->count(3))->create();

    $page = visit('/invoices')->actingAs($user);

    $page->fill('[dusk="search"]', 'ACME')
        ->waitForText('ACME Corp')
        ->assertSee('ACME Corp')
        ->assertDontSee('Globex')
        ->click('[dusk="row-1"]')
        ->waitForUrl('/invoices/*')
        ->assertSee('Invoice detail');
});

Two habits that eliminate most flakiness:

  1. Select on stable hooks, not text or Tailwind classes. A dusk="search" or data-testid attribute survives redesigns; .bg-blue-500 > div:nth-child(2) does not.
  2. Assert on the state you expect, not on elapsed time. waitForText, waitForUrl, and assertMissing are deterministic; a two-second sleep is a race condition that passes on your laptop and fails on a loaded CI runner.

Step 6: Mobile, dark mode, and multiple browsers

Responsive bugs are the ones clients actually notice. Pest 4 makes device and colour-scheme runs a one-liner:

it('shows the mobile navigation drawer', function () {
    visit('/')
        ->on()->mobile()
        ->click('[dusk="nav-toggle"]')
        ->assertVisible('[dusk="mobile-nav"]')
        ->assertNoJavascriptErrors();
});

it('respects the dark colour scheme', function () {
    visit('/')->inDarkMode()->assertSee('Sign in');
});

You can also fan a single test across engines — ->on()->firefox(), ->on()->webkit() — after installing those browsers (npx playwright install firefox webkit). Do this for the checkout flow and the login flow; running the whole suite in three engines on every push is rarely worth the CI minutes.

Step 7: Visual regression and accessibility

Two assertions that pay for themselves on marketing-heavy sites:

it('matches the approved pricing layout', function () {
    visit('/pricing')->assertScreenshotMatches();
});

it('has no obvious accessibility violations', function () {
    visit('/pricing')->assertNoAccessibilityIssues();
});

The first stores a baseline image on the initial run and diffs against it afterwards — commit the baselines, and be disciplined about reviewing diffs rather than blindly re-approving them. The second runs an axe-style audit and fails on missing labels, poor contrast, and unlabelled controls. It is not a full WCAG audit, but it catches the regressions that creep in during a redesign.

Step 8: CI without the flake

A GitHub Actions job that works:

- uses: actions/setup-node@v4
  with:
    node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build          # browser tests need built assets, not the dev server
- run: php artisan migrate --force
  env:
    DB_DATABASE: testing
- run: ./vendor/bin/pest --parallel

Checklist for green, trustworthy runs:

  • Build your assets. Missing a npm run build step is the number one cause of "works locally, fails in CI": Vite's dev server is not running, so @vite throws.
  • Use a real database service (MySQL or Postgres container), not SQLite in memory — the separate server process needs to see the same data.
  • Cache Playwright browsers with actions/cache on ~/.cache/ms-playwright to keep the job under a minute of setup.
  • Upload failure artifacts. Pest writes screenshots on failure; publishing that directory turns a red build into a two-second diagnosis.
  • Split the suite. Run unit and feature tests on every push; run browser tests on pull requests and on main. Fast feedback stays fast.

Migrating from Dusk incrementally

You do not need a big-bang rewrite. Dusk and Pest browser tests can coexist: leave tests/Browser for Pest and keep Dusk's suite under its own directory until it is empty. Translation is mostly mechanical — $browser->visit() becomes visit(), type() becomes fill(), waitForText() keeps its name, and assertSee() is unchanged. Port the flows you actually trust first, delete the ones that have been marked skipped for two years, and remove laravel/dusk and its ChromeDriver step once nothing references it.

What "done" looks like

For a typical client application we aim for: a multi-page smoke test, a login and registration journey, one authenticated write path end-to-end, the primary conversion flow, and a mobile check on the nav and the main form. That is five to eight browser tests, running in under two minutes, catching the class of bug that feature tests structurally cannot see.

If you want a test suite like this retrofitted onto an existing Laravel application — or an upgrade to a version that supports Pest 4 in the first place — get in touch, or read more about our prototype testing and Laravel version upgrade work.