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.