📖 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

CategoryTargetKey MethodsAuto-Retry
Page Assertionspage objecttoHaveTitle, toHaveURL✅ Yes
Locator AssertionsElementstoBeVisible, toHaveText, toBeEnabled✅ Yes
Snapshot AssertionsVisualtoMatchAriaSnapshot✅ Yes
Generic AssertionsAny valuetoBe, toEqual, toBeTruthy❌ No
Soft AssertionsNon-criticalexpect.soft()✅ Yes

🌐 Page Assertions

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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.

typescript
// 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():

typescript
// 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

typescript
// 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

typescript · ❌ No auto-retry
// ❌ 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

typescript · ❌ Over-strict
// ❌ 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

typescript · ❌ Fire-and-forget
// ❌ 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

  1. Always use locator assertions (toBeVisible, toContainText) over generic assertions for DOM elements.
  2. Always await assertions — fire-and-forget assertions never execute.
  3. Prefer toContainText over toHaveText for resilience.
  4. Use .not for negative assertions instead of manual checks.
  5. Set assertion timeouts globally — increase only for specific slow operations.
  6. Use expect.poll() for non-DOM values that need auto-retry.
  7. 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:

  1. Creates an Expect object wrapping the locator
  2. Registers a matcher function (toBeVisible) that evaluates the condition
  3. Starts a polling loop with setInterval (default interval: expect.timeout / 100)
  4. On each poll: queries the DOM, evaluates the matcher, checks if result is truthy
  5. If truthy: resolves the promise, assertion passes
  6. If falsy and timeout expired: captures a screenshot + error snapshot, throws with detailed message
  7. 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:

typescript
// 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

Beginner
What is the difference between 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).
Intermediate
Why do Playwright locator assertions auto-retry but generic assertions don't?
Show answer
Locator assertions operate on DOM elements that change asynchronously — the element might not exist yet when the assertion first evaluates. Auto-retry handles this. Generic assertions operate on pure JavaScript values (numbers, strings, objects) that are computed instantly and don't change over time. Retrying them would be meaningless.
Advanced
What happens internally when expect(locator).toBeVisible() is executed?
Show answer
Playwright creates an Expect object, registers the toBeVisible matcher, and starts a polling loop. On each poll (interval: timeout/100), it queries the DOM via the locator, evaluates the visibility condition, and checks the result. If truthy, the promise resolves (pass). If falsy and timeout expired, it captures a screenshot and throws a detailed error. If falsy but timeout remains, it continues polling.
Scenario-Based
Your test asserts expect(page.getByText('Success')).toBeVisible() but fails intermittently. The text IS on the page when you check manually. What do you investigate?
Show answer
1) The text might appear briefly then disappear (toast notification) — assert it's in a persistent container. 2) The default 5s timeout might not be enough on slow CI — increase timeout. 3) There might be multiple matching elements — use a more specific locator. 4) Enable trace on first retry to see exactly what the page looked like when the assertion failed.

🏋️ Practical Exercises

🛠️ Hands-On Beginner

Assertion Practice

  1. Navigate to https://playwright.dev.
  2. Assert the page title contains "Playwright" using regex.
  3. Assert the "Get started" link is visible.
  4. Assert the navigation bar is visible using getByRole('navigation').
  5. Assert the page URL contains "playwright".
🔥 Challenge Intermediate

Advanced Assertion Patterns

  1. Navigate to https://demo.playwright.dev/todomvc.
  2. Add 3 todo items. Assert the count shows "3 items left" using toContainText.
  3. Complete one item. Assert the count changes to "2 items left".
  4. Assert exactly 3 list items exist using toHaveCount.
  5. 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

  1. Create tests/assertions.spec.ts.
  2. Write a test for each of these assertion methods: toBeVisible, toContainText, toHaveURL, toBeEnabled, toHaveCount, toBeChecked, .not.toBeVisible.
  3. Use a different target page for each test (playwright.dev, todomvc, etc.).
  4. Add at least one expect.soft() assertion and observe how it reports.
  5. Add one expect.poll() assertion for a dynamic value.

🧠 Quiz

1. What makes Playwright's expect different from Jest's?
It has more assertion methods
It auto-retries on locator and page objects
It's faster
It doesn't require await
2. Which assertion checks if text appears anywhere in an element?
toHaveText
toContainText
toBeVisible
toHaveValue
3. What happens if you forget await before an assertion?
It still works
It throws immediately
The assertion never executes — silent failure
It runs synchronously
4. What is the default assertion timeout?
1 second
3 seconds
5 seconds
30 seconds
5. How do you negate an assertion in Playwright?
.reject()
.not
.negate()
.inverse()
6. When should you use expect.poll()?
For all assertions
For locator assertions
For non-DOM values that change over time
Never — it's deprecated
7. What does expect.soft() do?
Makes the test run slower
Logs failures without stopping the test
Skips the assertion
Reduces the timeout
8. Which is the recommended text assertion for most cases?
toHaveText
toContainText
toMatchText
includesText
9. Why should you avoid expect(await locator.isVisible()).toBe(true)?
It's too slow
It doesn't auto-retry — checks once and fails
It's deprecated
It throws a syntax error
10. What assertion checks how many elements match a locator?
toHaveLength
toHaveCount
toBeMultiple
toHaveLength

❓ FAQ

Can I use Chai or Jest matchers with Playwright?
Show answer
Technically yes, but it's strongly discouraged. Playwright's built-in 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.
What's the difference between toBeHidden and not.toBeVisible?
Show answer
Functionally identical for most cases. 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.
How do I assert that an element has focus?
Show answer
Use 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 await assertions — fire-and-forget = silent failures.
  • Prefer toContainText over toHaveText for resilient tests.
  • Use .not for 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: toHaveCount for exact matches, toHaveCount(0) for absence.