Pest 4 Browser Testing with Playwright — A Dusk Replacement That Actually Sticks

Pest 4 browser testing replaces Laravel Dusk with Playwright — auto-waits, --parallel, --shard, and a 90-second CI run instead of 12 minutes. Full setup.

Steven Richardson
Steven Richardson
· 9 min read

I had a Laravel project last year with 80-odd Dusk tests. Each CI run took twelve minutes, ChromeDriver pinned us to a specific Chrome major version, and at least one test was flaky every week. I'd rewritten the same waitFor blocks so many times I had snippets for them. Then Pest 4 shipped its browser plugin on top of Playwright, I ported those 80 tests over a weekend, and the same suite now runs on five sharded GitHub Actions runners in about 90 seconds.

This is the walk-through I wish I'd had on day one — install, write a real test, log a user in, run it parallel and sharded in CI, and the gotchas you'll hit on the way.

Install pestphp/pest-plugin-browser via Composer#

Add the browser plugin alongside Pest itself. The plugin is a dev dependency — you do not want it autoloaded in production. If you're still on Pest 3, bump to 4 first; the browser plugin requires it, and the upgrade is mostly painless on top of an existing Pest architecture testing setup.

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

That gives you the PHP side. You still need a JavaScript runtime so Playwright can drive a real browser process, which is the next step.

Run npx playwright install to fetch browsers#

Pest's plugin shells out to Playwright, so you need the Playwright Node package and the browser binaries it manages. Run both commands once after the Composer install — the second download is a few hundred megabytes but it's a one-off per machine.

npm install playwright@latest
npx playwright install

On Linux servers (CI included) you'll usually want --with-deps so Playwright pulls the apt packages Chromium needs. On macOS with Herd it's not required.

npx playwright install --with-deps

Add node_modules to your .gitignore if it isn't already, and don't commit the browser cache — Playwright pulls binaries on demand on every fresh checkout.

Scaffold your first browser test with visit()#

Generate a test the same way you'd generate any Pest test, then use the new visit() helper instead of the old Dusk $browser->visit() chain. The visit() function returns a page object you can chain assertions and interactions on.

php artisan make:test --pest BrowserSmokeTest

Drop this in tests/Feature/BrowserSmokeTest.php:

<?php

it('renders the welcome page without errors', function () {
    $page = visit('/');

    $page->assertSee('Laravel')
         ->assertNoJavaScriptErrors()
         ->assertNoConsoleLogs();
});

That third and fourth assertion are the small thing I miss most when I'm forced back into Dusk. Every visit can fail the test if the page logged a JS error or wrote anything unexpected to the console — silent frontend regressions become loud CI failures.

For an authenticated flow, combine actingAs() with visit(). RefreshDatabase still applies because the browser hits your app over HTTP from inside the same process group:

<?php

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

uses(RefreshDatabase::class);

it('lets a logged-in user reach the dashboard', function () {
    $user = User::factory()->create();

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

    $page->assertSee("Welcome back, {$user->name}")
         ->click('Settings')
         ->assertPathIs('/settings');
});

You can also script the login form end-to-end if you actually want to test the form itself:

<?php

it('logs a user in through the form', function () {
    User::factory()->create([
        'email' => '[email protected]',
        'password' => 'password',
    ]);

    visit('/login')
        ->fill('email', '[email protected]')
        ->fill('password', 'password')
        ->click('Submit')
        ->assertPathIs('/dashboard')
        ->assertSee('Dashboard');

    $this->assertAuthenticated();
});

Use auto-waits and assertions for stability#

This is the part Dusk veterans need to internalise. Every Playwright action — click(), fill(), assertSee() — auto-waits for the element to be attached, visible, stable, and able to receive the event. You do not write waitFor everywhere any more, and the test fails fast with a useful message instead of a timeout if the selector never resolves.

A typical flaky Dusk pattern looked like this:

// Old Dusk way — explicit waits everywhere
$browser->visit('/orders')
        ->waitForText('Recent Orders')
        ->click('@new-order-button')
        ->waitFor('#order-form', 5)
        ->type('@product-name', 'Widget')
        ->waitForText('Saved');

The Pest 4 equivalent drops every wait. Auto-wait handles it:

<?php

it('creates a new order', function () {
    $user = User::factory()->create();

    actingAs($user)->visit('/orders')
        ->assertSee('Recent Orders')
        ->click('New Order')
        ->fill('product_name', 'Widget')
        ->click('Save')
        ->assertSee('Saved');
});

Default action timeout is 5 seconds, which is enough for nearly everything. If you have a known-slow page (a heavy report, a third-party iframe), bump the timeout per test:

visit('/reports/quarterly')->timeout(15000)->assertSee('Q1 2026');

A few other things worth knowing while you're writing:

  • --debug runs the test headed and pauses so you can step through it
  • --headed just shows the browser without pausing
  • ->on()->mobile() and ->on()->iPhone14Pro() simulate device viewport, user agent, and touch behaviour
  • ->firefox() and ->safari() switch browser engines on a per-test basis (or use --browser firefox globally)
  • assertScreenshotMatches() ships visual regression testing — first run captures the baseline, subsequent runs diff against it

Run the suite with --parallel and --shard in CI#

Locally, run the suite with --parallel and you'll see a multi-process speedup straight away:

./vendor/bin/pest --parallel

In CI, you want to combine --parallel (workers per machine) with --shard (split tests across machines). Pest's --shard=N/M flag splits the suite into M groups and runs the Nth, which maps cleanly to a GitHub Actions matrix.

The first time you set this up, generate the timing data with --update-shards so Pest can do time-balanced distribution rather than naive bucketing:

./vendor/bin/pest --parallel --update-shards

That writes tests/.pest/shards.json. Commit it to the repo so CI uses it. Without timings Pest falls back to simple modulo distribution, which means one runner inevitably gets all the slow browser tests and finishes ten minutes after everyone else.

Here's the GitHub Actions workflow I use. It pairs nicely with a GitHub Actions matrix for PHP × database combinations if you also need to test multiple PHP versions:

name: Browser Tests

on:
  pull_request:
  push:
    branches: [main, develop]

jobs:
  browser:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5]
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none

      - name: Install Composer dependencies
        run: composer install --no-progress --prefer-dist --optimize-autoloader

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install JS dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Prepare Laravel environment
        run: |
          cp .env.ci .env
          php artisan key:generate
          php artisan migrate --force

      - name: Run sharded Pest browser tests
        run: ./vendor/bin/pest --parallel --shard=${{ matrix.shard }}/5

fail-fast: false is deliberate — if shard 3 fails, you want shards 1, 2, 4, 5 to keep running so you see every failure in one PR cycle, not just the first. This is the same reasoning behind running zero-downtime deployments through GitHub Actions and Forge: get the full failure picture before you act.

Gotchas and Edge Cases#

A few things will bite you on the first migration.

The browser doesn't share PHP state. When you call actingAs($user), the User exists in the test's PHP session, but the browser is a separate process hitting your app over HTTP. Pest wires the session cookie through automatically — you don't need to do anything extra — but if you start spawning Artisan commands or queue workers from inside the test, they run in their own process and won't see uncommitted database state. Stick to RefreshDatabase and synchronous queue drivers (QUEUE_CONNECTION=sync in .env.testing) for browser tests.

Selectors are looser than Dusk. click('Sign In') matches by visible text, label, or aria-label. That's friendlier than Dusk's @dusk-id convention but it means changing a button's label silently breaks the test. For UI you genuinely want pinned to a stable hook, use a CSS or data-testid selector explicitly: click('[data-testid=signin-button]').

Headless ≠ headed. The default user agent contains HeadlessChrome/..., which trips bot detection on some third-party widgets (Cloudflare Turnstile in particular). If a test fails only in headless, run it with --headed to confirm, then either spoof the user agent or swap the third-party for a fake in the test environment.

shards.json goes stale. Add or delete a meaningful chunk of tests and the timings drift. If a shard suddenly takes twice as long as the others, regenerate with ./vendor/bin/pest --parallel --update-shards and commit the new file. If the file gets corrupted Pest stops with a clear error telling you to delete or regenerate it.

Playwright binaries are not in your composer.lock. New developers cloning the repo will run composer install and wonder why browser tests fail with "browser not found". Add npx playwright install to your post-install script or your project's Makefile so it runs as part of streamlined Laravel onboarding.

Wrapping Up#

The honest pitch: Pest 4 browser testing is what Dusk should have been. Faster, less flaky, parallel-friendly, and built on a real browser-automation library that gets ongoing investment. If you have an existing Dusk suite, the porting effort is roughly "find/replace $browser-> to chained visit(), delete every waitFor, run it". Most tests work first try.

Once you have it running, go pair it with the rest of your quality stack — PHPStan level 10 catches static violations, and the broader Laravel developer toolchain for 2026 lays out where browser tests fit alongside Pint, Rector, and architecture rules.

FAQ#

How do I install the Pest 4 browser plugin?

Run composer require pestphp/pest pestphp/pest-plugin-browser --dev to add the plugin as a dev dependency, then npm install playwright@latest followed by npx playwright install to download the browser binaries. On Linux or in CI add --with-deps to that last command so Playwright also installs the system packages Chromium needs.

Is Pest 4 browser testing a Dusk replacement?

For most Laravel applications, yes. Pest 4's browser plugin runs faster than Dusk, supports --parallel and --shard natively, and replaces ChromeDriver with Playwright so you no longer have to chase Chrome version updates. Dusk still has a few niche features around mobile emulation patterns and Selenium grid integration, but for end-to-end browser tests against a Laravel app the Pest plugin covers virtually everything Dusk did with less code.

Can I run Pest browser tests in parallel?

Yes — pass --parallel to spawn worker processes on a single machine, and --shard=N/M to split tests across multiple CI runners. The two flags compose, so a typical CI pipeline runs ./vendor/bin/pest --parallel --shard=${{ matrix.shard }}/5 to get both forms of parallelism at once. Generate tests/.pest/shards.json with --update-shards first so each shard is balanced by actual run time rather than count.

Does Pest 4 browser testing work in CI?

It works in any CI that can run Node and PHP. The setup is to install Composer dependencies, install your package.json, run npx playwright install --with-deps, then run ./vendor/bin/pest. GitHub Actions, GitLab CI, and Bitbucket Pipelines all work — the --with-deps flag is what most people miss because Playwright needs apt packages on Linux runners that don't come pre-installed.

How do I share state between Pest browser tests?

You don't, by design. Use RefreshDatabase so each test gets a clean slate, and use Pest's beforeEach() hooks or actingAs($user) inside the test to set up the state that test needs. If you need to share fixtures across tests, build them in a Pest dataset or factory state — never lean on browser cookies or session state surviving between tests, because the browser context is reset on each visit() call.

Do I still need ChromeDriver with Pest 4?

No. Playwright bundles and manages its own browser binaries (Chromium, Firefox, WebKit) and updates them through npx playwright install. ChromeDriver is gone from the workflow entirely, which removes the most common source of "works on my machine" failures with Dusk where developers had different Chrome major versions installed locally.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.