SoftwareTestPilot
Module 03 · Lesson 2beginner 12 min read Playwright

Playwright Assertions & Auto-Waiting

Web-first assertions eliminate 90% of flaky tests. Learn every expect() variant, when to use soft assertions, and how auto-retry replaces every sleep you ever wrote.

Quick answer

A Playwright assertion is expect(locator).toXxx(). Anything starting with toBe, toHave or toContain on a locator auto-waits and auto-retries until it passes or times out (default 5 s). This is why modern Playwright tests need no sleeps.

1. Why web-first assertions matter

Old (Selenium-style)Modern PlaywrightResult
sleep(3000); assert el.isDisplayed()await expect(el).toBeVisible()No sleeps, no flake
wait.until(...).click()await el.click()Playwright auto-waits actionability
Manual retry loopsBuilt-in retry every ~50 ms5× faster, deterministic

2. Visibility & state assertions

await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await expect(page.getByText('Loading…')).toBeHidden();
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByRole('textbox', { name: 'Email' })).toBeEmpty();
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled();
await expect(page.getByRole('textbox')).toBeFocused();
await expect(page.getByRole('textbox')).toBeEditable();

3. Text and value assertions

await expect(page.getByTestId('cart-total')).toHaveText('$42.00');
await expect(page.getByTestId('cart-total')).toContainText('42');
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('a@b.co');
await expect(page.getByRole('combobox')).toHaveValues(['us', 'ca']);   // multi-select
await expect(page.locator('img.logo')).toHaveAttribute('alt', 'Acme');
await expect(page.locator('button')).toHaveClass(/primary/);
await expect(page.getByTestId('badge')).toHaveCSS('background-color', 'rgb(0, 130, 251)');

4. Count and collections

const rows = page.getByRole('row');
await expect(rows).toHaveCount(10);

// Assert the exact set of texts, in order
await expect(page.getByRole('listitem')).toHaveText(['Milk', 'Eggs', 'Bread']);

// Or contain-in-order
await expect(page.getByRole('listitem')).toContainText([/milk/i, /eggs/i]);

5. URL, title & network assertions

await expect(page).toHaveURL(/\/dashboard$/);
await expect(page).toHaveTitle('Dashboard — Acme');

const [resp] = await Promise.all([
  page.waitForResponse('**/api/user'),
  page.getByRole('button', { name: 'Refresh' }).click(),
]);
expect(resp.status()).toBe(200);
expect(await resp.json()).toMatchObject({ email: expect.any(String) });

6. Soft assertions — see every failure at once

test('dashboard renders every widget', async ({ page }) => {
  await page.goto('/dashboard');
  await expect.soft(page.getByTestId('revenue')).toBeVisible();
  await expect.soft(page.getByTestId('users')).toBeVisible();
  await expect.soft(page.getByTestId('churn')).toBeVisible();
  // Test still fails at the end if any soft assertion failed,
  // but you see ALL broken widgets in the report — not just the first.
});

7. Custom timeouts & negation

// One-off longer timeout
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 30_000 });

// Negated — wait until element disappears
await expect(page.getByText('Loading…')).not.toBeVisible();

// Custom matcher
expect.extend({
  toBeValidEmail(received: string) {
    const pass = /.+@.+\..+/.test(received);
    return { pass, message: () => `expected ${received} to be a valid email` };
  },
});
// usage:  expect('a@b.co').toBeValidEmail();

8. Common mistakes

Awaiting isVisible() in a test

await loc.isVisible() resolves once with a boolean — no retry. Always use await expect(loc).toBeVisible().

Wrapping matchers in try/catch

Assertions should fail loudly. If you catch them the test always passes and you lose the diff.

Sleeping before an assertion

await page.waitForTimeout(2000) before expect is redundant and slow. Delete it — the assertion already retries.

9. Hands-on task (10 minutes)

  1. 1

    Break a locator

    Point one getByRole at an element that doesn't exist. Run the test and note the 5 s default timeout and screenshot in the report.

  2. 2

    Add three soft assertions

    Pick a page with 3+ visible widgets. Assert each with expect.soft. Break two on purpose and observe the report shows both.

  3. 3

    Assert URL after nav

    Add await expect(page).toHaveURL(/dashboard/) after login and delete every waitForTimeout you had.

Next up: refactor these calls behind a Page Object Model.

Frequently asked questions

1.What is a web-first assertion in Playwright?
An assertion that automatically retries until it passes or times out. `expect(locator).toBeVisible()` will re-query the DOM every ~50 ms for up to 5 seconds — so you never need manual sleeps.
2.toBeVisible vs isVisible — what's the difference?
`toBeVisible()` is an assertion that auto-retries; `isVisible()` is a boolean check that runs once. Use `toBeVisible()` in tests. Reserve `isVisible()` for conditional flows like 'close cookie banner if it exists'.
3.How do I change the default assertion timeout?
Set `expect: { timeout: 10_000 }` in `playwright.config.ts`, or per-call `expect(loc).toBeVisible({ timeout: 15_000 })`. Keep the global short and override only when a specific step is genuinely slow.
4.What are soft assertions?
`expect.soft()` records the failure but keeps the test running so you collect every problem in one run. Use for dashboards or reports where you want to see all broken widgets, not just the first.
5.How do I assert an element is NOT visible?
Use `.not`: `await expect(page.getByText('Error')).not.toBeVisible()`. It waits until the element disappears — much better than asserting on a snapshot at one instant.
6.Can I write custom assertions?
Yes. Extend `expect` via `expect.extend({ toBeMyThing(received) { ... } })`. Great for domain-specific checks like `toHaveValidInvoice()`.

Related lessons