Writing Good Assertions
Verify your app works correctly with Playwright's powerful assertions
Writing Good Assertions
In the previous tutorial, we clicked buttons and filled forms. But how do you know it actually worked? Assertions are how your tests verify that things are correct. And Playwright's assertion system is smart — it auto-retries and knows about the web.
Why Playwright Assertions Are Special
"Can't I just use regular expect()?"
You can, but you'd miss out on the magic. Traditional assertions fail instantly:
// Regular assertion - fails immediately if not true
expect(element.textContent).toBe('Hello'); // 💥 Fails if text isn't ready yet
Playwright's web-first assertions auto-retry:
// Playwright assertion - retries until true or timeout
await expect(page.getByText('Hello')).toBeVisible(); // ✅ Waits up to 5s
This single feature eliminates most flaky tests. No more sleep(), no more race conditions. Boom!
The Basics: expect()
Every assertion starts with expect():
import { test, expect } from '@playwright/test';
test('basic assertions', async ({ page }) => {
await page.goto('https://example.com');
// Assert on locators (web-first, auto-retry)
await expect(page.getByRole('heading')).toBeVisible();
// Assert on page
await expect(page).toHaveTitle('Example Domain');
// Assert on values (generic, no retry)
expect(1 + 1).toBe(2);
});
Web-First Assertions (The Good Stuff)
These retry automatically until they pass or timeout. Use these for anything on the page.
Visibility
// Element is visible
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
// Element is hidden
await expect(page.getByRole('dialog')).toBeHidden();
// Element exists in DOM (might not be visible)
await expect(page.locator('.loading-spinner')).toBeAttached();
// Element removed from DOM
await expect(page.locator('.loading-spinner')).not.toBeAttached();
Text Content
// Exact text
await expect(page.getByRole('heading')).toHaveText('Welcome');
// Contains text (substring)
await expect(page.getByRole('alert')).toContainText('saved');
// Regex match
await expect(page.getByRole('status')).toHaveText(/\d+ items/);
// Multiple elements - check all texts
await expect(page.getByRole('listitem')).toHaveText([
'First item',
'Second item',
'Third item'
]);
Input Values
// Input has specific value
await expect(page.getByLabel('Email')).toHaveValue('test@example.com');
// Input is empty
await expect(page.getByLabel('Search')).toHaveValue('');
// Input matches pattern
await expect(page.getByLabel('Phone')).toHaveValue(/^\d{3}-\d{3}-\d{4}$/);
Form States
// Checkbox/radio is checked
await expect(page.getByLabel('Remember me')).toBeChecked();
// Checkbox is not checked
await expect(page.getByLabel('Subscribe')).not.toBeChecked();
// Element is enabled
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
// Element is disabled
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();
// Element is editable (not readonly)
await expect(page.getByLabel('Name')).toBeEditable();
// Element has focus
await expect(page.getByLabel('Search')).toBeFocused();
Attributes and CSS
// Has specific attribute
await expect(page.getByRole('link')).toHaveAttribute('href', '/about');
// Attribute matches pattern
await expect(page.locator('img')).toHaveAttribute('src', /logo/);
// Has CSS class
await expect(page.locator('.card')).toHaveClass(/active/);
// Has exact classes (order doesn't matter)
await expect(page.locator('.btn')).toHaveClass('btn btn-primary');
// Has CSS property
await expect(page.locator('.error')).toHaveCSS('color', 'rgb(255, 0, 0)');
Page-Level Assertions
// Page title
await expect(page).toHaveTitle('Dashboard - MyApp');
await expect(page).toHaveTitle(/Dashboard/);
// Page URL
await expect(page).toHaveURL('https://example.com/dashboard');
await expect(page).toHaveURL(/\/dashboard$/);
// Useful after navigation
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page).toHaveURL(/\/settings/);
Count Elements
// Exact count
await expect(page.getByRole('listitem')).toHaveCount(5);
// At least one
await expect(page.getByRole('alert')).toHaveCount(1);
// None (useful for checking something was removed)
await expect(page.locator('.error-message')).toHaveCount(0);
Negating Assertions
Add .not to negate any assertion:
// NOT visible
await expect(page.getByRole('dialog')).not.toBeVisible();
// Does NOT have text
await expect(page.getByRole('status')).not.toHaveText('Error');
// Does NOT contain text
await expect(page.getByRole('alert')).not.toContainText('failed');
// Is NOT checked
await expect(page.getByLabel('Premium')).not.toBeChecked();
// Is NOT disabled
await expect(page.getByRole('button')).not.toBeDisabled();
Soft Assertions
"What if I want to check multiple things and see ALL failures?"
Normal assertions stop the test on failure. Soft assertions let the test continue, collecting all failures:
test('form validation shows all errors', async ({ page }) => {
await page.goto('/signup');
await page.getByRole('button', { name: 'Submit' }).click();
// These all run even if some fail
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();
// At the end, test fails if any soft assertion failed
});
Great for checking multiple things that should all be true.
Custom Timeouts
Default timeout is 5 seconds. Override when needed:
// Wait longer for slow operations
await expect(page.getByText('Processing complete')).toBeVisible({
timeout: 30000 // 30 seconds
});
// Fail faster for things that should be immediate
await expect(page.getByRole('button')).toBeEnabled({
timeout: 1000 // 1 second
});
Set default timeout in config:
// playwright.config.ts
export default defineConfig({
expect: {
timeout: 10000, // 10 seconds for all assertions
},
});
Custom Error Messages
Make failures easier to debug:
await expect(page.getByRole('button', { name: 'Submit' }),
'Submit button should be enabled after form is valid'
).toBeEnabled();
// On failure:
// Error: Submit button should be enabled after form is valid
// Expected: enabled
// Received: disabled
Generic Assertions (Non-Web)
For plain values (no auto-retry):
// Equality
expect(count).toBe(5);
expect(name).toBe('John');
// Truthy/Falsy
expect(isValid).toBeTruthy();
expect(error).toBeFalsy();
// Comparisons
expect(price).toBeGreaterThan(0);
expect(items.length).toBeLessThanOrEqual(10);
// Strings
expect(message).toContain('success');
expect(email).toMatch(/^[\w-]+@[\w-]+\.\w+$/);
// Arrays
expect(tags).toContain('featured');
expect(users).toHaveLength(3);
expect(results).toEqual(['a', 'b', 'c']);
// Objects
expect(user).toEqual({ name: 'John', age: 30 });
expect(response).toMatchObject({ status: 'ok' }); // Partial match
// Null/Undefined
expect(result).toBeNull();
expect(optional).toBeUndefined();
expect(required).toBeDefined();
Snapshot Testing
"Can I catch visual regressions automatically?"
Yep! Compare against saved snapshots:
// Text snapshot
const content = await page.textContent('.article');
expect(content).toMatchSnapshot('article-content.txt');
// Screenshot snapshot (visual regression)
await expect(page).toHaveScreenshot('homepage.png');
// Element screenshot
await expect(page.locator('.chart')).toHaveScreenshot('chart.png');
First run creates the snapshot. Subsequent runs compare against it.
Common Patterns
Wait for Loading to Complete
// Wait for spinner to disappear
await expect(page.locator('.loading')).toBeHidden();
// Then check content
await expect(page.getByRole('heading')).toHaveText('Dashboard');
Check Success Message and Disappear
// Action
await page.getByRole('button', { name: 'Save' }).click();
// Success toast appears
await expect(page.getByText('Saved successfully')).toBeVisible();
// Toast disappears
await expect(page.getByText('Saved successfully')).toBeHidden();
Verify List Contents
// Check all items exist
const items = page.getByRole('listitem');
await expect(items).toHaveCount(3);
await expect(items).toHaveText(['Apple', 'Banana', 'Cherry']);
// Check specific item
await expect(items.filter({ hasText: 'Banana' })).toHaveCount(1);
Verify Table Row
const row = page.getByRole('row').filter({ hasText: 'John Doe' });
await expect(row.getByRole('cell').nth(1)).toHaveText('john@example.com');
await expect(row.getByRole('cell').nth(2)).toHaveText('Active');
Check After Navigation
await page.getByRole('link', { name: 'Profile' }).click();
// Always await URL change
await expect(page).toHaveURL(/\/profile/);
// Then check page content
await expect(page.getByRole('heading')).toHaveText('Your Profile');
Polling Assertions for APIs
When checking something that changes dynamically:
// Poll until condition is true
await expect(async () => {
const response = await page.request.get('/api/job/123');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.status).toBe('completed');
}).toPass({
timeout: 60000, // Give it up to 60 seconds
intervals: [1000, 2000, 5000], // Check at 1s, 2s, then every 5s
});
Debugging Assertion Failures
When an assertion fails:
- Check the error message — Playwright shows expected vs actual values
- Take a screenshot — See what the page looks like at failure
- Use trace viewer — See the state at each step
- Add page.pause() — Inspect interactively
test('debug example', async ({ page }) => {
await page.goto('/dashboard');
// Pause here to inspect
await page.pause();
await expect(page.getByText('Welcome')).toBeVisible();
});
What's Next?
You now have the full verify-everything toolkit:
- Web-first assertions auto-retry (use them for DOM elements)
toBeVisible(),toHaveText(),toHaveValue()are your bread and butter- Use
.notto negate:expect(x).not.toBeVisible() - Soft assertions let tests continue after failures
- Custom timeouts for slow/fast operations
- Generic assertions for plain values (no retry)
Clicking and asserting is great, but what about navigating between pages? Let's tackle navigation and waiting strategies. Let's go!