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
Web-first assertions
Section titled “Web-first assertions”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 pagesconst 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.
| Pattern | Auto-retries? | Use this |
|---|---|---|
expect(locator).toBeVisible() | Yes | Always |
expect(await locator.isVisible()).toBe(true) | No | Never |
expect(locator).toHaveText('...') | Yes | Always |
expect(await locator.textContent()).toBe('...') | No | Never |
Remember: pass a Locator into expect() — it polls and retries; an already-awaited boolean is checked once and can’t retry.
toBeVisible
Section titled “toBeVisible”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 visibleawait expect(page.locator('.welcome-message')).toBeVisible();
// Assert that a loading spinner has disappearedawait expect(page.locator('.spinner')).not.toBeVisible();
// Assert that a modal is shown after clickingawait 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 screentoBeAttached— 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 notawait expect(page.locator('[data-testid="lazy-component"]')).toBeAttached();
// Use toBeVisible when the user must be able to see itawait expect(page.locator('.notification')).toBeVisible();Remember: toBeVisible = in the DOM and on screen; toBeAttached = in the DOM even when hidden.
toHaveText vs toContainText
Section titled “toHaveText vs toContainText”This is the most common source of assertion confusion. Choosing the wrong one costs real debugging time.
toHaveText — exact match
Section titled “toHaveText — exact match”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 requiredawait 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 — substring match
Section titled “toContainText — substring match”toContainText asserts that the element’s text includes the expected string anywhere within it.
// Passes if the text contains "Order" anywhereawait expect(page.locator('h1')).toContainText('Order');
// Very useful for dynamic content — IDs, dates, usernamesawait expect(page.locator('.confirmation')).toContainText('order #');
// Regex with substring semanticsawait expect(page.locator('.message')).toContainText(/\d+ items/);Choosing between them
Section titled “Choosing between them”// toHaveText — when you control the exact stringawait expect(page.locator('.cart-empty')).toHaveText('Your cart is empty');
// toContainText — when the string is dynamic or surrounded by other textawait expect(page.locator('h1')).toContainText('Order'); // matches "Order #12345"
// toHaveURL with regex — for dynamic URLsawait 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
Section titled “toHaveValue”toHaveValue asserts the current value of a form field — <input>, <textarea>, or <select>.
// Assert the value of a text inputawait expect(page.locator('#email')).toHaveValue('customer@test.io');
// Assert a textareaawait expect(page.locator('#description')).toHaveValue('Test product description');
// Assert a select dropdown's current selectionawait expect(page.locator('#category')).toHaveValue('electronics');
// Use a regex for partial value matchingawait 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
Section titled “toHaveAttribute”toHaveAttribute asserts that an element has a specific attribute with a specific value.
// Assert an href attributeawait expect(page.getByRole('link', { name: 'Terms' })).toHaveAttribute( 'href', '/terms');
// Assert a data attributeawait expect(page.locator('.product-card').first()).toHaveAttribute( 'data-product-id', '42');
// Assert aria attributes for accessibility testingawait expect(page.getByRole('button', { name: 'Submit' })).toHaveAttribute( 'aria-disabled', 'true');
// Use regex for partial attribute valuesawait 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 inputawait 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
Section titled “toHaveCount”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 listawait expect(page.locator('.cart-item')).toHaveCount(3);
// Assert the table has exactly 5 rowsawait expect(page.locator('.product-table tbody tr')).toHaveCount(5);
// Assert that items were removed — retries until count reaches 0await 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 stateawait expect(page.locator('.cart-item')).toHaveCount(3);
await page.locator('.remove-all-btn').click();
// Retries until all items are removed from the DOMawait 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 asynchronouslyawait expect(page.locator('.search-result')).toHaveCount(5);
// NOT auto-retrying — use only for synchronous snapshotsconst 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”.
toBeEnabled and toBeDisabled
Section titled “toBeEnabled and toBeDisabled”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 enabledawait expect(page.locator('button[type="submit"]')).toBeEnabled();
// Assert a submit button is disabled when the form is emptyawait 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 validawait 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 enabledawait 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 thingawait 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; toBeEnabled ≡ not.toBeDisabled.
Negative assertions
Section titled “Negative assertions”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 controlsawait expect(page.locator('#admin-panel')).not.toBeVisible();
// Assert that a URL did NOT navigate to the admin sectionawait expect(page).not.toHaveURL('/admin');
// Assert text is NOT presentawait expect(page.locator('.status')).not.toHaveText('Error');
// Assert a field has been clearedawait 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).
Soft assertions
Section titled “Soft assertions”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 failawait 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 failedWhen 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.
Custom expect timeouts
Section titled “Custom expect timeouts”By default, every web-first assertion waits up to 5 seconds before failing. You can override this per-assertion or globally.
Per-assertion timeout
Section titled “Per-assertion timeout”// Wait up to 10 seconds for this specific assertionawait expect(page.locator('.slow-component')).toBeVisible({ timeout: 10_000 });
// Short timeout — fail fast if the element doesn't appear quicklyawait expect(page.locator('.instant-feedback')).toBeVisible({ timeout: 1_000 });
// Custom timeout on any assertionawait expect(page.locator('.report-table')).toHaveCount(50, { timeout: 15_000 });Global timeout in playwright.config.js
Section titled “Global timeout in 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.
Assertion cheat-sheet
Section titled “Assertion cheat-sheet”| I want to assert… | Use |
|---|---|
| Element is visible / hidden | toBeVisible() · not.toBeVisible() |
| Element is in the DOM (maybe hidden) | toBeAttached() |
| Exact text | toHaveText('…') |
| Substring of the text | toContainText('…') |
| Form-field value | toHaveValue('…') |
| An attribute’s value | toHaveAttribute('name', '…') |
| How many elements match | toHaveCount(n) (incl. 0) |
| Control is interactive | toBeEnabled() · toBeDisabled() |
| Checkbox / radio state | toBeChecked() (see Module 3) |
| Current page URL | toHaveURL('…' or /regex/) |
| Collect every failure on a page | expect.soft(…) |
| Wait longer for a slow element | …({ timeout: 10_000 }) |
Exercises
Section titled “Exercises”These exercises use TestMarket Lab running locally at http://localhost:3000.
Exercise 1: Assert cart state
Section titled “Exercise 1: Assert cart state”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)totoHaveCount(5)and watch the assertion retry for the full timeout before failing. - Delete the
add-to-cartline — nownot.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')fortoHaveText('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');});| Sleep | Replace with |
|---|---|
waitForTimeout(2000) | Remove — fill() already auto-waits |
waitForTimeout(1000) | Remove — click() already auto-waits |
waitForTimeout(3000) + manual count | expect(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-usersline 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.softto plainexpect, 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.
Self-check questions
Section titled “Self-check questions”- Why does
expect(locator).toBeVisible()retry automatically, butexpect(await locator.isVisible()).toBe(true)does not? - When would you use
toHaveTextvstoContainText? Give an example of each. - What does
toHaveCount(0)do if there are currently 5 matching elements? - What is the difference between a regular assertion and a soft assertion (
expect.soft)? - How do you set a per-assertion timeout? How do you set a global default?
- Why is
toBeEnableduseful for form validation tests? - What does
toHaveAttribute('href', '/terms')assert? - Why is
expect(page.url()).toContain('/admin')worse thanexpect(page).toHaveURL('/admin')?
Key takeaways
Section titled “Key takeaways”Four rules to carry into every test:
- Always pass a
Locatorintoexpect()— never an awaited value.expect(locator).toBeVisible()retries;expect(await locator.isVisible()).toBe(true)does not. toContainTextfor dynamic content,toHaveTextfor exact strings — when in doubt, prefertoContainTexttoHaveCountretries on element lists — use it instead ofcount()whenever elements appear asynchronously- 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.