You can write the most elegant locators and the most clever navigation logic, but without the right assertion your test proves nothing. Playwright ships with a powerful expect API that auto-retries, supports negative matching, and provides soft assertions — and most teams only use a fraction of it.
This guide walks through every assertion category with production-ready examples. By the end, you will know exactly which assertion to reach for in any scenario and how to avoid the common mistakes that cause flaky failures. If you are still learning how to use Playwright, start there first and come back here for the deep dive on assertions.
How Assertions Work in Playwright
Playwright assertions are fundamentally different from the assert module in Node.js or the expect in Jest. The critical difference is auto-retrying.
When you write await expect(locator).toBeVisible(), Playwright does not check once and fail. Instead, it repeatedly polls the DOM — roughly every 100ms — until the element becomes visible or the assertion timeout expires (default: 5 seconds). This single behavior eliminates an entire class of flaky tests caused by timing issues.
// Playwright assertion — auto-retries for 5 seconds await expect(page.getByRole('alert')).toBeVisible(); // Node.js assert — runs ONCE, fails immediately const el = await page.$('.alert'); assert(el !== null); // fragile — element may not exist yet
You can override the timeout per assertion or globally in playwright.config.ts:
export default defineConfig({ expect: { timeout: 10_000, // 10 seconds globally }, });
Tip: Only web-first assertions (those that take a locator or page) auto-retry. Generic value assertions like expect(myVariable).toBe(5) run once and are identical to Jest assertions. See the best practices guide for more on avoiding timing traps.
Page Assertions
Page-level assertions verify the current URL and document title. They are typically the first assertions in a test — confirming the page has navigated to the expected destination.
toHaveURL()
Checks that the page URL matches a string or regular expression. Auto-retries, so it naturally waits for client-side routing to complete.
// Exact match await expect(page).toHaveURL('https://example.com/dashboard'); // Regex — ignore query params await expect(page).toHaveURL(/\/dashboard/); // With custom timeout for slow redirects await expect(page).toHaveURL('https://example.com/login', { timeout: 15_000, });
toHaveTitle()
Validates the page <title> tag. Useful for SEO tests and verifying navigation landed on the right page.
// Exact title await expect(page).toHaveTitle('Dashboard | My App'); // Regex — partial match await expect(page).toHaveTitle(/Dashboard/);
Locator Assertions
Locator assertions are the most commonly used group. They check the state of an element — visible, hidden, enabled, disabled, checked, or editable. Every one of these auto-retries. For a deep dive on how to target elements before asserting on them, see the Playwright locators guide.
toBeVisible() / toBeHidden()
toBeVisible() passes when the element is attached to the DOM, is not hidden by CSS (display:none, visibility:hidden, opacity:0), and has a non-zero bounding box. toBeHidden() is the inverse.
// Wait for modal to appear await expect(page.getByRole('dialog')).toBeVisible(); // Wait for loading spinner to disappear await expect(page.getByRole('progressbar')).toBeHidden(); // Assert error banner shows after form submit await page.getByRole('button', { name: 'Submit' }).click(); await expect(page.getByRole('alert')).toBeVisible();
toBeEnabled() / toBeDisabled()
Checks the disabled attribute on form controls. Essential for multi-step forms where the "Next" button should be disabled until required fields are filled.
// Button disabled until form is valid await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); // Fill required fields, then verify button becomes enabled await page.getByLabel('Email').fill('user@test.com'); await page.getByLabel('Password').fill('SecurePass123'); await expect(page.getByRole('button', { name: 'Next' })).toBeEnabled();
toBeChecked()
Validates checkboxes and radio buttons.
await page.getByLabel('I agree to the terms').check(); await expect(page.getByLabel('I agree to the terms')).toBeChecked(); // Verify unchecked state await expect(page.getByLabel('Subscribe to newsletter')).toBeChecked({ checked: false });
toBeEditable()
Passes when an input is neither disabled nor readonly. Useful for verifying that a profile form switches from view mode to edit mode.
// Click edit, then verify fields are editable await page.getByRole('button', { name: 'Edit Profile' }).click(); await expect(page.getByLabel('Full Name')).toBeEditable();
Text Assertions
Text assertions verify what the user actually reads on the page. Playwright normalizes whitespace automatically, so extra spaces and newlines in the DOM will not cause false failures.
toHaveText() vs. toContainText()
toHaveText() matches the full text content of an element (after whitespace normalization). toContainText() checks for a substring. Both accept strings and regular expressions.
// Exact full text await expect(page.getByRole('heading', { level: 1 })) .toHaveText('Welcome to Your Dashboard'); // Substring await expect(page.getByTestId('status-badge')) .toContainText('Active'); // Regex for dynamic content await expect(page.getByTestId('order-total')) .toHaveText(/Total: \$[\d,.]+/);
Array matching with toHaveText()
When a locator matches multiple elements, you can pass an array to assert the text of all matched elements at once. This is powerful for verifying lists, table rows, or navigation items.
// Verify all nav links in order await expect(page.getByRole('navigation').getByRole('link')) .toHaveText(['Home', 'Products', 'Pricing', 'Contact']); // Regex array — each element matches its own pattern await expect(page.locator('.price')) .toHaveText([/\$\d+/, /\$\d+/, /\$\d+/]);
Tip: Use the { ignoreCase: true } option when text casing is unreliable — for example, when the backend may return "ACTIVE" or "Active" depending on the locale.
Attribute & CSS Assertions
These assertions inspect HTML attributes and computed CSS properties. They are essential for verifying styling changes, ARIA states, and dynamic class toggles.
toHaveAttribute()
// Verify link opens in new tab await expect(page.getByRole('link', { name: 'Docs' })) .toHaveAttribute('target', '_blank'); // Verify image src (regex for cache-busted URLs) await expect(page.getByAltText('Profile photo')) .toHaveAttribute('src', /\/avatars\/user-42/); // ARIA attribute await expect(page.getByRole('button', { name: 'Menu' })) .toHaveAttribute('aria-expanded', 'true');
toHaveClass()
Checks the element's class attribute. Accepts a string (full class list or substring) or regex.
// Verify active state is applied await expect(page.getByRole('tab', { name: 'Settings' })) .toHaveClass(/active/); // Exact class list await expect(page.locator('.card').first()) .toHaveClass('card card-featured');
toHaveCSS()
Asserts on computed CSS values. Useful for verifying color changes, visibility transitions, and responsive layout behavior.
// Verify error state turns border red await expect(page.getByLabel('Email')) .toHaveCSS('border-color', 'rgb(239, 68, 68)'); // Verify element is hidden via opacity await expect(page.locator('.tooltip')) .toHaveCSS('opacity', '0');
toHaveId()
A convenience assertion for the id attribute.
await expect(page.getByRole('region')) .toHaveId('main-content');
Count Assertions
toHaveCount() verifies the number of elements matching a locator. It auto-retries, so it naturally waits for items to load.
// Verify search returns exactly 5 results await expect(page.getByRole('listitem')).toHaveCount(5); // Verify cart is empty await expect(page.locator('.cart-item')).toHaveCount(0); // Verify table rows after filtering await page.getByPlaceholder('Filter by name').fill('Smith'); await expect(page.locator('tbody tr')).toHaveCount(3);
Tip: Prefer toHaveCount(0) over not.toBeVisible() when you want to assert that elements were fully removed from the DOM, not just hidden.
Value Assertions
toHaveValue() checks the current value of <input>, <textarea>, and <select> elements. For multi-select elements, use toHaveValues().
// Verify input value after programmatic fill await page.getByLabel('Quantity').fill('3'); await expect(page.getByLabel('Quantity')).toHaveValue('3'); // Verify dropdown selection await page.getByLabel('Country').selectOption('US'); await expect(page.getByLabel('Country')).toHaveValue('US'); // Multi-select — verify all selected options await page.getByLabel('Languages').selectOption(['en', 'fr', 'de']); await expect(page.getByLabel('Languages')) .toHaveValues(['en', 'fr', 'de']);
Visual Assertions
toHaveScreenshot() captures a screenshot and compares it pixel-by-pixel against a baseline image. On the first run it creates the baseline; on subsequent runs it diffs against it. For a comprehensive walkthrough, see the visual regression testing guide.
// Full-page screenshot comparison await expect(page).toHaveScreenshot('dashboard.png'); // Element-level screenshot await expect(page.locator('.chart-container')) .toHaveScreenshot('revenue-chart.png'); // Allow up to 100 pixel differences (animations, fonts) await expect(page).toHaveScreenshot('homepage.png', { maxDiffPixels: 100, }); // Mask dynamic elements (timestamps, ads, avatars) await expect(page).toHaveScreenshot('profile.png', { mask: [ page.locator('.timestamp'), page.locator('.user-avatar'), ], });
Tip: Use maxDiffPixelRatio instead of maxDiffPixels when the page size varies across test runs (responsive tests). A ratio of 0.01 allows 1% of pixels to differ.
Soft Assertions
Regular assertions stop the test immediately on failure. Soft assertions record the failure but allow the test to continue. All failures are reported together at the end.
// Check multiple dashboard elements — report ALL failures await expect.soft(page.getByTestId('total-revenue')) .toContainText('$'); await expect.soft(page.getByTestId('active-users')) .toBeVisible(); await expect.soft(page.getByTestId('chart-container')) .toBeVisible(); await expect.soft(page.getByRole('navigation')) .toContainText('Dashboard');
When to use soft assertions:
- Verifying multiple independent elements on the same page (dashboards, product cards)
- Smoke tests that check many sections at once
- Form validation where you want to see all broken fields, not just the first one
When NOT to use soft assertions:
- When the next step depends on the current assertion (login must succeed before dashboard check)
- Critical security checks that should halt the test immediately
Custom Assertion Messages
Playwright lets you add a custom message as the second argument to expect(). This message appears in the test report when the assertion fails, making debugging significantly faster.
// Without message: "Expected: visible, Received: hidden" // With message: "Login error banner should appear after invalid credentials" await expect( page.getByRole('alert'), 'Login error banner should appear after invalid credentials' ).toBeVisible(); // Useful in loops for (const item of expectedItems) { await expect( page.getByText(item), `Item "${item}" should be visible in the list` ).toBeVisible(); }
Tip: Custom messages are especially valuable in data-driven tests where the same assertion runs for many values. Without them, a failure like "expected visible" tells you nothing about which iteration failed.
Negative Assertions
The .not modifier inverts any assertion. It also auto-retries — Playwright waits until the condition is not true (or times out).
// Toast notification should disappear await expect(page.getByRole('alert')).not.toBeVisible(); // URL should NOT contain /login after successful auth await expect(page).not.toHaveURL(/\/login/); // Input should NOT have the error class await expect(page.getByLabel('Email')) .not.toHaveClass(/error/); // Button should NOT be disabled after form is complete await expect(page.getByRole('button', { name: 'Submit' })) .not.toBeDisabled();
Common Assertion Mistakes
These are the patterns that cause the most flaky failures in real-world test suites — and how to fix each one.
Mistake 1: Not awaiting assertions
Forgetting await is the single most common assertion bug. The assertion fires but the test does not wait for it, so it passes even when it should fail.
await expect(locator).toBeVisible();
expect(locator).toBeVisible();
Mistake 2: Using the wrong assertion type
Using a generic Jest-style assertion on a DOM value fetched with .textContent() defeats auto-retry.
await expect(locator).toHaveText('Hello');
const text = await locator.textContent(); expect(text).toBe('Hello');
Mistake 3: Exact string matching on dynamic content
Hard-coding timestamps, counts, or user-specific content in assertions guarantees failures in different environments.
await expect(el).toHaveText(/Welcome, .+/);
await expect(el).toHaveText('Welcome, John');
Mistake 4: Asserting before the action completes
Do not insert manual waits. Playwright auto-retrying assertions already handle timing. If you find yourself writing page.waitForTimeout(2000) before an assertion, the assertion itself should be enough.
await button.click(); await expect(modal).toBeVisible();
await button.click(); await page.waitForTimeout(2000); await expect(modal).toBeVisible();
Generate Assertions with Claude AI
Writing assertions manually for every element is tedious. In the Playwright + Claude AI & MCP Server course, you will learn how to use Claude AI to automatically generate assertion blocks from page screenshots and DOM snapshots. Claude analyzes the visible UI and produces the correct expect() calls — including the right locator, the right assertion method, and sensible custom messages.
The course covers how to connect Claude AI to your Playwright project via the MCP Server, enabling a workflow where you describe what to verify and AI writes the assertions for you. You will also learn how to review and refine AI-generated assertions to ensure they are stable and meaningful.
Frequently Asked Questions
What is the difference between Playwright assertions and Node.js assert?
Playwright's expect() auto-retries until a condition is met or the timeout expires, making it ideal for dynamic web pages. Node.js assert checks once and fails immediately. Always use Playwright's built-in expect() for web testing to avoid timing-related flakiness.
How long do Playwright assertions wait before failing?
The default auto-retry timeout is 5 seconds (5000ms). You can change it globally in playwright.config.ts with expect: { timeout: 10000 } or per-assertion with expect(locator).toBeVisible({ timeout: 10000 }).
What is the difference between toHaveText and toContainText?
toHaveText() checks that the element's full text content matches exactly (with whitespace normalization). toContainText() checks for a substring. Use toHaveText for strict validation and toContainText when you only need a partial match. Both support regex.
How do soft assertions work in Playwright?
Soft assertions (expect.soft()) record a failure but let the test continue. All failures are reported together at the end. Use them for verifying multiple independent conditions — like checking all widgets on a dashboard — without stopping at the first failure.
Can Playwright assertions match regular expressions?
Yes. Most text and URL assertions accept a RegExp: expect(page).toHaveURL(/\/dashboard/), expect(locator).toHaveText(/Welcome, .+/). Regex is preferred when content includes dynamic values like dates, counts, or user names.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.