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'));
Source: Laravel — Using Other Browsers
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
}
}
Source: Laravel — Pages
<?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".');
});
});
Source: Laravel — Waiting for Elements
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)
Source: Laravel — Running Tests
It took four attempts, and each failure taught something:
-
The development data was in the way. The first version had no
beforeEachand ran against the development database.Kindredwas not on the first screen, because the list showed faker titles alphabetically. The automatic failure screenshot intests/Browser/screenshotsshowed it at once. Dusk tests need their own data. -
Their own data means their own database. Dusk runs against the server’s database, not an in-memory one, so
DatabaseMigrationsorDatabaseTruncationwould wipe whatever the server uses. A.env.dusk.localfile withDB_DATABASE=…/database/dusk.sqlitefixed that.php artisan duskswaps it in for.envduring 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. -
DatabaseMigrationsrolls 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. Theirdown()methods now calldropIndex(['role'])anddropUnique(['api_key_hash'])first, andphp artisan migrate:reseton a fresh database ran with no failures. Nothing had run them backwards before. -
The Clear button did nothing. Typing worked, but after
click('@clear')the input still helddispo. 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 …Source: Livewire — CSP
The
AddSecurityHeadersmiddleware from Middleware setsdefault-src 'self'. Alpine compileswire:clickexpressions withnew Function(), which that policy forbids.wire:modelworked because it needs no expression.'csp_safe' => trueinconfig/livewire.phpswitched to Alpine’s CSP build, and the button worked. The server-sideLivewire::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();
});
Source: Pest — Browser Testing
$ ./vendor/bin/pest tests/BrowserPest
PASS Tests\BrowserPest\ShelfTest
✓ searching and clearing in a real browser 1.83s
Source: Pest — Browser Testing
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
DatabaseMigrationsagainst the development database erase local data. Use.env.dusk.localwith a separate database, orDatabaseTruncation. -
--headlessand--disable-gpuas the only Chrome flags became--headless=new. Containers also need--no-sandboxwhen 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.