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
awaitcauses the assertion to evaluate immediately without retrying. Always useawaitwith 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');
});
.notweb-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
- Prefer web-first assertions - Auto-retrying eliminates the need for
sleeporwaitForcalls, producing stable tests even with async UIs - Never forget
await- Missingawaiton web-first assertions causes immediate evaluation without retries, leading to flaky tests - Set thresholds for visual tests - Font rendering varies across environments. Configure appropriate
maxDiffPixelRatiovalues to absorb these differences
Practice Problems
Problem 1: Basics
Write tests for an e-commerce product detail page. Verify the following:
- The product name is visible
- The price starts with "$"
- The "Add to Cart" button is enabled
- 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
- Assertions - Playwright Documentation
- Visual comparisons - Playwright Documentation
- expect API - Playwright Documentation
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.