page.getByText('Success').isVisible() returns a boolean by checking the DOM exactly once: if the element hasn't rendered yet, you get false even if it appears 200ms later. expect(page.getByText('Success')).toBeVisible() retries for up to 5 seconds before failing. That retry behavior is the core of Playwright's assertion design, and most beginner mistakes come from not knowing which methods have it. This guide covers every assertion type, the not negation, soft assertions for collecting all failures, and the patterns that silently disable auto-retry.
How Playwright assertions work
Playwright assertions use the expect() function from @playwright/test. They're not the same as Jest's expect. Playwright's version is async and has built-in retry logic.
import { test, expect } from '@playwright/test';The key difference from most testing frameworks: Playwright assertions retry automatically. When you write:
await expect(page.getByText('Welcome')).toBeVisible();Playwright doesn't just check once. It checks repeatedly for up to 5 seconds (the default expect timeout), waiting for the condition to become true. This eliminates the need for manual waitFor calls in 90% of cases.
If the condition never becomes true within the timeout, the test fails with a clear message showing what was expected and what actually existed.
Element assertions (locator-based)
These check properties of a specific element on the page.
toBeVisible / toBeHidden
// Element is rendered and visible to the user
await expect(page.getByText('Dashboard')).toBeVisible();
// Element is not present, or present but hidden (display:none, visibility:hidden, opacity:0)
await expect(page.getByRole('dialog')).toBeHidden();toBeHidden() is true if the element doesn't exist OR if it exists but is invisible. Use not.toBeAttached() if you specifically need to confirm the element isn't in the DOM at all.
toHaveText / toContainText
// Exact text match (trims whitespace automatically)
await expect(page.getByRole('heading', { level: 1 })).toHaveText('My Travel Items');
// Partial text match
await expect(page.getByRole('heading')).toContainText('Travel');
// Array: check text of multiple elements
await expect(page.getByRole('listitem')).toHaveText(['Tokyo', 'Paris', 'London']);
// Regex: pattern match
await expect(page.getByTestId('price')).toHaveText(/\$\d+\.\d{2}/);toHaveText with an array matches the full text of each element in order. Very useful for verifying table rows or sorted lists.
toHaveValue
For , , elements:
// Text input
await expect(page.getByLabel('Email')).toHaveValue('admin@becomeqa.com');
// Empty input
await expect(page.getByLabel('Search')).toHaveValue('');
// Select dropdown
await expect(page.getByLabel('Status')).toHaveValue('completed');toBeChecked / not.toBeChecked
For checkboxes and radio buttons:
await expect(page.getByLabel('Remember me')).toBeChecked();
await expect(page.getByLabel('Subscribe to newsletter')).not.toBeChecked();toBeEnabled / toBeDisabled
// Button is clickable
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
// Button is greyed out / has disabled attribute
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();Use this to verify that a form submit button becomes enabled only after required fields are filled.
toBeEditable / toBeReadOnly
await expect(page.getByLabel('Username')).toBeEditable();
await expect(page.getByLabel('Created At')).toBeReadOnly();toHaveAttribute
Check any HTML attribute:
// href on a link
await expect(page.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');
// data-testid
await expect(page.getByTestId('status-badge')).toHaveAttribute('data-status', 'active');
// aria-label
await expect(page.getByRole('button', { name: 'Close' })).toHaveAttribute('aria-label', 'Close dialog');
// Regex value match
await expect(page.getByRole('img')).toHaveAttribute('src', /\/images\/.+\.svg/);toHaveClass
// Element has this CSS class
await expect(page.getByTestId('alert')).toHaveClass(/error/);
// Exact class list
await expect(page.getByTestId('button')).toHaveClass('btn btn-primary active');Note: toHaveClass checks if the class is present, not if it's the only class. Use regex for partial matches.
toHaveCount
For collections of elements:
// Table has header + 5 data rows
await expect(page.getByRole('row')).toHaveCount(6);
// Dropdown has 4 options
await expect(page.getByRole('option')).toHaveCount(4);
// No error messages
await expect(page.getByRole('alert')).toHaveCount(0);toHaveCSS
Check computed CSS properties:
await expect(page.getByTestId('error-message')).toHaveCSS('color', 'rgb(220, 38, 38)');
await expect(page.getByRole('dialog')).toHaveCSS('display', 'flex');Use computed values (rgb(...)) not CSS variables or shorthand properties.
Page assertions
These check properties of the whole page, not a specific element.
toHaveURL
// Exact URL
await expect(page).toHaveURL('https://lab.becomeqa.com/dashboard');
// Partial URL match with regex
await expect(page).toHaveURL(/\/dashboard/);
// With base URL configured in playwright.config.ts
await expect(page).toHaveURL('/dashboard');Use this after a navigation to confirm you landed where you expected.
toHaveTitle
await expect(page).toHaveTitle('My Travel Items | BecomeQA Lab');
await expect(page).toHaveTitle(/BecomeQA/);toHaveScreenshot (visual regression)
// First run creates the reference screenshot
// Subsequent runs compare against it
await expect(page).toHaveScreenshot('dashboard.png');
// With options
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100, // allow minor pixel differences
threshold: 0.2, // 0–1 scale of color difference tolerance
});Visual regression testing: the first run saves a reference image. Every subsequent run compares the current state to that reference. Fails if they differ beyond the threshold.
API response assertions
When using the request fixture for API testing:
test('GET /api/items returns valid data', async ({ request }) => {
const response = await request.get('/api/items');
// Status code
expect(response.status()).toBe(200);
expect(response.ok()).toBeTruthy(); // true for 200-299
// Response body
const items = await response.json();
expect(items).toHaveLength(5);
expect(items[0]).toHaveProperty('id');
expect(items[0]).toHaveProperty('title');
expect(items[0].status).toBe('planned');
// Headers
expect(response.headers()['content-type']).toContain('application/json');
});Note: response.ok() is a built-in method (not an assertion) that returns true for 2xx status codes.
Generic value assertions
For non-element values like variables, API responses, computed values:
// Equality
expect(items.length).toBe(5);
expect(user.role).toBe('admin');
// Not equal
expect(errorCode).not.toBe(0);
// Truthy/falsy
expect(isVisible).toBeTruthy();
expect(errorMessage).toBeFalsy();
// Null/undefined
expect(result).toBeNull();
expect(result).not.toBeNull();
expect(result).toBeDefined();
expect(result).toBeUndefined();
// Numbers
expect(count).toBeGreaterThan(0);
expect(price).toBeGreaterThanOrEqual(9.99);
expect(discount).toBeLessThan(100);
// Arrays
expect(statuses).toContain('completed');
expect(items).toHaveLength(3);
expect(tags).toEqual(['qa', 'automation', 'playwright']); // exact array match
// Objects
expect(user).toMatchObject({ email: 'admin@becomeqa.com', role: 'admin' }); // partial match
expect(user).toEqual({ id: 1, email: 'admin@becomeqa.com', role: 'admin' }); // exact match
// Strings
expect(message).toContain('success');
expect(slug).toMatch(/^[a-z0-9-]+$/);Negation: not
Every assertion can be negated with .not:
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('Error')).not.toBeAttached();
expect(response.status()).not.toBe(404);Soft assertions: collect all failures
By default, the first failed assertion stops the test. Soft assertions continue running even after a failure and report all failures at the end:
test('dashboard data is correct', async ({ page }) => {
await page.goto('/dashboard');
// These don't stop on first failure
await expect.soft(page.getByRole('heading')).toHaveText('My Travel Items');
await expect.soft(page.getByRole('row')).toHaveCount(6);
await expect.soft(page).toHaveURL('/dashboard');
// This throws if any soft assertion above failed
expect(test.info().errors).toHaveLength(0);
});Use soft assertions when you want to see all the things that are broken in one test run, not just the first failure.
Custom assertion messages
When an assertion fails, Playwright shows what was expected vs. what was found. You can add a custom message to make failures more readable:
await expect(page.getByRole('heading'), 'Page should show dashboard after login')
.toHaveText('My Travel Items');
expect(items.length, `Expected 5 items but got ${items.length}`)
.toBe(5);Configuring timeouts
The default assertion timeout is 5 seconds. You can override it per-assertion or globally:
// Per assertion (10 seconds for a slow operation)
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 10000 });
// Global in playwright.config.ts
export default defineConfig({
expect: {
timeout: 10000, // all assertions wait up to 10 seconds
},
});Don't increase timeouts to fix flaky tests. A flaky test with a longer timeout is still flaky. It just fails slower. Fix the root cause instead.
Common mistakes
Usingpage.locator().isVisible() instead of expect().toBeVisible()
// Wrong: checks once, no retry, returns boolean
const visible = await page.getByText('Success').isVisible();
expect(visible).toBe(true);
// Right: retries until visible or timeout
await expect(page.getByText('Success')).toBeVisible();The first version can fail intermittently because it checks exactly once. The second retries.
Asserting on stale locators// Don't save locators before navigation and assert them after
const heading = page.getByRole('heading');
await page.goto('/new-page');
await expect(heading).toHaveText('New Page'); // might be stale
// Better: create the locator close to the assertion
await page.goto('/new-page');
await expect(page.getByRole('heading')).toHaveText('New Page');expect(await locator.textContent()).toBe(...) instead of toHaveText
// Wrong: evaluates once, no retry
expect(await page.getByRole('heading').textContent()).toBe('Dashboard');
// Right: retries with auto-waiting
await expect(page.getByRole('heading')).toHaveText('Dashboard');If a list loads asynchronously, assert count after asserting that the list is visible:
await expect(page.getByRole('list')).toBeVisible(); // wait for list to appear
await expect(page.getByRole('listitem')).toHaveCount(5); // then count itemsA complete test using multiple assertion types
import { test, expect } from '@playwright/test';
test('user can add and view a travel item', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
// Page assertion: verify navigation
await expect(page).toHaveURL('/dashboard');
// Visibility assertion
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
// Text assertion
await expect(page.getByRole('heading', { level: 1 })).toHaveText('My Travel Items');
// Add a new item
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Destination').fill('Tokyo');
await page.getByRole('button', { name: 'Save' }).click();
// Count assertion: table should have one more row
const rows = page.getByRole('row');
await expect(rows).toHaveCount(7); // header + 6 items
// Text content assertion on the new row
await expect(page.getByRole('cell', { name: 'Tokyo' })).toBeVisible();
// Status assertion on new item
const tokyoRow = page.getByRole('row').filter({ hasText: 'Tokyo' });
await expect(tokyoRow.getByRole('cell').last()).toHaveText('Planned');
});FAQ
Why does my assertion pass locally but fail in CI?Timing. CI machines are slower. The element exists but takes longer to appear. Increase the assertion timeout in playwright.config.ts or investigate why the element loads slowly in CI environments.
toEqual and toBe?
toBe checks reference equality (same object in memory, or identical primitives). toEqual checks deep equality (same structure and values, works for objects and arrays). For comparing objects and arrays, use toEqual. For strings, numbers, and booleans, use toBe.
When should I use toMatchObject vs toEqual?
toMatchObject is a partial match. The actual object can have more properties than you specify. toEqual requires an exact match. For API responses where you want to verify key fields without listing every field, use toMatchObject.
My toHaveText fails because the actual text has extra whitespace, how do I fix it?
toHaveText trims leading and trailing whitespace automatically. For internal whitespace (multiple spaces, newlines), use a regex: toHaveText(/destination:\s+Tokyo/i).
→ See also: Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | Debugging Flaky Tests: A Practical Guide