Learn Playwright in 10 DaysDay 5: Assertions and Snapshots

Day 5: Assertions and Snapshots

What You'll Learn Today

  • Generic assertions (toBe, toEqual, toContain, etc.)
  • Web-first assertions (toBeVisible, toHaveText, etc.)
  • Auto-retrying behavior of web-first assertions
  • Soft assertions (expect.soft)
  • Negating assertions (.not)
  • Custom assertion messages
  • Snapshot testing (toMatchSnapshot)
  • Visual regression testing (toHaveScreenshot)
  • Updating snapshots

Two Worlds of Assertions

Playwright provides two fundamentally different types of assertions.

flowchart TB
    subgraph Types["Assertion Types"]
        GENERIC["Generic Assertions\nexpect(value)"]
        WEBFIRST["Web-first Assertions\nexpect(locator)"]
    end
    GENERIC -->|"Immediate evaluation\nNo retries"| USE1["Value/object comparison"]
    WEBFIRST -->|"Auto-retrying\nAsync-aware"| USE2["DOM element verification"]
    style GENERIC fill:#f59e0b,color:#fff
    style WEBFIRST fill:#22c55e,color:#fff
    style USE2 fill:#22c55e,color:#fff

Understanding this distinction is the first step to writing stable tests.


Generic Assertions

Generic assertions verify JavaScript values immediately. They use the same expect syntax as Jest/Vitest.

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

test('generic assertions', async ({ page }) => {
  const title = await page.title();

  // Strict equality
  expect(title).toBe('My App');

  // Deep equality (objects and arrays)
  expect({ name: 'Alice', age: 30 }).toEqual({ name: 'Alice', age: 30 });

  // Partial match
  expect(title).toContain('App');

  // Regular expression match
  expect(title).toMatch(/My\s+App/);

  // Truthiness
  expect(true).toBeTruthy();
  expect(0).toBeFalsy();
  expect(null).toBeNull();
  expect(undefined).toBeUndefined();

  // Numeric comparisons
  expect(10).toBeGreaterThan(5);
  expect(10).toBeGreaterThanOrEqual(10);
  expect(3).toBeLessThan(5);
  expect(3).toBeLessThanOrEqual(3);
  expect(0.1 + 0.2).toBeCloseTo(0.3, 5);

  // Length
  expect([1, 2, 3]).toHaveLength(3);

  // Object properties
  expect({ id: 1, name: 'Test' }).toHaveProperty('name', 'Test');

  // Partial object matching
  expect({ id: 1, name: 'Test', role: 'admin' }).toMatchObject({
    name: 'Test',
    role: 'admin',
  });
});

Note: Generic assertions do not retry. They evaluate the value at the moment they are called.


Web-first Assertions

Web-first assertions are one of Playwright's most powerful features. When you pass a Locator, Playwright automatically retries until the condition is met.

Visibility and State

test('element visibility and state', async ({ page }) => {
  await page.goto('/form');

  // Element is visible
  await expect(page.getByRole('heading')).toBeVisible();

  // Element is hidden
  await expect(page.getByTestId('loading')).toBeHidden();

  // Element is attached to DOM (visible or not)
  await expect(page.getByTestId('hidden-input')).toBeAttached();

  // Element is enabled
  await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();

  // Element is disabled
  await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();

  // Checkbox is checked
  await expect(page.getByLabel('I agree to the terms')).toBeChecked();

  // Input is editable
  await expect(page.getByLabel('Name')).toBeEditable();

  // Element is focused
  await expect(page.getByLabel('Name')).toBeFocused();
});

Text, Attributes, and Values

test('text, attributes, and values', async ({ page }) => {
  await page.goto('/profile');

  // Text content (exact match)
  await expect(page.getByTestId('username')).toHaveText('John Doe');

  // Text content (partial match)
  await expect(page.getByTestId('bio')).toContainText('engineer');

  // Text content (regex)
  await expect(page.getByTestId('date')).toHaveText(/\w+ \d{1,2}, \d{4}/);

  // Attribute
  await expect(page.getByRole('link', { name: 'Home' }))
    .toHaveAttribute('href', '/');

  // CSS class
  await expect(page.getByTestId('alert')).toHaveClass(/alert-danger/);

  // CSS property
  await expect(page.getByTestId('alert'))
    .toHaveCSS('background-color', 'rgb(239, 68, 68)');

  // Input value
  await expect(page.getByLabel('Email')).toHaveValue('user@example.com');

  // Input value (regex)
  await expect(page.getByLabel('Email')).toHaveValue(/.+@.+\..+/);
});

Element Count and Lists

test('element count and lists', async ({ page }) => {
  await page.goto('/todos');

  // Number of elements
  await expect(page.getByRole('listitem')).toHaveCount(5);

  // Verify text of multiple elements at once
  await expect(page.getByRole('listitem')).toHaveText([
    'Buy groceries',
    'Write report',
    'Go to the gym',
    'Read a book',
    'Cook dinner',
  ]);
});

Page-level Assertions

test('page-level assertions', async ({ page }) => {
  await page.goto('/dashboard');

  // URL
  await expect(page).toHaveURL('/dashboard');
  await expect(page).toHaveURL(/\/dashboard/);

  // Title
  await expect(page).toHaveTitle('Dashboard | My App');
  await expect(page).toHaveTitle(/Dashboard/);
});

How Auto-Retrying Works

Web-first assertions repeatedly check the condition until it passes. The default timeout is 5 seconds.

flowchart TB
    START["expect(locator).toBeVisible()"] --> CHECK["Check element state"]
    CHECK -->|"Condition met"| PASS["Test passes"]
    CHECK -->|"Condition not met"| TIMEOUT["Timeout?"]
    TIMEOUT -->|"Time remaining"| WAIT["Brief wait"] --> CHECK
    TIMEOUT -->|"5s elapsed"| FAIL["Test fails"]
    style START fill:#3b82f6,color:#fff
    style PASS fill:#22c55e,color:#fff
    style FAIL fill:#ef4444,color:#fff
test('auto-retry example', async ({ page }) => {
  await page.goto('/async-content');

  // Click button that triggers async content load
  await page.getByRole('button', { name: 'Load data' }).click();

  // No sleep or waitFor needed!
  // Playwright automatically retries until the condition is met
  await expect(page.getByTestId('result')).toBeVisible();
  await expect(page.getByTestId('result')).toHaveText('Loading complete');
});

test('custom timeout', async ({ page }) => {
  await page.goto('/slow-api');

  // Extend timeout for slow operations
  await expect(page.getByTestId('result'))
    .toBeVisible({ timeout: 30000 });
});

Important: Forgetting await causes the assertion to evaluate immediately without retrying. Always use await with web-first assertions.


Soft Assertions

Regular assertions stop the test immediately on failure. With expect.soft(), the test continues executing even after a failure.

test('form validation - all fields', async ({ page }) => {
  await page.goto('/settings');

  // Soft assertions: test continues even if one fails
  await expect.soft(page.getByLabel('Name')).toHaveValue('John Doe');
  await expect.soft(page.getByLabel('Email')).toHaveValue('john@example.com');
  await expect.soft(page.getByLabel('Phone')).toHaveValue('555-0123');
  await expect.soft(page.getByLabel('Address')).toHaveValue('123 Main St');

  // All failures are reported together after the test completes
});

When to Use Soft Assertions

flowchart TB
    subgraph Normal["Regular Assertions"]
        N1["Check 1: OK"] --> N2["Check 2: FAIL"]
        N2 -->|"Stops immediately"| NSTOP["Test fails\nChecks 3+ never run"]
    end
    subgraph Soft["Soft Assertions"]
        S1["Check 1: OK"] --> S2["Check 2: FAIL"]
        S2 -->|"Continues"| S3["Check 3: OK"]
        S3 --> S4["Check 4: FAIL"]
        S4 --> SSTOP["Test fails\nAll failures reported at once"]
    end
    style N2 fill:#ef4444,color:#fff
    style NSTOP fill:#ef4444,color:#fff
    style S2 fill:#ef4444,color:#fff
    style S4 fill:#ef4444,color:#fff
    style SSTOP fill:#f59e0b,color:#fff

Soft assertions are ideal when you want to discover multiple issues in a single test run. However, use regular assertions when subsequent actions depend on earlier checks passing.


Negating Assertions

Use .not to verify the opposite of a condition.

test('negating assertions', async ({ page }) => {
  await page.goto('/dashboard');

  // Element is not visible
  await expect(page.getByTestId('error-message')).not.toBeVisible();

  // Does not have specific text
  await expect(page.getByTestId('status')).not.toHaveText('Error');

  // URL is not a specific path
  await expect(page).not.toHaveURL('/login');

  // Checkbox is not checked
  await expect(page.getByLabel('Premium')).not.toBeChecked();

  // Does not have a specific attribute
  await expect(page.getByRole('button')).not.toHaveAttribute('disabled');
});

.not web-first assertions also auto-retry. For example, not.toBeVisible() retries until the element becomes hidden.


Custom Assertion Messages

You can customize the error message displayed when an assertion fails by passing a string as the second argument.

test('custom messages', async ({ page }) => {
  await page.goto('/cart');

  const cartCount = page.getByTestId('cart-count');

  // Custom message for web-first assertion
  await expect(cartCount, 'Cart item count should be 3').toHaveText('3');

  // Custom message for generic assertion
  const price = await page.getByTestId('total-price').textContent();
  expect(parseInt(price!), 'Total price should be at least $10')
    .toBeGreaterThanOrEqual(10);
});

Custom messages replace the default error output, making it easier to identify failure points in complex test suites.


Snapshot Testing

Text and Data Snapshots

toMatchSnapshot() compares test output against a stored snapshot. On the first run, a snapshot file is created. Subsequent runs verify that the output matches.

test('API response snapshot', async ({ request }) => {
  const response = await request.get('/api/config');
  const data = await response.json();

  // Compare JSON data against snapshot
  expect(data).toMatchSnapshot('api-config.json');
});

test('page text content snapshot', async ({ page }) => {
  await page.goto('/about');

  const content = await page.getByRole('main').textContent();
  expect(content).toMatchSnapshot('about-page-content.txt');
});

test('list items snapshot', async ({ page }) => {
  await page.goto('/categories');

  const items = await page.getByRole('listitem').allTextContents();
  expect(items).toMatchSnapshot('category-list.json');
});

Snapshot files are stored in a __snapshots__ directory alongside the test file.

Visual Regression Testing

toHaveScreenshot() compares screenshots at the pixel level.

test('visual regression - full page', async ({ page }) => {
  await page.goto('/');

  // Compare full page screenshot
  await expect(page).toHaveScreenshot('homepage.png');
});

test('visual regression - component', async ({ page }) => {
  await page.goto('/components');

  // Compare a specific element's screenshot
  const card = page.getByTestId('product-card');
  await expect(card).toHaveScreenshot('product-card.png');
});

test('full page screenshot', async ({ page }) => {
  await page.goto('/blog');

  // Capture entire scrollable page
  await expect(page).toHaveScreenshot('blog-full.png', {
    fullPage: true,
  });
});

Configuring Snapshot Thresholds

Pixel-perfect matching is often impractical. Font rendering and anti-aliasing differ across environments. Thresholds let you account for these differences.

test('visual with threshold', async ({ page }) => {
  await page.goto('/chart');

  // Allow up to 100 pixels to differ
  await expect(page).toHaveScreenshot('chart.png', {
    maxDiffPixels: 100,
  });

  // Allow up to 1% of pixels to differ
  await expect(page).toHaveScreenshot('chart-ratio.png', {
    maxDiffPixelRatio: 0.01,
  });

  // Per-pixel color difference threshold (0-1, default 0.2)
  await expect(page).toHaveScreenshot('chart-threshold.png', {
    threshold: 0.3,
  });
});

You can also configure thresholds globally in playwright.config.ts:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01,
      threshold: 0.2,
    },
    toMatchSnapshot: {
      maxDiffPixelRatio: 0.01,
    },
  },
});

Handling Animations and Dynamic Content

test('screenshot with animations disabled', async ({ page }) => {
  await page.goto('/animated-page');

  await expect(page).toHaveScreenshot('no-animation.png', {
    // Disable CSS animations
    animations: 'disabled',
    // Mask dynamic elements (timestamps, etc.)
    mask: [page.getByTestId('current-time')],
    // Mask color
    maskColor: '#FF00FF',
  });
});

Updating Snapshots

When you intentionally change the UI, you need to update the stored snapshots.

# Update all snapshots
npx playwright test --update-snapshots

# Update snapshots for a specific test file
npx playwright test tests/visual.spec.ts --update-snapshots

Practical Exercise: Testing a Dashboard

Let's combine everything we've learned to test a dashboard page.

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

test.describe('Dashboard', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/dashboard');
  });

  test('header displays correct information', async ({ page }) => {
    await expect(page).toHaveTitle(/Dashboard/);
    await expect(page).toHaveURL('/dashboard');

    await expect(page.getByRole('heading', { level: 1 }))
      .toHaveText('Dashboard');

    await expect(page.getByTestId('user-name'))
      .toContainText('John');
  });

  test('stat cards display correctly', async ({ page }) => {
    const cards = page.getByTestId('stat-card');
    await expect(cards).toHaveCount(4);

    // Soft assertions to verify all cards
    await expect.soft(cards.nth(0)).toContainText('Revenue');
    await expect.soft(cards.nth(1)).toContainText('Orders');
    await expect.soft(cards.nth(2)).toContainText('Customers');
    await expect.soft(cards.nth(3)).toContainText('Inventory');
  });

  test('data table appears after loading', async ({ page }) => {
    // Loading state
    await expect(page.getByTestId('loading')).toBeVisible();

    // Data loaded (auto-retry waits for this)
    await expect(page.getByTestId('loading')).not.toBeVisible();
    await expect(page.getByTestId('data-table')).toBeVisible();

    // Table has data
    const rows = page.getByRole('row');
    const count = await rows.count();
    expect(count, 'Table should have at least one data row')
      .toBeGreaterThan(1);
  });

  test('visual regression', async ({ page }) => {
    // Wait for data to load
    await expect(page.getByTestId('loading')).not.toBeVisible();

    await expect(page).toHaveScreenshot('dashboard.png', {
      animations: 'disabled',
      mask: [
        page.getByTestId('current-date'),
        page.getByTestId('last-updated'),
      ],
    });
  });
});

Summary

Concept Description
Generic assertions expect(value).toBe(), etc. Immediate evaluation, no retries
Web-first assertions expect(locator).toBeVisible(), etc. Auto-retrying
Soft assertions expect.soft() continues on failure. Reports all failures together
Negating assertions .not verifies the opposite condition. Retries still apply
Custom messages Second argument to expect customizes error output
toMatchSnapshot Detects text/data differences against stored snapshots
toHaveScreenshot Pixel-level visual comparison
maxDiffPixels Absolute number of pixels allowed to differ
maxDiffPixelRatio Percentage of pixels allowed to differ
--update-snapshots Bulk update all stored snapshots

Key Takeaways

  1. Prefer web-first assertions - Auto-retrying eliminates the need for sleep or waitFor calls, producing stable tests even with async UIs
  2. Never forget await - Missing await on web-first assertions causes immediate evaluation without retries, leading to flaky tests
  3. Set thresholds for visual tests - Font rendering varies across environments. Configure appropriate maxDiffPixelRatio values to absorb these differences

Practice Problems

Problem 1: Basics

Write tests for an e-commerce product detail page. Verify the following:

  1. The product name is visible
  2. The price starts with "$"
  3. The "Add to Cart" button is enabled
  4. The stock count is greater than 0, with a custom assertion message

Problem 2: Soft Assertions

Write a test that uses soft assertions to verify five fields on a user profile page (name, email, phone, address, date of birth) in a single test run.

Challenge Problem

Write a test for the following scenario:

  • Verify a dashboard chart component using visual snapshots
  • Mask elements that display dates or random values
  • Set maxDiffPixelRatio to 0.02
  • Disable animations

References


Next up: In Day 6, we'll learn about Network Interception and Mocking. You'll master page.route() for intercepting and mocking API requests, and page.waitForResponse() for monitoring network responses.