How to fix brittle exact-text assertions in Playwright tests?
PlaywrightExact text assertions break on whitespace normalization, added punctuation, translated strings, or copy edits that don't change application functionality. A test that asserts toHaveText('Welcome back, Ada!') will fail when marketing updates the copy to 'Welcome, Ada' — even though the page is rendering correctly. Brittle text assertions accumulate maintenance overhead and teach developers to treat test failures as noise, which is how real bugs start getting ignored.
Common mistake
test('shows dashboard greeting', async ({ page }) => {
await page.goto('https://app.example.com/dashboard');
// Breaks on any copy change, whitespace adjustment, or punctuation update
await expect(page.getByRole('heading')).toHaveText('Welcome back, Ada Lovelace!');
});
Also brittle: asserting on full button labels that include loading state text:
await expect(page.getByRole('button', { name: 'Submit' })).toHaveText('Submit');
// Fails when button shows 'Submitting...' during async operation
The fix
Use toContainText for partial matching when the exact wording can vary, and toHaveText only for content where precision is semantically required:
import { test, expect } from '@playwright/test';
test('dashboard shows user greeting', async ({ page }) => {
await page.goto('https://app.example.com/dashboard');
// Partial match — survives copy edits around the invariant part
await expect(page.getByRole('heading', { level: 1 })).toContainText('Ada Lovelace');
});
test('error message includes error code', async ({ page }) => {
await page.goto('https://app.example.com/api-errors/404');
// Regex match — more flexible than exact string
await expect(page.getByRole('alert')).toHaveText(/404.*not found/i);
});
test('invoice total shows correct amount', async ({ page }) => {
await page.goto('https://app.example.com/invoices/1042');
// Exact match IS appropriate here — financial figures must be precise
await expect(page.getByTestId('invoice-total')).toHaveText('$1,250.00');
});
For i18n applications, assert on data attributes or test IDs rather than display text:
test('success state is shown', async ({ page }) => {
await page.goto('https://app.example.com/checkout');
await page.getByRole('button', { name: /pay/i }).click();
// Prefer a test-stable attribute over localized text
await expect(page.getByTestId('payment-success')).toBeVisible();
});
Why it works
toContainText in Playwright checks that the element's text content includes the specified substring, using normalized whitespace. This survives surrounding copy changes while still asserting the meaningful content is present. Regex patterns in both toHaveText and toContainText give you control over matching specificity — toHaveText(/\$[\d,]+\.\d{2}/) verifies a currency format without hardcoding the specific amount. The principle is to make assertions as specific as the feature they're testing requires — no more, no less.
Tips
- Before writing a text assertion, ask: "Would this test need updating if someone fixed a typo or reworded the button?" If yes, use a partial match or attribute-based check.
- toHaveText with an exact string is appropriate for error messages (where specific wording matters for users), form validation messages, and financial/legal copy.
- For dynamic text like '3 items selected', use a regex: toHaveText(/\d+ items selected/) — it verifies the pattern without coupling to the exact count.
- If you're testing multiple text elements in a list, toHaveText([/item 1/, /item 2/]) accepts an array of matchers and checks each list item in order.