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.