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 <input>, <textarea>, <select> 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

Using page.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');

Using 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');

Asserting count on dynamic lists

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 items

A 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.

What's the difference between 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 | Flaky Tests: Why They Happen and How to Eliminate Them