/ sagar_pansuriya
§ Laravel

Pest v4 Browser Testing: Real Browser Tests for Your Laravel Frontend

Illustration of a Pest test file driving a browser window, with passing checks for clicks, JavaScript errors, mobile and dark mode

You've probably shipped this bug. Every feature test is green, the controller returns 200, the Blade view contains the right text. Then someone opens the page and the dropdown doesn't open, because an Alpine component throws on load and takes the rest of the page's JavaScript with it. Your HTTP tests never ran a line of JavaScript, so they had no way of knowing.

For years, the answer in Laravel was Dusk, or a separate Playwright or Cypress suite in JavaScript. Both work, but they live apart from the rest of your tests. Pest v4 changes that with first-party browser testing: real browser tests, powered by Playwright, written in the same Pest syntax as your unit and feature tests.

This post walks through setting it up, the handful of methods you'll use constantly, testing Livewire and Alpine behaviour, smoke testing every page in one line, device and dark mode emulation, screenshots, and running it in CI.

Installing the browser plugin

Browser testing ships as a Pest plugin, and it uses Playwright under the hood, so you install both the PHP package and the Playwright browsers:

composer require pestphp/pest-plugin-browser --dev

npm install playwright@latest
npx playwright install

That's it for setup. Browser tests are ordinary Pest tests, so the usual place is a tests/Browser directory, configured in tests/Pest.php like your feature tests, so they get the Laravel test case and a fresh database:

// tests/Pest.php
pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature', 'Browser');

One thing to remember: the browser loads your real frontend assets. Run npm run build (or have the Vite dev server running) before the suite, or you'll be testing a page with no CSS or JavaScript.

Your first browser test

Everything starts with visit(), which opens a URL in the browser and returns a page you can interact with and assert against:

// tests/Browser/HomePageTest.php
it('shows the home page', function () {
    $page = visit('/');

    $page->assertSee('Build faster with Laravel')
        ->assertNoJavascriptErrors();
});

Because it's a normal Pest test, factories, actingAs() and fakes all work. A login flow reads almost like a user story:

it('lets a user sign in', function () {
    $user = User::factory()->create(['email' => 'sagar@example.com']);

    visit('/login')
        ->type('email', 'sagar@example.com')
        ->type('password', 'password')
        ->press('Log in')
        ->assertPathIs('/dashboard')
        ->assertSee('Welcome back');
});

The core vocabulary is small, and you'll use these for most tests:

MethodWhat it does
visit($url)Open a page (or several, with an array)
click($text)Click a link, button or element
type($field, $value)Type into an input, by name or selector
press($button)Press a button by its text
assertSee($text)Text is on the page
assertPathIs($path)The browser ended up on this path
assertNoJavascriptErrors()No uncaught JS errors on the page
assertNoSmoke()No JS errors and no console logs

Pest's documentation at pestphp.com/docs/browser-testing lists the full set of interactions and assertions. Start with these and reach for the rest when you need them.

Diagram of a Pest browser test: the PHP test drives Playwright, which controls a real browser loading your Laravel app, and assertions run against the rendered page

Testing Livewire interactions

Livewire already has excellent component tests with Livewire::test(). They're fast and you should keep writing them. What they can't tell you is whether the wiring in the browser works: that wire:model is on the right input, that the request actually fires, and that the DOM updates. A browser test covers exactly that gap.

it('filters products as you type', function () {
    Product::factory()->create(['name' => 'Walnut Desk']);
    Product::factory()->create(['name' => 'Oak Chair']);

    visit('/products')
        ->assertSee('Walnut Desk')
        ->assertSee('Oak Chair')
        ->type('search', 'walnut')
        ->assertSee('Walnut Desk')
        ->assertSee('1 result')
        ->assertNoJavascriptErrors();
});

Playwright waits for elements to be ready before it interacts with them, which removes most of the sleep() calls old browser suites were full of. If a debounced wire:model.live ever makes a test flaky, assert on text that only appears after the update (like the result count above) rather than on something that was already there.

Testing Alpine behaviour

Alpine components are pure client-side state, so feature tests can't touch them at all. In a browser test they're just UI:

it('opens the account menu', function () {
    $this->actingAs(User::factory()->create());

    visit('/dashboard')
        ->click('Account')
        ->assertSee('Profile settings')
        ->click('Profile settings')
        ->assertPathIs('/settings/profile');
});

The most valuable line in most Alpine tests is assertNoJavascriptErrors(). A typo in an x-data expression or a missing Alpine.data() registration throws in the console, and this assertion turns that into a failing test.

Smoke testing every page in one test

This is my favourite feature. Pass an array of URLs to visit() and assert that none of them have JavaScript errors or console logs:

it('has no smoke on public pages', function () {
    $pages = visit([
        '/',
        '/about',
        '/pricing',
        '/blog',
        '/contact',
    ]);

    $pages->assertNoSmoke();
});

If you only write one browser test, write this one. It catches a broken Vite build, a third-party script throwing on a page nobody checked, and the stray console.log someone left in. You can generate the list from your routes or sitemap, but a hand-picked list of your most important templates is a great start.

If you want to separate the two concerns, assertNoJavascriptErrors() and assertNoConsoleLogs() also work on their own.

Devices and dark mode

Responsive bugs are the classic frontend regression: the navigation works on desktop, but the mobile menu button is hidden behind a banner. Pest can emulate a mobile viewport and a dark colour scheme per test:

it('opens the mobile navigation', function () {
    visit('/')
        ->on()->mobile()
        ->click('Menu')
        ->assertSee('Pricing')
        ->assertNoJavascriptErrors();
});

it('renders the pricing page in dark mode', function () {
    visit('/pricing')
        ->inDarkMode()
        ->assertSee('Pricing')
        ->assertNoJavascriptErrors();
});

Dark mode emulation sets the browser's prefers-color-scheme, so it exercises Tailwind's default dark: variant. If your site toggles dark mode with a class instead, click your toggle in the test.

Screenshots and visual regression

Text assertions can't tell you that a card's padding collapsed or a button turned white on white. For key pages, Pest can compare a screenshot against a stored baseline:

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

The first run saves the baseline, and later runs fail if the page changes. A few things make this workable in practice:

  • Keep it to a handful of stable, high-value pages. Visual tests on every page become noise quickly.
  • Freeze dynamic content with factories and fixed data. Dates, random avatars and rotating testimonials cause false failures.
  • Generate baselines in the same environment that runs CI, because fonts and rendering differ between operating systems.

For debugging, screenshot() saves an image of the current state, and running Pest with --headed opens a visible browser so you can watch the test run.

Running browser tests in CI

In CI you need PHP, Node, the Playwright browsers, and built assets. A GitHub Actions job looks something like this:

# .github/workflows/tests.yml
jobs:
  browser:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: composer install --no-interaction --prefer-dist
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build
      - run: cp .env.example .env && php artisan key:generate
      - run: ./vendor/bin/pest --parallel

The --with-deps flag installs the system libraries the browsers need on a fresh Linux runner. Browser tests are slower than feature tests, so --parallel helps, and many teams run the browser suite as a separate job so a quick feature-test failure reports first.

What to test in the browser (and what not to)

Browser tests are slower and a bit more brittle than feature tests, so be deliberate:

  • Do: a smoke test across key pages, critical user journeys (sign up, checkout, main form), JavaScript-driven UI (Alpine, Livewire wiring), and mobile navigation.
  • Don't: validation rules, authorisation logic, or every edge case of a calculation. Feature and Livewire component tests do that faster.

A checklist to start with

  1. Install pestphp/pest-plugin-browser and the Playwright browsers.
  2. Add a tests/Browser directory to tests/Pest.php.
  3. Write one assertNoSmoke() test covering your main templates.
  4. Add browser tests for your two or three most important user journeys.
  5. Cover the mobile navigation with on()->mobile().
  6. Add screenshot baselines for a few stable, high-value pages.
  7. Build assets and install browsers in CI, and run with --parallel.

The dropdown bug from the start of this post would have failed that very first smoke test. That's the real value here: not a huge new test suite, but a few browser tests that catch the bugs your HTTP tests were never able to see.

Discussion