Browser tests

Wait for observable state

Use Playwright actionability and web-first assertions instead of arbitrary sleeps and race-prone immediate checks.

A fixed delay in a browser test is wrong in both directions at once. Too slow on a fast machine, where you sit there waiting. Too short on a slow CI runner, where the test fails for no reason.

Playwright’s answer is to never wait for time. Wait for the state the user needs.

Playwright already waits for you

Two mechanisms do most of the work. Before an action, Playwright checks actionability: the locator resolves to one element, and that element is visible, stable, enabled, and able to receive the click. It retries those checks until they pass or the timeout expires.

After the action, a web-first assertion does the same thing for the outcome:

await page.getByRole('button', { name: 'Add book' }).click()

const list = page.getByRole('list', { name: 'Books' })
await expect(list.getByRole('listitem')).toContainText('Dune')

expect(locator).toContainText() reads the page again and again until the text appears, or until the timeout. No sleep, no race.

One detail matters here. Pass the locator to expect(), not its text. This version loses the retry:

const text = await list.textContent()
expect(text).toContain('Dune')

textContent() reads once, right after the click, before the API answered. The assertion then checks a stale string. Keep the locator inside expect() and Playwright keeps looking.

Wait for what the user sees

If saving a book should add a list item, the list item is the evidence. Waiting for the POST /books response is weaker: a 200 can be followed by broken rendering, and the user still sees nothing.

Wait for a response only when the response itself is the contract, or when you need it for diagnostics. Never as a substitute for the visible outcome.

The two tempting fixes

page.waitForTimeout(1000) and click({ force: true }) both make a red test green quickly. Both hide a real problem: an overlay covering the button, a control that stays disabled, a state transition that never fires.

When an assertion times out, read which condition never became true. Playwright tells you: “element is not visible”, “element is disabled”, “expected to contain text”. That’s the bug report. Then ask whether the app exposes a stable, observable state at that moment. Often it doesn’t, and fixing the app fixes the test.

Try this: take one test and remove every waitForTimeout(), replacing each with a locator and an assertion about visible state. Add a two-second delay to the API locally and check the test still passes. Then break the UI update and check the assertion fails with a message about the missing list item.

Lesson completed