Playwright Assertions — The Complete Guide
Without assertions, your test is just a script that visits a page and leaves. Assertions are the judge and jury — they decide if the application works or doesn't. Master them, and your tests become trustworthy proof of quality.
📖 The Story: The Test That Never Failed
There was a QA engineer named Marco who wrote 300 tests. Every single one passed every single time. The team celebrated — 100% green! Then one day, a junior developer accidentally deleted the entire homepage. Marco's tests still passed. Every. Single. One.
How? Marco had written tests that clicked buttons and filled forms but never checked if anything actually worked. No assertions. No verification. The tests ran, the browser opened, things happened — but nobody was watching the results. The tests were vacations for the browser, not quality checks.
The CTO said the famous words: "A test without an assertion is a lie that tells you everything is fine."
That day, Marco added assertions to every test. The next run, 47 tests failed — catching real bugs that had been hiding for months. The green dashboard was finally honest.
Assertions are not optional. They are the entire reason tests exist.
🎯 Why Assertions Are Everything
Verification
Assertions prove the application actually works — not just that it didn't crash.
Auto-Retry
Playwright assertions retry automatically, handling slow renders and animations.
Reporting
When an assertion fails, Playwright captures screenshots, traces, and error details.
🧒 Explain Like I'm 10
Imagine you're a teacher grading a math test. A student writes "2 + 2 =". Without checking the answer, you stamp it "PASS." That's a test without assertions — you're just confirming the student wrote something, not that it's correct.
Now imagine you check: "Does 2 + 2 = 4?" If yes, PASS. If no, FAIL with a red mark explaining why. That's an assertion — it compares what you expect against what actually happened. If they don't match, the test stops and tells you exactly what went wrong.
🎓 Professional Explanation
Playwright provides two assertion modules: expect from @playwright/test (auto-retrying, async-aware) and the standard Node.js expect from Jest (synchronous, no retry). The Playwright expect wraps locator and page objects with a polling mechanism that re-evaluates the condition every expect.timeout milliseconds until it passes or the timeout expires. This is the architectural reason Playwright tests are dramatically less flaky than Selenium tests that use static assertions.
📋 Assertion Categories
| Category | Target | Key Methods | Auto-Retry |
|---|---|---|---|
| Page Assertions | page object | toHaveTitle, toHaveURL | ✅ Yes |
| Locator Assertions | Elements | toBeVisible, toHaveText, toBeEnabled | ✅ Yes |
| Snapshot Assertions | Visual | toMatchAriaSnapshot | ✅ Yes |
| Generic Assertions | Any value | toBe, toEqual, toBeTruthy | ❌ No |
| Soft Assertions | Non-critical | expect.soft() | ✅ Yes |
🌐 Page Assertions
// Assert page title (exact string) await expect(page).toHaveTitle('Dashboard — My App'); // Assert page title (regex — recommended for resilience) await expect(page).toHaveTitle(/Dashboard/); // Assert current URL (exact) await expect(page).toHaveURL('https://app.com/dashboard'); // Assert URL contains a path (regex) await expect(page).toHaveURL(/dashboard/);
Always prefer regex over exact strings. Titles and URLs often include dynamic parts (user names, IDs, timestamps). Regex matches survive these changes.
🔍 Locator Assertions — The Core
These are the assertions you'll use 90% of the time. Each one operates on a locator and auto-retries until the condition is met.
Visibility
// Element is visible on the page await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); // Element is NOT visible (hidden or not in DOM) await expect(page.getByText('Loading...')).toBeHidden(); // or not.toBeVisible() // Element is in the DOM but not visible (display:none, etc.) await expect(page.getByTestId('error-banner')).not.toBeVisible();
Text Content
// Exact text match — entire content must match await expect(page.getByRole('heading')).toHaveText('Welcome'); // Partial text match — substring is enough (PREFERRED) await expect(page.getByRole('heading')).toContainText('Welcome'); // Multiple elements — array of texts await expect(page.getByRole('listitem')).toContainText(['Apple', 'Banana', 'Cherry']); // Regex match await expect(page.getByText('Items in cart')).toHaveText(/\d+ items? in cart/);
The #1 mistake: Using toHaveText when you mean toContainText. If a heading says "Welcome back, Alex!", toHaveText('Welcome') FAILS but toContainText('Welcome') PASSES. Always prefer toContainText unless you need exact matching.
State
// Button/input is enabled await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); // Button/input is disabled await expect(page.getByRole('button')).toBeDisabled(); // Checkbox/radio is checked await expect(page.getByRole('checkbox')).toBeChecked(); // Input has specific value await expect(page.getByLabel('Email')).toHaveValue('user@example.com'); // Input has placeholder await expect(page.getByPlaceholder('Enter email')).toBeVisible();
Count & Count
// Assert number of matching elements await expect(page.getByRole('listitem')).toHaveCount(5); // Assert at least one element exists await expect(page.getByText('No results')).toHaveCount(0);
CSS & Attributes
// Assert CSS class await expect(page.getByRole('button')).toHaveClass(/active/); // Assert CSS property await expect(page.getByRole('alert')).toHaveCSS('color', 'rgb(255, 0, 0)'); // Assert ARIA attribute await expect(page.getByRole('textbox')).toHaveAttribute('aria-required', 'true');
🚫 Negation with .not
// Element is NOT visible await expect(page.getByText('Error')).not.toBeVisible(); // Element does NOT have text await expect(page.getByRole('heading')).not.toContainText('Error'); // Input is NOT disabled await expect(page.getByRole('button')).not.toBeDisabled();
🔧 Generic Assertions (Synchronous)
For comparing plain JavaScript values (not browser elements), use generic assertions. These do NOT auto-retry.
// Compare numbers expect(200).toBe(200); expect(items.length).toBeGreaterThan(0); // Compare strings expect(userName).toContain('Admin'); // Compare objects expect(config).toEqual({ retries: 2, workers: 4 }); // Truthiness expect(isLoggedIn).toBeTruthy(); expect(error).toBeNull();
Critical: Generic assertions have NO auto-retry. If you need to check a value that changes over time (like a counter), use expect.poll() instead.
⏳ expect.poll() — Retry Generic Assertions
When you need auto-retry behavior on a non-locator value, use expect.poll():
// Poll a value until it meets the condition await expect.poll(() => cartItemCount()).toBe(3); // Poll an API endpoint until it returns expected data await expect.poll(async () => { const resp = await request.get('/api/status'); return resp.status(); }).toBe(200);
When to use: Checking API responses, reading localStorage, or verifying values computed from the DOM that aren't covered by locator assertions.
⏱️ Configuring Assertion Timeouts
// Global config (playwright.config.ts) expect: { timeout: 5000 } // default: 5000ms // Per-assertion timeout await expect(page.getByText('Loaded')).toBeVisible({ timeout: 10000 }); // For slow operations like file uploads test.slow(); // Triples all timeouts for this test
⚠️ Common Mistakes
Mistake 1: Using Generic Assertions for DOM Elements
// ❌ Generic — no auto-retry, fails if element not rendered yet expect(await page.getByText('Welcome').isVisible()).toBe(true); // ✅ Locator assertion — auto-retries until visible await expect(page.getByText('Welcome')).toBeVisible();
Why it matters: The first version checks ONCE and fails if the element isn't rendered yet. The second version keeps checking for up to 5 seconds, handling slow renders, animations, and network delays automatically.
Mistake 2: Asserting with toHaveText When You Mean toContainText
// ❌ Fails if heading is "Welcome back, Alex!" instead of just "Welcome" await expect(heading).toHaveText('Welcome'); // ✅ Passes as long as "Welcome" is somewhere in the text await expect(heading).toContainText('Welcome');
Mistake 3: Not Awaiting Assertions
// ❌ Missing await — assertion never executes expect(page.getByText('Welcome')).toBeVisible(); // ✅ Always await await expect(page.getByText('Welcome')).toBeVisible();
What happens without await: The assertion is created but never evaluated. The test passes regardless of whether the element is visible. This is a silent failure — the most dangerous kind.
✅ Best Practices
- Always use locator assertions (
toBeVisible,toContainText) over generic assertions for DOM elements. - Always
awaitassertions — fire-and-forget assertions never execute. - Prefer
toContainTextovertoHaveTextfor resilience. - Use
.notfor negative assertions instead of manual checks. - Set assertion timeouts globally — increase only for specific slow operations.
- Use
expect.poll()for non-DOM values that need auto-retry. - Assert one key outcome per test step — don't stack 10 assertions on the same element.
🏗️ Senior Engineer Deep Dive
Auto-Retry Internals
When you write await expect(locator).toBeVisible(), Playwright executes this internally:
- Creates an
Expectobject wrapping the locator - Registers a matcher function (
toBeVisible) that evaluates the condition - Starts a polling loop with
setInterval(default interval:expect.timeout / 100) - On each poll: queries the DOM, evaluates the matcher, checks if result is truthy
- If truthy: resolves the promise, assertion passes
- If falsy and timeout expired: captures a screenshot + error snapshot, throws with detailed message
- If falsy but timeout remains: continues polling
Why Generic Assertions Don't Auto-Retry
Generic assertions like expect(1 + 1).toBe(2) operate on pure JavaScript values that don't change over time. There's nothing to "wait for" — the value is either correct or it isn't. Adding auto-retry to these would be meaningless and could mask real bugs. Only assertions on locators and page objects need retry because the DOM changes asynchronously.
Soft Assertions for Non-Critical Checks
Use expect.soft() for visual or informational checks that shouldn't stop the test:
// Critical — stops test on failure await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); // Non-critical — logs failure but test continues await expect.soft(page.getByAltText('Logo')).toBeVisible();
💼 Interview Questions
toHaveText and toContainText?Show answer
toHaveText performs an exact match against the element's full text content. toContainText checks if the text includes the substring. Use toContainText for resilience — it survives minor text changes. Use toHaveText only when exact text is critical (e.g., error messages).Show answer
expect(locator).toBeVisible() is executed?Show answer
expect(page.getByText('Success')).toBeVisible() but fails intermittently. The text IS on the page when you check manually. What do you investigate?Show answer
🏋️ Practical Exercises
Assertion Practice
- Navigate to
https://playwright.dev. - Assert the page title contains "Playwright" using regex.
- Assert the "Get started" link is visible.
- Assert the navigation bar is visible using
getByRole('navigation'). - Assert the page URL contains "playwright".
Advanced Assertion Patterns
- Navigate to
https://demo.playwright.dev/todomvc. - Add 3 todo items. Assert the count shows "3 items left" using
toContainText. - Complete one item. Assert the count changes to "2 items left".
- Assert exactly 3 list items exist using
toHaveCount. - Assert the "Clear completed" button is NOT visible until you complete an item, then assert it IS visible after.
🧩 Mini Project
🎯 Project: Assertion-Driven Test Suite
- Create
tests/assertions.spec.ts. - Write a test for each of these assertion methods: toBeVisible, toContainText, toHaveURL, toBeEnabled, toHaveCount, toBeChecked, .not.toBeVisible.
- Use a different target page for each test (playwright.dev, todomvc, etc.).
- Add at least one
expect.soft()assertion and observe how it reports. - Add one
expect.poll()assertion for a dynamic value.
🧠 Quiz
expect different from Jest's?toHaveTexttoContainTexttoBeVisibletoHaveValueawait before an assertion?.reject().not.negate().inverse()expect.poll()?expect.soft() do?toHaveTexttoContainTexttoMatchTextincludesTextexpect(await locator.isVisible()).toBe(true)?toHaveLengthtoHaveCounttoBeMultipletoHaveLength❓ FAQ
Show answer
expect has auto-retry built in for locator and page assertions. Chai and Jest matchers don't auto-retry, making them flaky. Use Playwright's expect for everything browser-related.toBeHidden and not.toBeVisible?Show answer
toBeHidden passes if the element is either not in the DOM or has display:none/visibility:hidden. not.toBeVisible is the negated form of toBeVisible and behaves the same way. Choose whichever reads more naturally in context.Show answer
toBeFocused(): await expect(page.getByRole('textbox')).toBeFocused(). This auto-retries, making it reliable for testing focus behavior after tab navigation or click interactions.📌 Summary
🎯 Key Takeaways
- Locator assertions auto-retry — always use them over generic assertions for DOM elements.
- Always
awaitassertions — fire-and-forget = silent failures. - Prefer
toContainTextovertoHaveTextfor resilient tests. - Use
.notfor negation instead of manual boolean checks. - Generic assertions don't auto-retry — use
expect.poll()when needed. - Soft assertions (
expect.soft()) for non-critical checks. - Default timeout: 5 seconds — configure globally or per-assertion.
- Page assertions:
toHaveTitle,toHaveURL— always assert after navigation. - State assertions:
toBeEnabled,toBeDisabled,toBeChecked,toHaveValue. - Count:
toHaveCountfor exact matches,toHaveCount(0)for absence.
