SkillByAIOpen interactive version →

Lesson 6 / 25

Web-First Assertions

Assertions that retry.

expect(locator) retries until it passes

Assertions such as await expect(locator).toBeVisible(), toHaveText, toContainText, toHaveValue, toHaveCount, toBeEnabled and page-level toHaveURL or toHaveTitle are web-first: they keep re-checking until the condition holds or the expect timeout (5 seconds by default, configurable) runs out. That is why they must be awaited. Avoid reading a value once and asserting on it, as in expect(await locator.isVisible()).toBe(true), because that does not retry. expect.soft records a failure without stopping the test, and expect.poll retries an arbitrary async function.

Retrying versus non-retrying

Prefer the first form.

import { test, expect } from '@playwright/test';

test('shows saved status', async ({ page }) => {
  await page.goto('/settings');
  await page.getByRole('button', { name: 'Save' }).click();

  // good: retries until the text appears or the timeout expires
  await expect(page.getByRole('status')).toHaveText('Saved');
  await expect(page).toHaveURL(/\/settings$/);

  // fragile: reads once, no retry
  // expect(await page.getByRole('status').textContent()).toBe('Saved');

  // retry an arbitrary async check
  await expect.poll(async () => (await page.request.get('/api/settings')).status()).toBe(200);
});

Raise timeouts in config, not with sleeps

If a step is genuinely slow, set expect: { timeout: 10_000 } in the config or pass { timeout } to one assertion, instead of adding page.waitForTimeout.

Quick check: Why is `await expect(locator).toHaveText('Saved')` better than comparing textContent() once?

  • It ignores letter case automatically
  • It runs faster because it never waits
  • It works without a page
  • It retries until the text matches or the timeout expires
Answer

It retries until the text matches or the timeout expires — Web-first assertions absorb timing differences.