C.W.K.
Stream
Lesson 02 of 04 · published

Web-First Assertions That Retry Themselves

~15 min · playwright-locators, assertions, web-first

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"In Playwright, the assertion IS the wait."

What 'Web-First' Actually Means

A regular assertion (expect(value).toBe(...)) runs once. If the value doesn't match, it fails immediately. That's the right behavior for in-process testing where state is synchronous.

In a browser, state is asynchronous. The button might be disabled for 100 ms while a form is being validated. The toast might take 200 ms to fade in. The list might re-render after a fetch completes. A one-shot assertion against any of these is a race — sometimes the browser has caught up, sometimes it hasn't.

Web-first assertions (expect(locator).toBe...) retry until the condition passes or the timeout (default 5 s) expires. The button isn't enabled? Wait. The toast isn't visible yet? Wait. The list has 0 items? Wait. The retry is built in — you don't write waitFor, you don't sleep, you just assert what you expect to be true.

The Assertion Vocabulary You'll Use

  • toBeVisible() / toBeHidden() — element exists and is in the viewport / not.
  • toBeEnabled() / toBeDisabled()
  • toBeChecked() / not.toBeChecked()
  • toHaveText(string | regex) — exact text match (use regex for substrings).
  • toContainText(string | regex) — substring match.
  • toHaveValue(string) — for inputs.
  • toHaveAttribute(name, value) — for href, aria-*, data-*.
  • toHaveClass(string | regex)
  • toHaveCount(n) — for locators that match multiple elements.
  • toHaveURL(string | regex) — page-level.
  • toHaveTitle(string | regex) — page-level.

Adjusting Timeout Per-Assertion

The default 5-second timeout fits most cases. For a known-slow operation (a payment gateway, a long-running search), pass { timeout: 15000 } as the last arg. For a known-fast one where you want to fail loudly on any delay, drop it to { timeout: 1000 }.

If you find yourself writing await page.waitForTimeout(2000), stop. waitForTimeout is a real method but it's almost always wrong. Either there's an assertion you should be using that retries naturally, or there's a missing app-state event you should be waiting for. Sleeping for a duration is a guess; the assertion is the truth.

What Web-First Assertions Are NOT

They are not magic. If the condition is genuinely never going to be true (you misspelled the button name, the element doesn't render), they fail after the timeout — same as a regular assertion, just slower. The point is to absorb the JITTER of async UI, not to paper over real bugs.

page-Level vs Locator-Level Assertions

Most assertions go through expect(locator). Two important page-level ones:

  • expect(page).toHaveURL(...) — after navigation.
  • expect(page).toHaveTitle(...) — for the <title> element.

These also retry — useful when navigation involves a short redirect chain.

Code

Web-first assertions — no manual waits needed·typescript
// All of these RETRY until they pass or the timeout (default 5s) expires
import { test, expect } from '@playwright/test';

test('web-first assertions don\'t need waits around them', async ({ page }) => {
  await page.goto('/dashboard');

  // Visibility — retries until the element appears (or 5s)
  await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();

  // Text content — handles slow-loading text
  await expect(page.getByTestId('user-name')).toHaveText('Pippa Choi');

  // Count — retries until N items render
  await expect(page.getByRole('listitem')).toHaveCount(3);

  // URL after navigation — handles redirect chains
  await page.getByRole('link', { name: 'Settings' }).click();
  await expect(page).toHaveURL(/\/settings$/);

  // Form input value — handles async state from form libraries
  await page.getByLabel('Email').fill('new@example.com');
  await expect(page.getByLabel('Email')).toHaveValue('new@example.com');
});
Per-assertion timeout — slow paths up, fast paths down·typescript
// Adjusting timeout when defaults aren't right
import { test, expect } from '@playwright/test';

test('payment confirmation can take up to 30s', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay $99.99' }).click();

  // Override the default 5s to 30s — payment gateway is slow
  await expect(
    page.getByText(/payment confirmed/i)
  ).toBeVisible({ timeout: 30_000 });
});

test('fast-path assertion should fail loudly on any delay', async ({ page }) => {
  await page.goto('/login');

  // This page should render instantly; if it's slow, that's a bug.
  await expect(
    page.getByRole('heading', { name: 'Sign in' })
  ).toBeVisible({ timeout: 1_000 });
});
Negative assertions — `toBeHidden` retries too·typescript
// Negative assertions — for things that should DISAPPEAR
import { test, expect } from '@playwright/test';

test('toast auto-dismisses', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Save' }).click();

  // Toast appears
  const toast = page.getByRole('alert', { name: /saved/i });
  await expect(toast).toBeVisible();

  // Toast disappears — also retries (waits up to 5s for it to go away)
  await expect(toast).toBeHidden();
  // Or use the explicit semantic:
  // await expect(toast).not.toBeVisible();
});
Anti-pattern vs the right pattern·typescript
// Anti-pattern: don't do this
import { test, expect } from '@playwright/test';

test('the wrong way (DON\'T)', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Load' }).click();

  // ❌ Sleeping for a guessed duration — fragile and slow
  await page.waitForTimeout(2000);

  // ❌ Now you assert against possibly-stale state
  const text = await page.getByTestId('result').textContent();
  expect(text).toBe('Loaded!');
});

test('the right way (DO)', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Load' }).click();

  // ✅ Wait FOR the condition, not for time
  await expect(page.getByTestId('result')).toHaveText('Loaded!');
});

External links

Exercise

Take an existing E2E test in your project that uses page.waitForTimeout or page.waitForSelector and rewrite it using web-first assertions only. The test should be SHORTER after the rewrite and (if you measure) at least as fast. Then deliberately break the assertion (assert text that won't appear) and observe how the failure message looks — note the timeout, the locator description, and the actual state.
Hint
If you can't find a web-first assertion that replaces a waitForSelector('.thing'), the underlying issue is usually that you should be asserting on a state (text, visibility, value) instead of just 'the element exists.' Asking 'what user-visible thing am I actually waiting for?' usually reveals the right assertion.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.