Assertions — expect with Auto-Retrying

9 min read

A test that doesn't assert is just a script. Assertions are how you turn "click this button" into "click this button, then verify the dashboard loaded with three orders and a total of $142.50." Playwright has two flavours of assertion — web-first (auto-retrying, for things that take time to render) and non-retrying (instant, for static values) — and choosing between them at the right moment is the difference between a test that flakes once a week and one that catches real bugs deterministically.

Web-first assertions — the default

Web-first assertions are the ones you'll reach for 90% of the time. They auto-retry until the assertion passes or the assertion timeout fires (5 seconds by default):

import { test, expect } from "@playwright/test";
 
test("dashboard renders", async ({ page }) => {
  await page.goto("/dashboard");
  await expect(page.getByRole("heading")).toContainText("Welcome");
  await expect(page.getByTestId("order-count")).toHaveText("3");
  await expect(page).toHaveURL(/dashboard/);
});

The retry behaviour matters more than it sounds. After clicking a button that triggers an async operation, you can write the assertion immediately:

await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
// Playwright keeps re-querying the page until the toast appears
// or 5 seconds pass. No manual wait, no waitForTimeout.

This single behaviour — every assertion retries — is why Playwright tests rarely need explicit waits. The framework handles the timing.

Page-level assertions

Some assertions apply to the page itself, not a locator:

await expect(page).toHaveURL("https://shop.example.com/dashboard");
await expect(page).toHaveURL(/\/dashboard$/);              // regex
await expect(page).toHaveTitle("Dashboard — MyApp");
await expect(page).toHaveTitle(/MyApp/);

toHaveURL is your post-navigation guard: assert the redirect actually happened before you keep going.

Locator assertions — the toolbox

The day-to-day vocabulary:

// Visibility / existence
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();
 
// Interaction state
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeEditable();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
 
// Text
await expect(locator).toHaveText("exact string");
await expect(locator).toHaveText(/pattern/);
await expect(locator).toContainText("partial");
await expect(locator).toContainText(["item 1", "item 2"]); // array — each must appear
 
// Form values
await expect(locator).toHaveValue("alice@test.com");
await expect(locator).toHaveValues(["Sports", "Music"]); // multi-select
 
// Counts
await expect(locator).toHaveCount(5);
 
// Attributes and CSS
await expect(locator).toHaveAttribute("href", "/products");
await expect(locator).toHaveAttribute("aria-expanded", "true");
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveClass(["btn", "btn-primary"]); // multiple classes
await expect(locator).toHaveCSS("color", "rgb(255, 0, 0)");
await expect(locator).toHaveCSS("display", "block");
 
// Screenshots (covered in chapter 7)
await expect(locator).toHaveScreenshot();
await expect(page).toHaveScreenshot("home.png");

The full reference is in the Playwright commands cheat sheet.

Negation with .not

Every assertion has a negative form via .not:

await expect(page.getByText("Error")).not.toBeVisible();
await expect(page.getByLabel("Newsletter")).not.toBeChecked();
await expect(page).not.toHaveURL(/login/);

.not.toBeVisible() and .toBeHidden() are subtly different: .toBeHidden() passes if the element is in the DOM but not visible or not in the DOM at all; .not.toBeVisible() is similar but keeps the asymmetric retry behaviour. In practice, prefer .toBeHidden() when you're asserting "the modal closed" and .not.toBeVisible() when you want to retry waiting for it to disappear.

Custom timeouts

The default assertion timeout is 5 seconds (configurable globally as expect.timeout in playwright.config.ts). Override per-assertion when you genuinely need longer or shorter:

await expect(page.getByText("Report ready")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("Quick toast")).toBeVisible({ timeout: 1_000 });

A common pattern: bump the timeout for the one slow operation in your test (say, a long-running export job), keep all the other assertions fast.

Soft assertions — keep going on failure

Sometimes you want the test to record multiple failures rather than stopping at the first:

await expect.soft(page.getByRole("heading")).toHaveText("Dashboard");
await expect.soft(page.getByTestId("count")).toHaveText("3");
await expect.soft(page).toHaveURL(/dashboard/);
 
// All three assertions run; failures are reported at the end of the test

Soft assertions are useful for smoke tests that check many things on a page in one go — if three are wrong, you want to know all three, not just the first. They're also useful for visual / accessibility scans where you want to surface every violation in one report.

Non-retrying assertions — for static values

Plain expect(value) (without a Locator or Page) doesn't retry. It's instant — same as Jest's expect:

const apiResponse = await request.get("/api/orders");
expect(apiResponse.status()).toBe(200);
 
const orders = await apiResponse.json();
expect(orders).toHaveLength(3);
expect(orders[0]).toHaveProperty("total");
expect(orders[0].total).toBeGreaterThan(0);
 
const cookies = await context.cookies();
expect(cookies.find(c => c.name === "session")).toBeDefined();

These are the right choice for API responses, computed values, counts read into variables, anything that doesn't change once you have it. The full Jest matcher set works: toBe, toEqual, toContain, toHaveLength, toHaveProperty, toBeGreaterThan, etc.

Retrying vs non-retrying — the difference

When does the assertion retry?

Web-first — retries automatically

  • Pattern: await expect(locator).toBe...

  • Re-queries the DOM until the condition holds or the timeout fires

  • Right tool for: visibility, text, count, URL, anything async

  • Removes the need for waitForTimeout in 95% of cases

Non-retrying — instant snapshot

  • Pattern: expect(value).toBe...

  • Checks once against a value already in memory

  • Right tool for: API responses, computed values, parsed JSON

  • Wrong tool for DOM state — turns auto-retry into instant flake

The single most common Playwright mistake is using a non-retrying assertion against the DOM:

// ❌ Wrong — snapshots the text once, no retry
const text = await page.getByRole("heading").textContent();
expect(text).toBe("Welcome");
 
// ✅ Right — retries until the heading shows "Welcome"
await expect(page.getByRole("heading")).toHaveText("Welcome");

Both pass when the page is fast. Only the second works on a slow CI run.

A complete product-page assertion test

import { test, expect } from "@playwright/test";
 
test.describe("Product detail page", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/products/wireless-headphones");
  });
 
  test("renders product details correctly", async ({ page }) => {
    const card = page.getByTestId("product-detail");
 
    // Visibility and structure
    await expect(card).toBeVisible();
    await expect(card.getByRole("heading", { level: 1 })).toHaveText("Wireless Headphones");
 
    // Price and stock
    await expect(card.getByTestId("price")).toContainText("$");
    await expect(card.getByTestId("stock")).toContainText(/in stock/i);
 
    // Add-to-cart button enabled
    const addBtn = card.getByRole("button", { name: "Add to cart" });
    await expect(addBtn).toBeEnabled();
 
    // Image alt text — accessibility check via assertion
    await expect(card.getByRole("img")).toHaveAttribute("alt", /headphones/i);
 
    // Class state
    await expect(card).toHaveClass(/in-stock/);
  });
 
  test("smoke check with soft assertions", async ({ page }) => {
    const card = page.getByTestId("product-detail");
    await expect.soft(card.getByRole("heading")).toBeVisible();
    await expect.soft(card.getByTestId("price")).toContainText("$");
    await expect.soft(card.getByRole("button", { name: "Add to cart" })).toBeEnabled();
    await expect.soft(card.getByText("Free returns")).toBeVisible();
    // All four are checked; the test reports each failure independently
  });
 
  test("adds to cart and the badge updates", async ({ page }) => {
    await page.getByRole("button", { name: "Add to cart" }).click();
    await expect(page.getByTestId("cart-count")).toHaveText("1");
    await expect(page.getByText("Added to cart")).toBeVisible();
  });
});

Read each assertion for the reason it picks the matcher it does. .toBeVisible() for the card itself. .toHaveText() for an exact heading. .toContainText(/in stock/i) for case-insensitive partial match. .toBeEnabled() for button state. .toHaveAttribute() for the alt text accessibility check. Every assertion describes the user-visible truth, not implementation detail.

Coming from Cypress?

The mappings:

  • cy.get(...).should('be.visible')await expect(page.locator(...)).toBeVisible()
  • cy.get(...).should('contain', 'text')await expect(page.locator(...)).toContainText('text')
  • cy.get(...).should('have.text', 'exact')await expect(page.locator(...)).toHaveText('exact')
  • cy.get(...).should('have.length', 5)await expect(page.locator(...)).toHaveCount(5)
  • cy.get(...).should('have.value', 'foo')await expect(page.locator(...)).toHaveValue('foo')
  • cy.get(...).should('have.attr', 'href', '/x')await expect(page.locator(...)).toHaveAttribute('href', '/x')
  • cy.url().should('include', '/dash')await expect(page).toHaveURL(/\/dash/)

Two conceptual differences: Playwright's matchers are named (toHaveText, toBeVisible) instead of stringly-typed ('have.text', 'be.visible'), which gives you full TypeScript autocomplete on every assertion. And Playwright's negation is .not (.not.toBeVisible()) instead of 'not.be.visible'. Both retry by default; the rhythm is the same.

⚠️ Common mistakes

  • Awaiting the value first, then asserting against it. const t = await locator.textContent(); expect(t).toBe('Saved') snapshots once and skips Playwright's retry. The right pattern is await expect(locator).toHaveText('Saved') — the assertion itself does the polling. This is the single highest-leverage habit to internalise from this lesson.
  • Reaching for await page.waitForTimeout(2000) to "let the page settle." Web-first assertions already do this — they retry up to the timeout. Fixed sleeps are slow when the page is fast and flaky when it's slow. If a specific assertion needs longer than 5 seconds, raise its timeout ({ timeout: 10_000 }); never sprinkle waitForTimeout.
  • Using soft assertions for everything. Soft assertions are powerful but they change semantics: the test continues even if a critical condition fails. If the heading is wrong, you don't really want the test to keep clicking buttons. Reserve expect.soft for smoke tests and multi-violation reports (a11y, visual). For normal flow tests, regular expect is the right default — fail fast.

🎯 Practice task

Build an assertion-rich product-page spec. 25-30 minutes.

  1. Use Sauce Demo (baseURL: "https://www.saucedemo.com") and log in via beforeEach.

  2. Create tests/assertions.spec.ts with three tests against the inventory page:

    import { test, expect } from "@playwright/test";
     
    test.describe("Inventory assertions", () => {
      test.beforeEach(async ({ page }) => {
        await page.goto("/");
        await page.getByPlaceholder("Username").fill("standard_user");
        await page.getByPlaceholder("Password").fill("secret_sauce");
        await page.getByRole("button", { name: "Login" }).click();
      });
     
      test("inventory smoke check (soft assertions)", async ({ page }) => {
        await expect.soft(page).toHaveURL(/inventory/);
        await expect.soft(page.locator(".inventory_item")).toHaveCount(6);
        await expect.soft(page.locator(".shopping_cart_link")).toBeVisible();
        await expect.soft(page.getByText("Products")).toBeVisible();
      });
     
      test("first product card has all expected fields", async ({ page }) => {
        const card = page.locator(".inventory_item").first();
        await expect(card.locator(".inventory_item_name")).toHaveText("Sauce Labs Backpack");
        await expect(card.locator(".inventory_item_price")).toContainText("$");
        await expect(card.getByRole("button", { name: "Add to cart" })).toBeEnabled();
        await expect(card.getByRole("img")).toHaveAttribute("alt", /Sauce Labs Backpack/);
      });
     
      test("cart badge updates after add-to-cart", async ({ page }) => {
        await expect(page.locator(".shopping_cart_badge")).toBeHidden();
        await page
          .locator(".inventory_item")
          .filter({ hasText: "Backpack" })
          .getByRole("button", { name: "Add to cart" })
          .click();
        await expect(page.locator(".shopping_cart_badge")).toHaveText("1");
      });
    });
  3. Run the spec. All three should pass on all three browsers.

  4. Force a slow assertion to fail. In test 3, change the cart-badge assertion to toHaveText("99"). Re-run. Watch the assertion retry for 5 seconds before reporting the failure — that's auto-retry in action. The HTML report shows the failure and the actual value at timeout.

  5. Demonstrate the snapshot-vs-retry pitfall. Replace test 2's price assertion with the broken pattern: const t = await card.locator(".inventory_item_price").textContent(); expect(t).toContain("$"). It still passes (Sauce Demo is fast), but you've removed retry. Now imagine the price loaded asynchronously after a 2s delay — the snapshot version would catch null instead of "$29.99". This is the failure mode that turns into "flaky" tests in production.

  6. Stretch: add a test that asserts a complex DOM state with five soft assertions — the heading, the cart badge being hidden, six product cards, the sort dropdown defaulting to "Name (A to Z)", and the burger menu being closed. Run it. Now break two of the five conditions on purpose. The test reports both failures, not just the first — that's the soft-assertion superpower.

You now have a precise, retry-aware assertion vocabulary. The next lesson is the last one in this chapter — handling the form controls that don't fit cleanly into "click" and "fill": dropdowns, checkboxes, radios, custom comboboxes, and the patterns for date pickers and toggle switches.

// tip to track lessons you complete and pick up where you left off across devices.