Skip to content

Module 4: Assertions

You’ve learned to interact with the page. Now let’s talk about the other half of every test: verifying that the app actually did what you asked. This module covers the full Playwright expect() API — and explains why writing assertions the right way eliminates entire categories of flaky tests.

🎬 Video coming soon


Playwright assertions are web-first: they don’t evaluate once and report pass or fail. They poll the DOM on a tight loop (roughly every 50 ms) and retry until the assertion passes — or until the timeout expires.

This single design decision means you rarely need explicit waits before an assertion.

// This retries automatically until the element is visible (or times out)
await expect(page.locator('.success-banner')).toBeVisible();
// WRONG — this evaluates once, no retries, fails on slow pages
const visible = await page.locator('.success-banner').isVisible();
expect(visible).toBe(true);

The golden rule: pass a Locator into expect(), never an already-awaited value. The first form auto-retries. The second does not.

PatternAuto-retries?Use this
expect(locator).toBeVisible()YesAlways
expect(await locator.isVisible()).toBe(true)NoNever
expect(locator).toHaveText('...')YesAlways
expect(await locator.textContent()).toBe('...')NoNever

Remember: pass a Locator into expect() — it polls and retries; an already-awaited boolean is checked once and can’t retry.


toBeVisible() passes when the element is in the DOM and visible — not hidden via display: none, visibility: hidden, opacity: 0, or outside the viewport.

// Assert that an element is visible
await expect(page.locator('.welcome-message')).toBeVisible();
// Assert that a loading spinner has disappeared
await expect(page.locator('.spinner')).not.toBeVisible();
// Assert that a modal is shown after clicking
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.locator('.modal')).toBeVisible();

toBeVisible vs toBeAttached:

  • toBeVisible — element is in the DOM and visible on screen
  • toBeAttached — element is in the DOM, even if hidden

Use toBeAttached when you want to assert that React rendered a component but it might be hidden. Use toBeVisible for everything that the user can actually see.

// Use toBeAttached when presence matters but visibility does not
await expect(page.locator('[data-testid="lazy-component"]')).toBeAttached();
// Use toBeVisible when the user must be able to see it
await expect(page.locator('.notification')).toBeVisible();

Remember: toBeVisible = in the DOM and on screen; toBeAttached = in the DOM even when hidden.


This is the most common source of assertion confusion. Choosing the wrong one costs real debugging time.

toHaveText asserts that the element’s full text content exactly matches the expected string (after trimming whitespace).

// Passes only if the full text is exactly "Order Confirmed"
await expect(page.locator('h1')).toHaveText('Order Confirmed');
// With a regex — full match required
await expect(page.locator('.status-badge')).toHaveText(/Processing/);

When it trips you up: If the element contains " Order Confirmed " (with extra whitespace), Playwright normalizes whitespace, so it still passes. But if the element contains "Your Order Confirmed", it fails.

toContainText asserts that the element’s text includes the expected string anywhere within it.

// Passes if the text contains "Order" anywhere
await expect(page.locator('h1')).toContainText('Order');
// Very useful for dynamic content — IDs, dates, usernames
await expect(page.locator('.confirmation')).toContainText('order #');
// Regex with substring semantics
await expect(page.locator('.message')).toContainText(/\d+ items/);
// toHaveText — when you control the exact string
await expect(page.locator('.cart-empty')).toHaveText('Your cart is empty');
// toContainText — when the string is dynamic or surrounded by other text
await expect(page.locator('h1')).toContainText('Order'); // matches "Order #12345"
// toHaveURL with regex — for dynamic URLs
await expect(page).toHaveURL(/\/orders\/\d+/);

Rule of thumb: reach for toContainText when the exact text includes anything dynamic (IDs, timestamps, usernames). Use toHaveText when you own the string entirely.

Remember: toHaveText = exact (whitespace-normalized), toContainText = substring — default to toContainText for anything dynamic.


toHaveValue asserts the current value of a form field — <input>, <textarea>, or <select>.

// Assert the value of a text input
await expect(page.locator('#email')).toHaveValue('customer@test.io');
// Assert a textarea
await expect(page.locator('#description')).toHaveValue('Test product description');
// Assert a select dropdown's current selection
await expect(page.locator('#category')).toHaveValue('electronics');
// Use a regex for partial value matching
await expect(page.locator('#search')).toHaveValue(/widget/i);

A common pattern — fill then verify:

await page.locator('#shipping_name').fill('Jane Doe');
await expect(page.locator('#shipping_name')).toHaveValue('Jane Doe');
await page.locator('#category').selectOption('electronics');
await expect(page.locator('#category')).toHaveValue('electronics');

Note: toHaveValue reads the DOM value property, not the visible text. For <select>, it returns the option’s value attribute, not its label text.

Remember: toHaveValue reads a field’s DOM value; for <select> that’s the option’s value, not its label.


toHaveAttribute asserts that an element has a specific attribute with a specific value.

// Assert an href attribute
await expect(page.getByRole('link', { name: 'Terms' })).toHaveAttribute(
'href',
'/terms'
);
// Assert a data attribute
await expect(page.locator('.product-card').first()).toHaveAttribute(
'data-product-id',
'42'
);
// Assert aria attributes for accessibility testing
await expect(page.getByRole('button', { name: 'Submit' })).toHaveAttribute(
'aria-disabled',
'true'
);
// Use regex for partial attribute values
await expect(page.locator('img.hero')).toHaveAttribute('src', /\/images\//);

Tip: toHaveAttribute is especially useful for asserting disabled, aria-label, data-testid, type, and href values.

// Assert the type attribute on an input
await expect(page.locator('#password')).toHaveAttribute('type', 'password');
// After toggling "show password"
await page.locator('#show-password').click();
await expect(page.locator('#password')).toHaveAttribute('type', 'text');

Remember: toHaveAttribute(name, value) checks one attribute — pass a regex for partial matches.


toHaveCount asserts the number of elements that match a locator. Like all web-first assertions, it retries until the count matches or times out.

// Assert exactly 3 items in a list
await expect(page.locator('.cart-item')).toHaveCount(3);
// Assert the table has exactly 5 rows
await expect(page.locator('.product-table tbody tr')).toHaveCount(5);
// Assert that items were removed — retries until count reaches 0
await expect(page.locator('.cart-item')).toHaveCount(0);

toHaveCount(0) is powerful. If there are currently 3 items and you assert toHaveCount(0), Playwright will retry the check until all 3 are gone. This is the right way to test deletion flows.

// Add 3 items, then remove all, then assert clean state
await expect(page.locator('.cart-item')).toHaveCount(3);
await page.locator('.remove-all-btn').click();
// Retries until all items are removed from the DOM
await expect(page.locator('.cart-item')).toHaveCount(0);
await expect(page.locator('.empty-cart-message')).toBeVisible();

toHaveCount vs manual count:

// AUTO-RETRYING — use this when elements appear asynchronously
await expect(page.locator('.search-result')).toHaveCount(5);
// NOT auto-retrying — use only for synchronous snapshots
const count = await page.locator('.search-result').count();
expect(count).toBeGreaterThan(0);

Remember: toHaveCount(n) retries until the match count is exactly n; toHaveCount(0) is the clean way to assert “all gone”.


These assertions check whether a form control is interactive. They auto-retry — useful for asserting that a submit button becomes enabled after required fields are filled in.

// Assert a submit button is enabled
await expect(page.locator('button[type="submit"]')).toBeEnabled();
// Assert a submit button is disabled when the form is empty
await expect(page.locator('button[type="submit"]')).toBeDisabled();

Pattern — a button that enables only when the form is valid:

Some apps keep the submit button disabled until every required field is filled. Because these assertions auto-retry, you can assert the transition without a manual wait:

// On a signup form that disables its button until the form is valid
await expect(page.locator('#signup-btn')).toBeDisabled();
await page.getByLabel('Email').fill('new@user.io');
await page.getByLabel('Password').fill('secret123');
await page.getByLabel('Confirm Password').fill('secret123');
// Playwright retries until the button flips to enabled
await expect(page.locator('#signup-btn')).toBeEnabled();

toBeEnabled vs not.toBeDisabled: They are equivalent. Choose whichever reads more naturally for your test scenario.

// These two lines assert the same thing
await expect(page.locator('#submit')).toBeEnabled();
await expect(page.locator('#submit')).not.toBeDisabled();

Remember: toBeEnabled/toBeDisabled auto-retry, so they wait for a control to flip state; toBeEnablednot.toBeDisabled.


Any Playwright assertion can be negated with .not. The negated assertion also auto-retries — it waits until the condition is false.

// Wait until an element is no longer visible (e.g., a loading spinner)
await expect(page.locator('.loading-spinner')).not.toBeVisible();
// Assert that non-admin users cannot see admin controls
await expect(page.locator('#admin-panel')).not.toBeVisible();
// Assert that a URL did NOT navigate to the admin section
await expect(page).not.toHaveURL('/admin');
// Assert text is NOT present
await expect(page.locator('.status')).not.toHaveText('Error');
// Assert a field has been cleared
await expect(page.locator('#search')).not.toHaveValue('old query');

Negative assertions for access control:

test('non-admin is redirected away from admin', async ({ page }) => {
// Log in as a regular customer (not admin)
await page.goto('/admin');
// Assert that the page redirected — URL should not contain /admin
await expect(page).not.toHaveURL(/\/admin/);
// Assert that the admin dashboard title is not visible
await expect(page.locator('h1')).not.toHaveText('Admin Dashboard');
// Assert the error alert is visible instead
await expect(page.locator('.alert.alert-error')).toBeVisible();
});

Remember: .not retries too — it waits until the condition becomes false (a spinner disappearing, a field clearing).


By default, a failed assertion immediately stops the test. Soft assertions are different — the test continues even when a soft assertion fails. All failures are collected and reported together at the end of the test.

// Soft assertions — test continues even if these fail
await expect.soft(page.locator('.widget-revenue')).toBeVisible();
await expect.soft(page.locator('.widget-orders')).toBeVisible();
await expect.soft(page.locator('.widget-users')).toBeVisible();
await expect.soft(page.locator('.widget-products')).toBeVisible();
// At the end of the test, Playwright reports ALL failures at once
// even if one of the above failed

When to use soft assertions:

  • Dashboard smoke tests — you want to check 10+ widgets and see all failures, not just the first
  • Visual completeness checks — verifying that all sections of a page rendered correctly
  • Parallel form validation — checking multiple fields at once
test('admin dashboard smoke test', async ({ page }) => {
// (logged in as the admin)
await page.goto('/admin');
// Check every stat card — report all missing ones at once
await expect.soft(page.getByRole('heading', { name: 'Total Products' })).toBeVisible();
await expect.soft(page.getByRole('heading', { name: 'Total Users' })).toBeVisible();
await expect.soft(page.getByRole('heading', { name: 'Total Orders' })).toBeVisible();
await expect.soft(page.getByRole('heading', { name: 'Recent Orders' })).toBeVisible();
// Optional: assert no soft failures accumulated
expect(test.info().errors).toHaveLength(0);
});

Caution: Soft assertions are powerful but easy to misuse. If a test has 15 soft assertions and 10 fail, the report becomes noisy. Use them deliberately for smoke checks, not as a way to avoid thinking about test structure.

Remember: expect.soft(...) records the failure and keeps going — for smoke checks, not critical-path flow.


By default, every web-first assertion waits up to 5 seconds before failing. You can override this per-assertion or globally.

// Wait up to 10 seconds for this specific assertion
await expect(page.locator('.slow-component')).toBeVisible({ timeout: 10_000 });
// Short timeout — fail fast if the element doesn't appear quickly
await expect(page.locator('.instant-feedback')).toBeVisible({ timeout: 1_000 });
// Custom timeout on any assertion
await expect(page.locator('.report-table')).toHaveCount(50, { timeout: 15_000 });
playwright.config.js
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
timeout: 10_000, // 10 seconds for all assertions
},
});

When to use custom timeouts:

  • Known-slow operations (report generation, file processing, email delivery)
  • CI environments that are significantly slower than local dev
  • Third-party widgets that take time to initialize

When NOT to use custom timeouts: as a band-aid for a slow or flaky app. If you’re adding timeout: 30_000 everywhere, the right fix is to profile the app, not the test.

Remember: override with { timeout } per assertion or globally via expect.timeout — for genuinely slow operations, not to mask a flaky app.


I want to assert…Use
Element is visible / hiddentoBeVisible() · not.toBeVisible()
Element is in the DOM (maybe hidden)toBeAttached()
Exact texttoHaveText('…')
Substring of the texttoContainText('…')
Form-field valuetoHaveValue('…')
An attribute’s valuetoHaveAttribute('name', '…')
How many elements matchtoHaveCount(n) (incl. 0)
Control is interactivetoBeEnabled() · toBeDisabled()
Checkbox / radio statetoBeChecked() (see Module 3)
Current page URLtoHaveURL('…' or /regex/)
Collect every failure on a pageexpect.soft(…)
Wait longer for a slow element…({ timeout: 10_000 })


These exercises use TestMarket Lab running locally at http://localhost:3000.


Create tests/starter/mod4/cart-assertions.spec.js. A beforeEach logs in; each test then asserts a cart state with web-first assertions.

import { test, expect } from '@playwright/test';
test.describe('Cart assertions', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/auth/login');
await page.locator('#email').fill('customer@test.io');
await page.locator('#password').fill('customer123');
await page.getByTestId('login-submit').click();
await expect(page.locator('.alert.alert-success')).toBeVisible();
});
test('cart with items shows rows, a total, and a checkout link', async ({ page }) => {
await page.goto('/products');
await page.getByTestId('add-to-cart').first().click();
await page.goto('/cart');
await expect(page.locator('.cart-table .table-row')).not.toHaveCount(0);
await expect(page.locator('.total-row h3')).toBeVisible();
await expect(page.getByRole('link', { name: 'Proceed to Checkout' })).toBeVisible();
});
test('empty cart shows the empty-state message', async ({ page }) => {
// A fresh browser context starts with an empty (session-scoped) cart
await page.goto('/cart');
await expect(page.getByText('Your cart is empty')).toBeVisible();
await expect(page.locator('.cart-table .table-row')).toHaveCount(0);
await expect(page.locator('.total-row h3')).not.toBeVisible();
});
});

Tasks:

  • In the first test, change not.toHaveCount(0) to toHaveCount(5) and watch the assertion retry for the full timeout before failing.
  • Delete the add-to-cart line — now not.toHaveCount(0) fails because the cart is empty.

Exercise 2: Checkout confirmation assertions

Section titled “Exercise 2: Checkout confirmation assertions”

Create tests/starter/mod4/checkout-assertions.spec.js. The beforeEach logs in, adds a product, and opens checkout.

import { test, expect } from '@playwright/test';
test.describe('Checkout assertions', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/auth/login');
await page.locator('#email').fill('customer@test.io');
await page.locator('#password').fill('customer123');
await page.getByTestId('login-submit').click();
await page.goto('/products');
await page.getByTestId('add-to-cart').first().click();
await page.goto('/checkout');
});
test('placing an order lands on the confirmation page', async ({ page }) => {
await page.locator('#shipping_name').fill('Jane Doe');
await page.locator('#shipping_address').fill('123 Main Street');
await page.locator('#shipping_city').fill('Portland');
await page.locator('#shipping_zip').fill('97201');
await page.getByTestId('place-order').click();
// Dynamic order URL — match with a regex
await expect(page).toHaveURL(/\/orders\/\d+/);
// The heading reads "Order Confirmation" — substring match
await expect(page.locator('h1')).toContainText('Order');
await expect(page.locator('.alert.alert-success')).toBeVisible();
});
test('empty form is blocked by HTML5 validation', async ({ page }) => {
// Required fields stop the submit client-side — the page never navigates
await page.getByTestId('place-order').click();
await expect(page).toHaveURL(/\/checkout/);
});
});

Tasks:

  • Swap toContainText('Order') for toHaveText('Order') — the exact match fails (the real text is “Order Confirmation”); switch it back.
  • In the empty-form test, fill only #shipping_name, then submit — the next missing required field still blocks navigation.

Exercise 3: Admin assertions — table + access control

Section titled “Exercise 3: Admin assertions — table + access control”

Create tests/starter/mod4/admin-assertions.spec.js. One test logs in as the admin; the other proves a customer is locked out.

import { test, expect } from '@playwright/test';
test.describe('Admin assertions', () => {
test('admin products page shows rows and a New Product button', async ({ page }) => {
await page.goto('/auth/login');
await page.locator('#email').fill('admin@test.io');
await page.locator('#password').fill('admin123');
await page.getByTestId('login-submit').click();
await page.goto('/admin/products');
await expect(page.locator('.table-row')).not.toHaveCount(0);
await expect(page.getByRole('link', { name: 'New Product' })).toBeVisible();
});
test('a customer is redirected away from /admin with an error', async ({ page }) => {
await page.goto('/auth/login');
await page.locator('#email').fill('customer@test.io');
await page.locator('#password').fill('customer123');
await page.getByTestId('login-submit').click();
await page.goto('/admin');
// Redirected off /admin (the app sends non-admins to the home page)...
await expect(page).not.toHaveURL(/\/admin/);
// ...and the access-denied flash renders on that destination page
await expect(page.locator('.alert.alert-error')).toContainText('Access denied');
});
});

Tasks:

  • In the second test, swap the customer credentials for the admin ones — the redirect assertion now fails because the admin actually reaches /admin.
  • Add await expect(page.locator('.alert.alert-error')).not.toBeVisible(); to the first test to confirm no error shows on a valid admin load.

Exercise 4: Replace fixed waits with assertions

Section titled “Exercise 4: Replace fixed waits with assertions”

Refactor this flaky test — replace every waitForTimeout with the correct web-first assertion:

// BEFORE (flaky — fixed sleeps everywhere)
import { test, expect } from '@playwright/test';
test('flaky search test', async ({ page }) => {
await page.goto('/products');
await page.waitForTimeout(2000);
await page.fill('#search', 'Wireless');
await page.waitForTimeout(1000);
await page.click('#apply-filters');
await page.waitForTimeout(3000);
const count = await page.locator('.product-card').count();
expect(count).toBeGreaterThan(0);
await page.waitForTimeout(1000);
expect(page.url()).toContain('search=Wireless');
});
SleepReplace with
waitForTimeout(2000)Remove — fill() already auto-waits
waitForTimeout(1000)Remove — click() already auto-waits
waitForTimeout(3000) + manual countexpect(locator).not.toHaveCount(0)
waitForTimeout(1000) + page.url()expect(page).toHaveURL(/[?&]search=Wireless/)
// AFTER (web-first — no fixed sleeps)
import { test, expect } from '@playwright/test';
test('reliable search test', async ({ page }) => {
await page.goto('/products');
await page.locator('#search').fill('Wireless');
await page.locator('#apply-filters').click();
await expect(page.locator('.product-card')).not.toHaveCount(0);
await expect(page).toHaveURL(/[?&]search=Wireless/);
});

Exercise 5: Soft assertions — check every widget at once

Section titled “Exercise 5: Soft assertions — check every widget at once”

Create tests/starter/mod4/soft-assertions.spec.js. This one is fully self-contained — it injects a mock dashboard with page.evaluate, so it runs against any page.

import { test, expect } from '@playwright/test';
test('a dashboard smoke check with soft assertions', async ({ page }) => {
await page.goto('/products');
// Inject a mock dashboard with four stat widgets
await page.evaluate(() => {
const panel = document.createElement('div');
panel.id = 'dashboard';
panel.innerHTML = `
<div data-testid="widget-revenue">Revenue: $12,400</div>
<div data-testid="widget-orders">Orders: 87</div>
<div data-testid="widget-users">Users: 1,203</div>
<div data-testid="widget-products">Products: 56</div>`;
document.body.appendChild(panel);
});
// Soft assertions: every widget is checked even if an earlier one fails,
// and all failures are reported together at the end of the test.
await expect.soft(page.getByTestId('widget-revenue')).toBeVisible();
await expect.soft(page.getByTestId('widget-orders')).toBeVisible();
await expect.soft(page.getByTestId('widget-users')).toBeVisible();
await expect.soft(page.getByTestId('widget-products')).toBeVisible();
// Optional gate: fail the test if any soft assertion failed
expect(test.info().errors).toHaveLength(0);
});

Tasks:

  • Delete the widget-users line from the injected HTML (leave its assertion) and rerun — the test still checks all four widgets, then reports the one failure at the end instead of stopping at it.
  • Change all four expect.soft to plain expect, with a widget still missing — now the test stops at the first failure and never checks the rest. That contrast is the whole point of soft assertions.

  1. Why does expect(locator).toBeVisible() retry automatically, but expect(await locator.isVisible()).toBe(true) does not?
  2. When would you use toHaveText vs toContainText? Give an example of each.
  3. What does toHaveCount(0) do if there are currently 5 matching elements?
  4. What is the difference between a regular assertion and a soft assertion (expect.soft)?
  5. How do you set a per-assertion timeout? How do you set a global default?
  6. Why is toBeEnabled useful for form validation tests?
  7. What does toHaveAttribute('href', '/terms') assert?
  8. Why is expect(page.url()).toContain('/admin') worse than expect(page).toHaveURL('/admin')?

Four rules to carry into every test:

  1. Always pass a Locator into expect() — never an awaited value. expect(locator).toBeVisible() retries; expect(await locator.isVisible()).toBe(true) does not.
  2. toContainText for dynamic content, toHaveText for exact strings — when in doubt, prefer toContainText
  3. toHaveCount retries on element lists — use it instead of count() whenever elements appear asynchronously
  4. Soft assertions for smoke tests, hard assertions for flow tests — collect all failures on a page-level check; fail fast on critical path tests

Module 5 covers auto-waiting — you’ll learn how Playwright’s built-in action checks eliminate explicit waits, and what to do when they’re not enough.