After writing 50 tests, you will notice patterns. Every test checks if a page loaded. Every form test verifies fields and error messages. Every table test checks headers and row count. Instead of copy-pasting the same 10 lines, build reusable assertion helpers. This is what separates a beginner from a senior QA engineer.
import { Page, Locator, expect } from '@playwright/test';
/**
* Verify a page loaded correctly.
*/
export async function assertPageLoaded(
page: Page,
urlPattern: RegExp,
headingText: string
) {
await expect(page).toHaveURL(urlPattern);
await expect(
page.getByRole('heading', { name: headingText })
).toBeVisible();
}
/**
* Verify a form field has label, is visible, and is editable.
*/
export async function assertFieldEditable(
page: Page,
label: string
) {
const field = page.getByLabel(label);
await expect(field, `Field "${label}" should be visible`).toBeVisible();
await expect(field, `Field "${label}" should be enabled`).toBeEnabled();
}
/**
* Verify a table has expected headers and row count.
*/
export async function assertTableState(
page: Page,
expectedHeaders: string[],
expectedRowCount: number
) {
await expect(page.getByRole('columnheader')).toHaveText(expectedHeaders);
// +1 for header row
await expect(page.getByRole('row')).toHaveCount(expectedRowCount + 1);
}
/**
* Verify a toast/alert message appears and has correct text.
*/
export async function assertToastMessage(
page: Page,
message: string | RegExp
) {
const toast = page.getByRole('alert');
await expect(toast).toBeVisible();
await expect(toast).toHaveText(message);
}import { test, expect } from '@playwright/test';
import {
assertPageLoaded,
assertTableState,
assertToastMessage,
} from './helpers/assertions';
test('banking dashboard verification', async ({ page }) => {
await page.goto('/banking/dashboard');
// One-liner page check
await assertPageLoaded(page, /\/dashboard/, 'Dashboard');
// One-liner table check
await assertTableState(
page,
['Date', 'Description', 'Amount', 'Balance'],
10
);
});
test('transfer funds success', async ({ page }) => {
await page.goto('/banking/transfer');
await page.getByLabel('Amount').fill('500');
await page.getByRole('button', { name: 'Transfer' }).click();
// One-liner toast check
await assertToastMessage(page, /Transfer successful/);
});For larger projects, group assertions into a class that pairs with your Page Object Model. The page object handles actions. The assertion helper handles verification.
import { Page, expect } from '@playwright/test';
export class DashboardAssertions {
constructor(private page: Page) {}
async verifyLoaded() {
await expect(this.page).toHaveURL(/\/dashboard/);
await expect(
this.page.getByRole('heading', { name: 'Dashboard' })
).toBeVisible();
}
async verifyBalance(expected: string) {
await expect(this.page.getByTestId('balance')).toHaveText(expected);
}
async verifyAccountStatus(status: 'Active' | 'Inactive' | 'Frozen') {
await expect(this.page.getByTestId('status')).toHaveText(status);
}
async verifyTransactionCount(count: number) {
await expect(
this.page.getByTestId('transaction-row')
).toHaveCount(count);
}
async verifyNoErrors() {
await expect(this.page.getByRole('alert')).not.toBeVisible();
}
}import { test } from '@playwright/test';
import { DashboardAssertions } from './pages/dashboard.assertions';
test('verified user dashboard', async ({ page }) => {
await page.goto('/banking/dashboard');
const dashboard = new DashboardAssertions(page);
await dashboard.verifyLoaded();
await dashboard.verifyBalance('$5,000.00');
await dashboard.verifyAccountStatus('Active');
await dashboard.verifyTransactionCount(10);
await dashboard.verifyNoErrors();
});Playwright lets you extend expect with custom matchers. This is advanced but powerful for domain-specific assertions.
import { expect, Locator } from '@playwright/test';
// Extend expect with a custom matcher
expect.extend({
async toHaveCurrencyValue(locator: Locator, expected: number) {
const text = await locator.textContent();
const actual = parseFloat((text || '').replace(/[$,]/g, ''));
const pass = actual === expected;
return {
pass,
message: () => pass
? `Expected ${text} not to equal $${expected}`
: `Expected $${expected} but got ${text}`,
name: 'toHaveCurrencyValue',
expected,
actual,
};
},
});
// Usage:
// await expect(page.getByTestId('balance')).toHaveCurrencyValue(5000);Start with simple helper functions. Move to assertion classes when you have 10+ tests for one page. Use custom matchers when the same parsing logic appears in 5+ tests. Do not over-engineer from day one.
Custom matchers using expect.extend are NOT auto-retrying. They run once. If you need retrying behavior, use expect.poll() or stick with built-in assertions inside helper functions.
Q: How do you organize assertions in a large Playwright test suite?
A: I use three layers. First, simple helper functions for common checks -- assertPageLoaded(), assertToastMessage(). These reduce duplication across tests. Second, assertion classes that pair with Page Object Models -- DashboardAssertions, LoginAssertions. Each class knows how to verify its page. Third, custom expect matchers for domain-specific logic like currency parsing or date validation. I start with simple helpers and only add complexity when the test suite grows. The goal is that test files read like business requirements, not technical assertions.
Key Point: Build reusable assertion helpers: functions for common checks, classes for page-level verification, custom matchers for domain logic. Start simple, refactor when patterns emerge.