Browser Testing with Dusk

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with laravel/dusk 8.7.0 and pestphp/pest-plugin-browser 4.3.1. The HTTP tests on Testing check what the server sends. A browser test also runs the JavaScript: Livewire’s round trips, Alpine, Inertia pages and anything a CSP or a bundling mistake can break. Browser testing in general, with Playwright, Cypress and Selenium compared, is on E2E Testing in Real Browsers. This page drives Bookshelf’s Livewire shelf page from Livewire in headless Chromium.

Installing Dusk and ChromeDriver

composer require --dev laravel/dusk and php artisan dusk:install create tests/DuskTestCase.php and tests/Browser/. Dusk talks to Chrome through ChromeDriver, and the two must have the same major version. php artisan dusk:chrome-driver --detect downloads the matching driver. This environment had Chromium 141 (from Playwright) and a ChromeDriver 147 on the PATH, which refuses any other version, and dusk:install could not download a driver here. npx @puppeteer/browsers install chromedriver@141.0.7390.37 fetched the matching driver into the place Dusk looks for it:

$ /opt/pw-browsers/chromium-1194/chrome-linux/chrome --version
Chromium 141.0.7390.37
$ npx @puppeteer/browsers install chromedriver@141.0.7390.37
chromedriver@141.0.7390.37 …/chromedriver/linux-141.0.7390.37/chromedriver-linux64/chromedriver
$ cp …/chromedriver vendor/laravel/dusk/bin/chromedriver-linux

DuskTestCase::driver() builds the Chrome options. Two additions were needed here: the Chromium binary, and --no-sandbox because the container runs as root:

<?php
// tests/DuskTestCase.php, in driver()
$options = (new ChromeOptions)->addArguments(collect([
    $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
    '--disable-search-engine-choice-screen',
    '--disable-smooth-scrolling',
    '--no-sandbox',                                        // running as root in a container
])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
    return $items->merge(['--disable-gpu', '--headless=new']);
})->all())->setBinary(env('DUSK_CHROME_BINARY', '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'));

A page object and a test

Dusk tests visit the running application (APP_URL, here php artisan serve on port 8000). dusk="…" attributes give stable selectors, written as @name, that do not change when the markup or CSS does. A page object keeps the URL, the selectors and repeated steps in one class:

<?php
// tests/Browser/Pages/ShelfPage.php
namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;
use Laravel\Dusk\Page;

class ShelfPage extends Page
{
    public function __construct(private int $bookId = 1) {}

    public function url(): string
    {
        return "/live/shelf/{$this->bookId}";
    }

    public function assert(Browser $browser): void
    {
        $browser->assertPathIs($this->url())->waitFor('@results');
    }

    public function elements(): array
    {
        return ['@search' => '[dusk="search"]', '@results' => '[dusk="results"]', '@clear' => '[dusk="clear"]'];
    }

    public function searchFor(Browser $browser, string $term): void
    {
        $browser->type('@search', $term)->waitUntilMissingText('Loading')->pause(400);   // past the 300 ms debounce
    }
}
<?php
// tests/Browser/BookSearchTest.php
use App\Models\{Author, Book};
use Laravel\Dusk\Browser;
use Tests\Browser\Pages\ShelfPage;

beforeEach(function () {
    Book::factory()->for(Author::factory()->create(['name' => 'Octavia E. Butler']))->create(['id' => 1, 'title' => 'Kindred']);
    Book::factory()->for(Author::factory()->create(['name' => 'Ursula K. Le Guin']))->create(['title' => 'The Dispossessed']);
});

test('searching filters the shelf as you type', function () {
    $this->browse(function (Browser $browser) {
        $browser->visit(new ShelfPage)
            ->assertSeeIn('@results', 'Kindred')
            ->searchFor('dispo')
            ->waitForTextIn('@results', 'The Dispossessed')
            ->assertDontSeeIn('@results', 'Kindred')
            ->assertQueryStringHas('q', 'dispo')                 // #[Url(as: 'q')] on the property
            ->click('@clear')
            ->waitForTextIn('@results', 'Kindred')
            ->assertInputValue('@search', '')
            ->screenshot('shelf-cleared');
    });
});

test('no match shows the empty state', function () {
    $this->browse(function (Browser $browser) {
        $browser->visit(new ShelfPage)
            ->searchFor('zzz')
            ->waitForTextIn('@results', 'No match for "zzz".');
    });
});

waitFor… methods poll for up to five seconds. A Livewire update is an HTTP request, so asserting right after type() or click() checks the page before the response has arrived. The page answered in a few milliseconds locally, but CI machines are slower, and fixed pause() calls are only acceptable for known delays such as the debounce.

What the first runs found

The final run passed:

$ php artisan dusk
   PASS  Tests\Browser\BookSearchTest
  ✓ searching filters the shelf as you type                              2.31s
  ✓ no match shows the empty state                                       1.07s
  Tests:    2 passed (11 assertions)

It took four attempts, and each failure taught something:

  1. The development data was in the way. The first version had no beforeEach and ran against the development database. Kindred was not on the first screen, because the list showed faker titles alphabetically. The automatic failure screenshot in tests/Browser/screenshots showed it at once. Dusk tests need their own data.

  2. Their own data means their own database. Dusk runs against the server’s database, not an in-memory one, so DatabaseMigrations or DatabaseTruncation would wipe whatever the server uses. A .env.dusk.local file with DB_DATABASE=…/database/dusk.sqlite fixed that. php artisan dusk swaps it in for .env during the run and restores the original afterwards, so the server used the Dusk database. The development database still had its 27 books after the runs.

  3. DatabaseMigrations rolls back after every test, and two of Bookshelf’s migrations could not be rolled back on SQLite: SQLSTATE[HY000]: General error: 1 error in index users_role_index after drop column: no such column: role. SQLite cannot drop a column that is still in an index. Their down() methods now call dropIndex(['role']) and dropUnique(['api_key_hash']) first, and php artisan migrate:reset on a fresh database ran with no failures. Nothing had run them backwards before.

  4. The Clear button did nothing. Typing worked, but after click('@clear') the input still held dispo. The browser’s console log, read with $browser->driver->manage()->getLog('browser'), showed why:

    Refused to apply inline style because it violates the following Content Security Policy directive: "default-src 'self'". …
    Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'self'". …
    Alpine Expression Error: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script …

    The AddSecurityHeaders middleware from Middleware sets default-src 'self'. Alpine compiles wire:click expressions with new Function(), which that policy forbids. wire:model worked because it needs no expression. 'csp_safe' => true in config/livewire.php switched to Alpine’s CSP build, and the button worked. The server-side Livewire::test() tests had passed all along, because no browser and no CSP were involved.

The final screenshot showed one more effect of the same policy: the Uploading… indicator of the review form was always visible. Livewire hides wire:loading elements with an inline <style>, which default-src 'self' also blocks. A policy with a nonce for Livewire’s styles and scripts (Vite and Frontend Assets) fixes that. Only a browser shows it.

The Pest browser plugin

pestphp/pest-plugin-browser (4.3 for Pest 4, 5.x for Pest 5 on PHP 8.4) drives Playwright instead of ChromeDriver. It starts the application inside the test process, so tests use the normal testing environment and RefreshDatabase, with no running server and no .env swap. npm install -D playwright provides the driver, and the Chromium already installed for Playwright was used:

<?php
// tests/BrowserPest/ShelfTest.php
use App\Models\{Author, Book};
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(Tests\TestCase::class, RefreshDatabase::class);

test('searching and clearing in a real browser', function () {
    Book::factory()->for(Author::factory())->create(['id' => 1, 'title' => 'Kindred']);
    Book::factory()->for(Author::factory())->create(['title' => 'The Dispossessed']);

    visit('/live/shelf/1')                                       // @name means data-testid or data-test here, not dusk
        ->assertSee('Kindred')
        ->type('[dusk="search"]', 'dispo')
        ->wait(0.6)                                              // past the 300 ms debounce
        ->assertSee('The Dispossessed')
        ->assertDontSeeIn('[dusk="results"]', 'Kindred')
        ->click('[dusk="clear"]')
        ->assertSeeIn('[dusk="results"]', 'Kindred')
        ->assertNoJavaScriptErrors();
});
$ ./vendor/bin/pest tests/BrowserPest
   PASS  Tests\BrowserPest\ShelfTest
  ✓ searching and clearing in a real browser                             1.83s

The first version reused the @search selectors and timed out after 5 seconds. In the plugin, @search means [data-testid=search] or [data-test=search], while in Dusk it means dusk="search". Elements that both tools must find need both attributes, or CSS selectors as here. assertNoJavaScriptErrors() passed, although the CSP still logged refused inline styles. A CSP violation is a console message, not a JavaScript error.

The plugin’s in-process server makes setup simpler and runs faster. Dusk’s separate server is closer to production, with a real web server, real sessions and real queues, and its API has been stable since Laravel 5.4.

In CI

A CI job for Dusk installs Chrome and the matching driver, starts the application (php artisan serve &), and runs php artisan dusk. It uploads tests/Browser/screenshots and tests/Browser/console as artifacts when a test fails, because the screenshot is usually the fastest diagnosis. Browser tests are slow and fragile compared to HTTP tests, so they belong to the few paths that only a browser can verify: the JavaScript-driven parts, the CSP, and log-in.

Where older books and tutorials are outdated

  • Dusk tests with DatabaseMigrations against the development database erase local data. Use .env.dusk.local with a separate database, or DatabaseTruncation.

  • --headless and --disable-gpu as the only Chrome flags became --headless=new. Containers also need --no-sandbox when running as root.

  • Selenium Server is not needed. Dusk starts ChromeDriver itself.

  • Asserting immediately after a click broke as soon as front ends became asynchronous. waitFor… replaces it.