skip to content

A Laravel Dusk sign-up test passes locally but fails intermittently in CI after press('Sign up'). How do you make it reliable?

level: seniorimportance: should knowfreq 28%

answer

  1. press() clicks and returns at once
  2. assertions do not retry
  3. waitFor, waitForText, waitForLocation
  4. default wait is five seconds
  5. dusk="..." attributes with @name

basics

~20 s

Dusk's press() and assertions never wait, so on a slower CI machine they run before the page updates. Replace pause() with condition waits such as waitForLocation() or waitForText(), and target elements through dusk attributes like @signup-button.

solid answer

~40 s

`press()` clicks and returns immediately, and assertions like `assertPathIs()` or `assertSee()` check the page once. When the form submits via JavaScript, or the server is slower in CI, the assertion runs too early. Replace `pause(1000)` with condition waits: `waitForLocation('/dashboard')`, `waitForText('Welcome, Ada')`, `waitFor('@welcome-banner')`, `waitUntilMissing('.spinner')`, or `pressAndWaitFor('Sign up')` for a button that disables itself during the request. Each polls every 100 ms and throws a timeout after a default of five seconds (a second argument or `Browser::$waitSeconds` changes it). Also stop targeting elements with CSS paths such as `.form div > button`; add `dusk="signup-button"` in the Blade view and use `@signup-button`, which Dusk rewrites to `[dusk="signup-button"]`. `whenAvailable('.modal', fn ($m) => ...)` waits for and scopes to an element in one step.

code

html · 5 lines
html
<form method="POST" action="{{ route('register') }}">
    @csrf
    <input name="email" type="email" dusk="signup-email">
    <button type="submit" dusk="signup-button">Sign up</button>
</form>

go deeper

for a junior

Recall that waitFor() and waitForText() pause until something appears, and that dusk attributes are referenced with @ in tests.

for a middle

Explain that actions and assertions do not wait, name the condition waits, and know the five-second default and 100 ms polling.

for a senior

Remove every fixed pause, add a wait after each asynchronous step, move selectors to dusk attributes, and use failure artefacts to diagnose CI-only flakes.

for a principal

Set suite conventions, dusk attributes and condition waits only, so browser tests stay trustworthy as the frontend changes.

## Where the flakiness comes from A Dusk test drives a real browser against a real server, so every step takes time the test does not control: the network round trip, the server's response time, JavaScript updating the page. Two Dusk behaviours make that visible: - **Actions do not wait for their effects.** `press()` finds the button and clicks it; it does not wait for the form's request, a redirect or a re-render. - **Assertions check once.** `assertPathIs('/dashboard')` reads the current path and fails if it is not there yet. The docs note this explicitly for paths updated asynchronously. On a fast laptop the page usually wins the race. On a busy CI runner it often does not. ## The wrong fix: `pause()` `$browser->pause(2000)` sleeps a fixed time. It is **too long** when the page is fast, making the suite slow, and **too short** when CI is slow, so the flake returns. Treat `pause()` as a debugging aid. ## The right fix: wait for a condition | Method | Waits until | |---|---| | `waitFor('@welcome-banner')` | the element exists and is displayed | | `waitForText('Welcome, Ada')` | the text appears on the page | | `waitForTextIn('@flash', 'Account created')` | the element contains the text | | `waitUntilMissing('.spinner')` | the element is gone or hidden | | `waitForLocation('/dashboard')` | the browser's location matches | | `waitForReload(fn ($b) => $b->press('Sign up'))` | a full page reload happened | | `pressAndWaitFor('Sign up')` | the pressed button is enabled again | | `waitUntil('window.appReady === true')` | a JavaScript expression is true | All of them are built on `waitUsing()`: they poll every 100 milliseconds and throw a timeout, with a message such as "Waited 5 seconds for selector [...]", when the limit passes. The default limit is **five seconds**, set by the public static `Browser::$waitSeconds`; pass a second argument, e.g. `waitForText('Welcome', 10)`, to change it for one call. A reliable sign-up test therefore reads: ```php $browser->visit('/register') ->type('email', '[email protected]') ->press('@signup-button') ->waitForLocation('/dashboard') ->assertSee('Welcome, Ada'); ``` The wait turns the race into a condition; the assertion after it is safe. ## Stable selectors with `dusk` attributes The other half of flakiness is selectors that break when markup changes. A CSS path like `.signup div > button` fails as soon as a wrapper `div` moves. Dusk selectors decouple tests from layout: 1. Add an attribute in the Blade view: `<button dusk="signup-button">Sign up</button>`. 2. Refer to it with an `@` prefix: `$browser->press('@signup-button')` or `waitFor('@signup-button')`. 3. Dusk rewrites `@signup-button` to the CSS selector `[dusk="signup-button"]`. `Dusk::selectorHtmlAttribute('data-dusk')`, usually called in `AppServiceProvider::boot()`, changes the attribute name if your team prefers `data-*` attributes. ## Scoping to components that appear later `whenAvailable('.modal', function (Browser $modal) { $modal->press('OK'); })` waits for the element and then runs the closure with every selector scoped inside it. It combines the wait and the scope, which avoids pressing a same-named button elsewhere on the page. ## Waiting on application state Some conditions are not visible in the DOM. `waitUntil('window.signupReady === true')` polls a JavaScript expression (no `return` keyword needed), `waitForEvent('load')` waits for a DOM event, and `waitUsing(10, 250, fn () => ...)` accepts any PHP callback with its own timeout and interval. Prefer a visible signal where one exists, because it is what the user waits for too. ## A checklist for a flaky Dusk test - Replace every `pause()` with a condition wait. - Put a wait after every action that triggers a request or JavaScript update. - Use `dusk` attributes for anything the test touches. - Raise a specific wait's timeout only when the operation is genuinely slow, and investigate why. - Read the failure screenshot and console log that Dusk stores before guessing.

  • Why is assertPathIs('/dashboard') right after press() unreliable even for a classic form post?
    `press()` returns as soon as the click is sent. The browser still has to submit, wait for the server and follow the redirect, and `assertPathIs()` checks the current path once. Use `waitForLocation('/dashboard')`, or wrap the press in `waitForReload()`, before asserting.
  • How do you give one slow step more time without slowing the whole suite?
    Pass a timeout in seconds to that call, for example `waitForText('Report ready', 15)`. Changing `Browser::$waitSeconds` raises the default for every wait, which hides slowness elsewhere; a per-call value keeps the budget explicit.

saying these in an interview costs you the question

  • press() waits for the resulting page to finish loading
  • A longer pause() is the proper fix for a flaky Dusk test
  • Dusk assertions retry until they pass or time out
  • The @ prefix makes Dusk look up an element by its id
  • waitFor() waits forever unless a timeout is given