Playwright
Intermediate 14 min read Playwright

Assertions & Actions

Playwright pairs auto-waiting actions (click, fill, press) with polling web-first assertions (toBeVisible, toHaveText, toHaveValue). Understanding soft assertions, expect.poll and timeouts separates reliable suites from flaky ones.

web-first assertionsexpectauto-retryingsoft assertionsexpect.pollactionstoHaveValue
Prerequisites: Locators & Selectors
Introduction

What is this?

Actions perform user gestures on locators, while web-first assertions verify state. Assertions from expect() retry automatically until the condition passes or the timeout expires, which is what makes them robust against async rendering. Soft assertions record failures without aborting the test, and expect.poll lets you poll arbitrary values.

Motivation

Why do you need it?

  • Web-first assertions auto-retry, so they pass as soon as the app reaches the expected state instead of flaking on timing.
  • Actions wait for actionability first, meaning you never hand-roll 'wait for element then click' helpers.
  • Soft assertions let a test check many UI invariants in one pass and report every failure at the end.
  • expect.poll converts custom async checks (API responses, storage, counters) into retrying assertions.
  • Built-in timeouts are configurable per assertion, so slow regions get more headroom without global sleep.
Think about it

Mental model

Imagine

A surgeon verifying vital signs before each step

Think of

Actionability + web-first assertions

The surgeon only acts when the patient is stable, and double-checks the result. Playwright waits for the element to be actionable, then asserts the outcome actually happened.

Mechanics

How it works

  • An action like click() checks actionability — attached, visible, stable, enabled and unobstructed — polling until true.
  • Playwright performs the actual mouse event at the element's visible center; obscured elements time out.
  • A web-first assertion registers a check with the expect runner, which polls the DOM and re-runs the check until it passes.
  • If the check never passes, the runner throws after the assertion timeout and records the last value it saw.
  • expect.poll polls a custom function (not DOM-coupled) at an interval until it returns a truthy result or times out.

The retry loop behind a single web-first assertion.

Call expect(locator)
Evaluate check
Check passed?
Wait and re-poll
Throw on timeout
Reference

Key commands

await expect(locator).toBeVisible()

Retry until the element is visible (has non-empty box and no visibility:hidden).

Expected locator to be visible, resolved to <button class='disabled'>…
await expect(locator).toHaveText('Saved')

Assert the element's full text content, retrying until it matches.

await expect(locator).toHaveValue('ALICE')

Assert an input or select's current value, useful after typing and blur.

await expect.poll(() => fetchTotal(), { timeout: 10000 })

Poll a custom async function until it returns a truthy value.

await expect(locator).toBeEnabled()

Wait for a disabled button or field to become interactive.

test.use({ actionTimeout: 15000 })

Raise the default action timeout for a slow region of the app.

Hands-on

Examples

Actions plus web-first and soft assertions

Actions plus web-first and soft assertions
import { test, expect } from "@playwright/test";
test("checks out with a coupon", async ({ page }) => {
await page.goto("https://example.com/cart");
await page.getByLabel("Promo code").fill("SAVE10");
await page.getByRole("button", { name: "Apply" }).click();
await expect(page.getByText("You saved $10.00")).toBeVisible();
expect.soft(page.getByTestId("tax-line")).toHaveText("Tax: $4.20");
await page.getByRole("button", { name: "Checkout" }).click();
await expect(page).toHaveURL(/checkout/);
await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible();
});

fill and click auto-wait for the fields to be actionable. toBeVisible retries until the coupon message renders. The soft assertion on the tax line records a failure but lets the test continue to checkout. toHaveURL checks the navigation outcome directly on the page object.

Real world

In production

  • Checkout suites assert URL changes, confirmation headings and order IDs with web-first assertions to catch redirect bugs.
  • Soft assertions validate non-critical UI (footers, tax lines, labels) without failing the whole journey on one cosmetic bug.
  • expect.poll polls the payment provider's status endpoint before asserting on the receipt, replacing flaky sleeps.
  • Action timeout tuning in the config gives slow environments (CI, emulated mobile) the headroom they need.
Guidelines

Best practices

  • Prefer web-first assertions over expect.truthy(polling code) and never mix in waitForTimeout.
  • Use toHaveText/toHaveValue for form state and toBeVisible for presence — they check the right semantics.
  • Keep soft assertions for genuinely secondary checks so the primary flow still fails loudly.
  • Set the assertion timeout once in the config and only override it locally with a reason.
  • Use toHaveURL after navigations instead of asserting on window.location inside the page.
Watch out

Common mistakes

  • Chaining assertion-after-fill without waiting for the value to propagate, then getting a stale read.
  • Using expect(page).toHaveScreenshot in the same test as soft assertions and missing the real failure.
  • Asserting on hidden elements: toBeVisible vs toBeAttached vs toHaveText mean different things.
  • Writing page.waitForNavigation before an action that triggers it, a pattern now removed in favor of auto-waiting.
Root cause analysis

Common issues

Error
expect(received).toHaveText(): expected 'Saved' but got 'Saving…'
Root cause

The assertion polled before the async save finished updating the element.

Fix

Web-first assertions retry automatically — just ensure the timeout covers the slowest render, not that you add waits.

Error
element is not visible ... waiting for selector
Root cause

The element exists but is hidden or zero-sized (display:none, off-screen).

Fix

Check which locator matched with codegen; you may be asserting on a hidden clone or the element needs a different state first.

Error
Timeout exceeded while waiting for event 'load'
Root cause

A navigation never completed, often because the action didn't trigger it.

Fix

Confirm the triggering action ran (assert its effect first), and remove any legacy waitForNavigation patterns.

When things break

Debugging tips

  • Watch the retry loop fail live: the error message prints the last resolved DOM snapshot and value.
  • Temporarily raise testTimeout with test.setTimeout(60000) to distinguish slow from broken.
  • Add expect.configure({ timeout: 15000 }) in a describe block to scope longer waits where needed.
  • Use npx playwright test --trace on to capture the exact assertion failure frame in the trace viewer.
Interviews

Interview checkpoint

Practice

Hands-on lab

Assertion Lab: From Flaky to Web-First
35 min · intermediate
In progress
Objectives
  • Replace sleep-based waits with web-first assertions
  • Verify form state with toHaveValue and toBeEnabled
  • Use soft assertions to collect multiple failures
  • Use expect.poll to await a non-DOM condition

Assertion Lab: From Flaky to Web-First

35 min · intermediate

0/4 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which assertion is correct for checking that an input contains exactly 'ALICE'?

2

Web-first assertions retry until the condition passes or the assertion timeout is reached.

3

You want the test to keep running and report two unrelated UI bugs instead of failing on the first one. Which tool do you use?

Quick reference

Cheat sheet

Core actions
await locator.click()Auto-wait then click
await locator.fill('x')Replace input value
await locator.press('Enter')Press a key
await locator.selectOption('opt')Pick a select option
await locator.hover()Mouse over the element
await page.goto(url)Navigate
Web-first assertions
toBeVisible()Rendered with a box
toHaveText('x')Exact text match
toContainText('x')Substring text match
toHaveValue('x')Input/select value
toHaveURL(/regex/)Page URL check
toHaveCount(n)Number of matches
Go deeper

Official documentation