laravel/dusk
Laravel Dusk provides an expressive browser automation and testing API for Laravel apps, using a standalone Chromedriver with no JDK or Selenium required by default, while supporting other Selenium drivers.
Install and bootstrap (5 minutes)
composer require laravel/dusk --dev
php artisan dusk:install # publishes tests/Browser, DuskTestCase, phpunit.dusk.xml, .env.dusk
php artisan dusk:chrome-driver --detect # matches driver to your installed Chrome
cp .env.dusk.example .env.dusk.local 2>/dev/null || true
dusk:install scaffolds:
| Path | Purpose |
|---|---|
tests/DuskTestCase.php |
The only file you normally edit — driver config, Chrome flags, headless toggles |
tests/Browser/ExampleTest.php |
Starter test; delete and replace |
tests/Browser/Pages/ |
Page Objects (Home, Login) |
phpunit.dusk.xml |
Separate PHPUnit suite so Dusk never runs under php artisan test |
.env.dusk.local |
Test env: point APP_URL at a real host/port, use a throwaway DB |
Where to look first
tests/DuskTestCase.php → driver(). This is where headless mode, window size, --no-sandbox, DUSK_DRIVER_URL, and download paths are decided.tests/Browser/Pages/Home.php → the assert() method doubles as a per-page smoke test.vendor/laravel/dusk/src/Browser.php → the authoritative API list; the Concerns/ traits (InteractsWithElements, MakesAssertions, WaitsForElements) tell you what is actually supported better than any blog post.First realistic use case: the login/render smoke test
// tests/Browser/LoginTest.php
public function test_user_can_log_in(): void
{
$user = User::factory()->create(['password' => bcrypt('secret')]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit('/login')
->type('email', $user->email)
->type('password', 'secret')
->press('Log in')
->waitForLocation('/dashboard')
->assertAuthenticatedAs($user)
->assertSee('Dashboard');
});
}
Run it with php artisan dusk (all), php artisan dusk --filter=LoginTest (one), php artisan dusk --browse (watch it happen), php artisan dusk:fails (re-run last failures). On failure, look in tests/Browser/screenshots/ and tests/Browser/console/ before touching the code.
Aim the first week at breadth, not depth: one test per critical journey (register, login, checkout, admin CRUD) plus one per historically broken page. Dusk's ROI comes from catching "the page 500s / the button vanished / the JS broke", not from re-testing validation rules your feature tests already cover.
Page Objects + @dusk selectors — the pattern everything else builds on
// app markup: <button dusk="checkout-submit">
class Checkout extends Page
{
public function url(): string { return '/checkout'; }
public function elements(): array
{
return [
'@submit' => '[dusk=checkout-submit]',
'@coupon' => '[dusk=coupon-field]',
'@total' => '[dusk=cart-total]',
];
}
public function applyCoupon(Browser $browser, string $code): self
{
$browser->type('@coupon', $code)
->clickWhen('@apply-coupon') // waits for visible+enabled, no sleep()
->waitForTextIn('@total', '£0.00');
return $this;
}
}
Rules of thumb that keep the suite alive:
dusk="..." attribute. Freeze the contract in markup; refactoring CSS classes then never breaks tests.elements(), so @submit is a stable alias, not a CSS path duplicated in 40 tests.) inside selector names — resolution has historically choked on closing parentheses (fixed in 8.3.6, but avoid the pattern).data-testid (or any other attribute), register it as the custom Dusk selector once and keep using @name everywhere.Composable flows: traits, within(), and components
// tests/Browser/Concerns/LogsIn.php
trait LogsIn { protected function loginAs(Browser $b, User $u): Browser { return $b->loginAs($u); } }
$browser->within('@cart-row-1', fn (Browser $row) => $row->assertSee('Sneakers')->click('@remove'));
$browser->component(NavBar::class)->assertSee('Cart (1)'); // scoped sub-browser
component() (v7.13+) is the cleanest way to model repeatable widgets — nav bars, modals, tables — that appear on many pages. Define the component's root selector once; child selectors are looked up inside it.
Wait strategy (the single biggest flake killer)
| Need | Method |
|---|---|
| Element appears | waitFor('@selector') / waitUntilMissing() |
| Text changes | waitForTextIn('@selector', 'Saved') |
| Navigation | waitForLocation('/orders'), waitForRoute('orders.show') |
| Element becomes usable | clickWhen('@selector') — waits for visible + enabled before clicking |
| Arbitrary JS condition | waitUntil(fn ($b) => $b->script('return window.appReady === true'), 10) |
| Async assertion retries | waitForLink, waitForEnabled, waitForInput variants |
Never pause() in committed tests — it's a debugging tool only. If you find yourself needing one, the app is not signalling readiness; add a dusk attribute that appears only when the state is settled.
Assertions beyond assertSee
assertPathEndsWith() / assertPathContains() (8.2.0) for URL routing checks; assertCount() (8.3.0) for list lengths; assertAttributeMissing() (7.12.2); assertAttributeDoesntContain(); assertAuthenticatedAs(), assertGuest(); assertVue() / assertVueContains() for Vue state (requires the Dusk JS bindings and the dev Vue build — treat as a bonus, not a foundation). For case-insensitive checks there is an explicit flag — don't regex around it.
Multi-user / multi-browser flows
$this->browse(function (Browser $admin, Browser $customer) use ($adminUser) {
$admin->loginAs($adminUser)->visit('/admin/orders');
$customer->visit('/orders/latest')->assertSee('Pending');
});
Extra Browser parameters get fresh browser instances sharing the same driver — the canonical way to test chat, notifications, and permission boundaries.
Database & environment strategy
use Illuminate\Foundation\Testing\DatabaseTruncation;
class CheckoutTest extends DuskTestCase
{
use DatabaseTruncation; // far faster than DatabaseMigrations on a mature schema
}
php artisan migrate --force on the Dusk database), then truncate per test..env.dusk.local must use a separate database from dev — Dusk does not sandbox you.QUEUE_CONNECTION=sync) and mail (MAIL_MAILER=log or array) should be switched in that env file; anything async becomes an implicit race.CI wiring
- run: php artisan dusk:chrome-driver --detect
- run: php artisan serve --port=8000 & # or let Dusk start it
- run: php artisan dusk
env:
APP_URL: http://127.0.0.1:8000
Keep --headless=new (the default since 7.7.0). On ARM runners or when you need Selenium features, set DUSK_DRIVER_URL=http://selenium:4444/wd/hub and Dusk talks to the grid instead of spawning Chromedriver.
Parallelism
php artisan dusk --parallel gives real speedups on suites over ~20 tests, but every worker needs its own database — a :memory: SQLite DSN will collide, so use distinct database names per worker (DB_DATABASE=${DB_DATABASE}_${TEST_TOKEN}). Data-provider tests already disambiguate screenshot/console filenames.
APP_URL is load-bearing. Dusk visits exactly that URL. A port mismatch, an https:// in a local http:// app, or a localhost vs 127.0.0.1 split will manifest as instant timeouts, not helpful errors. Test php artisan serve + APP_URL by hand once before debugging tests.
Chromedriver/Chrome version drift is the #1 "nothing works" cause. Chrome 115+ changed the download endpoints and Dusk has been patched repeatedly (7.9.0, 8.2.2, 8.2.4). Always run php artisan dusk:chrome-driver --detect in CI; on Chrome release day, run it locally too.
Headless defaults surprise people. Tests run headless unless you pass --browse, set the DUSK_HEADLESS_DISABLED env, or override hasHeadlessDisabled() in DuskTestCase. Conversely, CI fails without --no-sandbox in containers — add it via ChromeOptions::addArguments() in driver().
Debugging toolkit, in order of usefulness: php artisan dusk --browse → $browser->dump() / dd() (separate methods since 8.1.0) → $browser->screenshot('name') and elementScreenshot('@selector') → storeConsoleLog('name') (captures JS console errors, requires Chromedriver) → storeSource('name') (full DOM snapshot). Console/source/screenshot directories are auto-created if missing (8.2.9), and php artisan dusk:fails re-runs only the previous failures — the tightest inner loop available.
Flakes come from missing waits, not from Dusk. The changelog is a catalogue of timing fixes: clickWhen() replaced the misnamed clickOnce(), findButtonByText() now prefers exact text over substring (8.5.0), empty strings passed to assertSeeIn() are proxied (8.5.0). If an assertion behaves oddly, upgrade before you work around it — but read the behaviour-change notes (assertAttributeDoesntContain in 8.2.13).
Http::preventStrayRequests() interaction (7.9.3): Dusk boots your real app, so a global fake/prevent registered in a service provider can poison browser requests. Keep HTTP faking out of anything reachable from the Dusk provider path.
assertVue is the most brittle API in the package. It depends on injected JS bindings and the dev Vue build, breaks with Vue 3 setups, and silently degrades. Prefer dusk attributes rendered from component state and assert on those.
Artifacts in git. tests/Browser/screenshots, /console, /source should be gitignored; keep the .gitignore files Dusk creates inside them. Otherwise every failing CI run churns your repo.
Extension points worth knowing before you fork anything:
DuskTestCase::driver() — full ChromeOptions/DesiredCapabilities control (downloads dir, window size, proxy, mobile emulation).Browser::macro('name', fn (...) => ...) — add domain helpers like ->seedCart([...]) registered in a service provider or your DuskTestCase::setUp().dusk selector attribute — swap [dusk=...] for [data-testid=...] if your front-end conventions demand it (7.5.0).dusk:page generates Page classes and honours either Laravel\Dusk\Page or your own Tests\Browser\Pages\Page base (8.2.10).Browser::$storeScreenshotsAt) for CI artifact upload.Scope discipline matters more than coverage. Dusk suites are 10–100× slower than feature tests. A lean, selector-stable suite of ~15 journey tests survives years; a 200-test Dusk suite gets deleted within two quarters. Push anything expressible as an HTTP-level assertion back into tests/Feature.
How can I help you explore Laravel packages today?