← Blog

"Playwright Series #4: Assertions — Expect, Soft Assertions and Custom Matchers"

Assertions are how your tests decide pass or fail. Learn the full Playwright expect API, the difference between web-first and value assertions, soft assertions, and how to write your own matchers.

reading now
views
comments

Series Navigation

Part 3: Locators — The Right Way to Find Elements

Part 5: Page Object Model — Structuring Tests for Scale


Two Kinds of Assertions

Playwright has two distinct assertion styles and mixing them up is a common source of confusion.

Web-First Assertions (use these)

Web-first assertions operate on locators and have built-in auto-waiting. They automatically retry until the condition is met or the timeout expires.

// ✅ Web-first — retries until visible or times out
await expect(page.getByRole('alert')).toBeVisible();

Value Assertions (use sparingly)

Value assertions operate on already-resolved values — strings, numbers, booleans. They check once and fail immediately if wrong.

// ✅ Value assertion — fine for strings you've already fetched
const title = await page.title();
expect(title).toBe('Dashboard');
// ❌ Anti-pattern — resolves immediately, no retry, race conditions
const isVisible = await page.locator('.banner').isVisible();
expect(isVisible).toBe(true); // may fail if banner is still animating in

Rule of thumb: if you're asserting on something in the browser, use a web-first assertion with await expect(locator). If you're asserting on a JavaScript value you've already computed, use expect(value).


Web-First Assertions Reference

Visibility

await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();     // in DOM but might not be visible
await expect(locator).not.toBeVisible();  // negate with .not

Text Content

await expect(locator).toHaveText('Exact match');
await expect(locator).toHaveText(/partial match/i); // regex
await expect(locator).toContainText('substring');
await expect(locator).not.toContainText('error');

// Assert multiple elements have specific texts
await expect(page.getByRole('listitem')).toHaveText(['Apple', 'Banana', 'Cherry']);

// Assert each contains a substring
await expect(page.getByRole('listitem')).toContainText(['App', 'Ban']);

Input Values

await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page.getByLabel('Search')).toBeEmpty();
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByRole('checkbox')).not.toBeChecked();

Element State

await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeFocused();
await expect(locator).toBeEditable();

Counts

await expect(page.getByRole('row')).toHaveCount(5);
await expect(page.getByTestId('product-card')).toHaveCount(12);

Attributes and CSS

await expect(locator).toHaveAttribute('href', '/dashboard');
await expect(locator).toHaveAttribute('aria-expanded', 'true');
await expect(locator).toHaveClass('btn-primary');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');

Page-Level

await expect(page).toHaveTitle('Dashboard | MyApp');
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL('https://example.com/dashboard');
await expect(page).toHaveURL(/\/dashboard/);

Screenshots (Visual Testing)

// Compared against a stored baseline
await expect(page).toMatchAriaSnapshot();
await expect(page).toHaveScreenshot('dashboard.png');
await expect(locator).toHaveScreenshot('button-state.png');

We cover visual testing in depth in Part 8.


Value Assertions Reference

These are the standard Jest-style matchers, used on resolved JavaScript values:

// Equality
expect(value).toBe(42);           // strict equality (===)
expect(value).toEqual({ a: 1 }); // deep equality for objects/arrays
expect(value).not.toBe(null);

// Strings
expect(str).toContain('hello');
expect(str).toMatch(/pattern/i);
expect(str).toHaveLength(10);

// Numbers
expect(num).toBeGreaterThan(0);
expect(num).toBeLessThanOrEqual(100);
expect(num).toBeCloseTo(3.14, 2); // within 2 decimal places

// Arrays
expect(arr).toContain('item');
expect(arr).toHaveLength(3);
expect(arr).toEqual(['a', 'b', 'c']);

// Objects
expect(obj).toHaveProperty('name', 'Srikanth');
expect(obj).toMatchObject({ status: 'active' }); // partial match

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

Soft Assertions — Don't Stop on First Failure

By default, if an assertion fails the test stops immediately. Soft assertions continue running even after a failure, collecting all failures before reporting:

test('form validation shows all errors', async ({ page }) => {
  await page.goto('/register');
  await page.getByRole('button', { name: 'Submit' }).click();

  // These are soft — all run even if one fails
  await expect.soft(page.getByText('Name is required')).toBeVisible();
  await expect.soft(page.getByText('Email is required')).toBeVisible();
  await expect.soft(page.getByText('Password is required')).toBeVisible();
  await expect.soft(page.getByText('Terms must be accepted')).toBeVisible();

  // Hard assertion at the end — checks all soft assertions passed
  expect(test.info().errors).toHaveLength(0);
});

Use soft assertions when:

  • Testing a form with multiple validation messages
  • Checking multiple elements that should all be present
  • You want the full picture of what's wrong, not just the first failure

Assertion Timeouts

Web-first assertions retry until they pass or a timeout expires. The default timeout is 5 seconds (controlled by expect.timeout in config).

Override per-assertion:

// Wait up to 15 seconds for a slow-loading element
await expect(page.getByTestId('data-table')).toBeVisible({ timeout: 15_000 });

// Only wait 1 second — fail fast for elements that should be instant
await expect(page.getByRole('alert')).toBeVisible({ timeout: 1_000 });

Set globally in playwright.config.ts:

export default defineConfig({
  expect: {
    timeout: 10_000, // 10 seconds for all assertions
  },
});

Polling Assertions

When you need to assert on something that changes over time (e.g. an API response), use expect.poll:

// Polls until the condition is true or times out
await expect.poll(async () => {
  const response = await page.evaluate(() =>
    fetch('/api/job-status').then(r => r.json())
  );
  return response.status;
}, {
  message: 'Job should complete within 30 seconds',
  timeout: 30_000,
  intervals: [1_000, 2_000, 5_000], // retry at 1s, 2s, then every 5s
}).toBe('completed');

Custom Matchers

For domain-specific assertions you use frequently, write a custom matcher:

// tests/matchers/currency.ts
import { expect } from '@playwright/test';

// Extend expect with a custom matcher
expect.extend({
  async toDisplayPrice(locator: any, expected: number) {
    const text = await locator.textContent();
    const actual = parseFloat(text.replace(/[^0-9.]/g, ''));

    const pass = Math.abs(actual - expected) < 0.01;
    return {
      pass,
      message: () =>
        pass
          ? `Expected price NOT to be $${expected}, got "${text}"`
          : `Expected price $${expected}, got "${text}" (parsed: ${actual})`
    };
  }
});

Register in playwright.config.ts:

import './tests/matchers/currency';

Use in tests:

test('order total is correct', async ({ page }) => {
  await expect(page.getByTestId('order-total')).toDisplayPrice(49.99);
  await expect(page.getByTestId('tax-amount')).toDisplayPrice(4.50);
});

Another practical example — asserting on API response bodies:

expect.extend({
  toBeValidUserResponse(body: any) {
    const pass =
      typeof body.id === 'number' &&
      typeof body.email === 'string' &&
      body.email.includes('@') &&
      ['active', 'pending'].includes(body.status);

    return {
      pass,
      message: () =>
        pass
          ? `Expected response NOT to be a valid user`
          : `Expected valid user response, got: ${JSON.stringify(body)}`
    };
  }
});

// In a test:
const response = await page.request.get('/api/me');
const body = await response.json();
expect(body).toBeValidUserResponse();

Common Assertion Mistakes

Mistake 1: Asserting Without await

// ❌ Missing await — assertion runs but result is ignored
expect(page.getByText('Success')).toBeVisible();

// ✅ Correct
await expect(page.getByText('Success')).toBeVisible();

Your linter or TypeScript compiler won't always catch this. Always await web-first assertions.

Mistake 2: Using isVisible() Instead of toBeVisible()

// ❌ Resolves immediately — no retry
const visible = await page.locator('.toast').isVisible();
expect(visible).toBe(true);

// ✅ Retries until visible or timeout
await expect(page.locator('.toast')).toBeVisible();

Mistake 3: Asserting Against Animated Values

// ❌ Might catch the element mid-animation
await expect(page.locator('.counter')).toHaveText('10');

// ✅ Use a regex if the value might vary slightly
await expect(page.locator('.counter')).toHaveText(/\d+/);

// Or wait for animation to settle first
await page.locator('.counter').waitFor({ state: 'stable' });
await expect(page.locator('.counter')).toHaveText('10');

Mistake 4: Too-Specific Text Assertions

// ❌ Breaks if a single character changes
await expect(locator).toHaveText('Welcome back, Srikanth! You have 3 new messages.');

// ✅ Assert on the important parts
await expect(locator).toContainText('Welcome back');
await expect(page.getByTestId('message-count')).toHaveText('3');

A Complete Assertion Example

import { test, expect } from '@playwright/test';

test.describe('Checkout flow assertions', () => {
  test('order summary is correct before payment', async ({ page }) => {
    await page.goto('/cart');

    // Page state
    await expect(page).toHaveURL('/cart');
    await expect(page).toHaveTitle(/Shopping Cart/);

    // Item count
    await expect(page.getByRole('listitem')).toHaveCount(2);

    // Specific item text
    await expect(page.getByTestId('cart-items')).toContainText([
      'Wireless Headphones',
      'USB-C Cable'
    ]);

    // Price format
    await expect(page.getByTestId('subtotal')).toHaveText(/\$\d+\.\d{2}/);

    // Proceed button is enabled
    await expect(page.getByRole('button', { name: 'Proceed to Checkout' })).toBeEnabled();

    // No error messages
    await expect(page.getByRole('alert')).toBeHidden();
  });
});

What's Next

In Part 5 we tackle the Page Object Model — the design pattern that keeps test code maintainable as your application and test suite grow. We'll refactor raw test code into clean, reusable page objects that make tests read like English.

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000