Introduction
Browser tests fail for reasons unit tests never do: an asset loaded late, a CSS transition running when the assertion fired, a Chromium update that changed click behavior. This lesson walks through the techniques that turn a flaky test into a reliable one — and the techniques that make the failure easy to diagnose when it's genuinely broken.
Key Concepts
- Screenshot on failure: Dusk auto-captures a screenshot when a test fails; it lands in
tests/Browser/screenshots. - Console log capture: Dusk also dumps the browser's JS console to
tests/Browser/console— usually the first thing to check. - Explicit waits: Replace every
pause()withwaitFor*()variants that block only until a condition is true.
Real World Context
A test that passes locally but fails on CI 1 in 10 runs. The screenshot shows a half-rendered page — the Blade template finished but an async <script> fetch hadn't resolved yet. The fix isn't pause(500); it's waiting for the specific DOM element that proves the fetch finished.
Deep Dive
Check the screenshot first
When a Dusk test fails, Dusk writes:
tests/Browser/screenshots/failure-{ClassName}-{method}.png
tests/Browser/console/{ClassName}-{method}.log
tests/Browser/source/{ClassName}-{method}.txt
Open the PNG before reading the stack trace — nine times out of ten the failure is visually obvious (a 404 page, a validation error, a spinner that never went away).
Explicit waits instead of pause
php// Bad — waits a fixed 2 seconds every run $browser->click('@save')->pause(2000)->assertSee('Saved'); // Good — waits only until the text appears $browser->click('@save')->waitForText('Saved'); // Good — for disappearing elements $browser->click('@delete')->waitUntilMissing('@confirmation-modal'); // Good — for a custom predicate $browser->click('@load-more')->waitUsing(10, 100, function () use ($browser) { return $browser->element('.post:nth-child(20)') !== null; });
Debugging in headed mode
Run with --browse and a breakpoint ($browser->pause(10_000) while you inspect) when you can't reproduce locally:
bashphp artisan dusk --browse --filter test_checkout_flow
The browser window stays open and you can poke the DOM with DevTools.
Assert no JavaScript errors
Add this to every test or the base class to catch regressions you'd otherwise miss:
phppublic function tearDown(): void { if (isset($this->browser)) { $this->browser->assertNoJavascriptErrors(); } parent::tearDown(); }
Retry only the intentionally-flaky stuff
PHPUnit core does not ship a retry attribute, but third-party extensions (for example phpunit-retry) add one. Use retries sparingly — only for tests that depend on real timing (animations, network calls to staging) that you've already pushed as hard as you can with explicit waits. If you're reaching for retries often, that's a signal to fix the underlying wait logic instead.
Common Pitfalls
- Adding
pause()to "fix" flakes — turns a 200ms test into a 2-second test and just papers over the root cause. UsewaitFor*and identify the specific DOM state you're waiting for. - Not checking console.log — a failed fetch that returned 500 is often visible in the browser console before anywhere else.
Best Practices
- Fail fast on JS errors — assert
assertNoJavascriptErrors()in atearDown. - Keep Dusk tests focused on user flows — don't duplicate what feature tests already cover via
$this->get(). Dusk is for testing interactions feature tests can't reach.
Summary
- Screenshots, console logs, and page source are captured automatically on failure.
- Replace every
pause()withwaitFor,waitForText,waitUntilMissing, orwaitUsing. - Use
--browsewith a long pause when you need to inspect the live browser. - Fail the test on any JavaScript console error.