Debugging Flaky Dusk Tests

+15 Mana ✨

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() with waitFor*() 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:

bash
php 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:

php
public 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

  1. Adding pause() to "fix" flakes — turns a 200ms test into a 2-second test and just papers over the root cause. Use waitFor* and identify the specific DOM state you're waiting for.
  2. Not checking console.log — a failed fetch that returned 500 is often visible in the browser console before anywhere else.

Best Practices

  1. Fail fast on JS errors — assert assertNoJavascriptErrors() in a tearDown.
  2. 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() with waitFor, waitForText, waitUntilMissing, or waitUsing.
  • Use --browse with a long pause when you need to inspect the live browser.
  • Fail the test on any JavaScript console error.
✓ Completed