Text assertions deserve their own lesson because they have subtle differences that catch people off guard. toHaveText and toContainText look similar. They are not. One is strict. One is forgiving. Pick the wrong one and your test either fails when it should pass, or passes when it should fail.
import { test, expect } from '@playwright/test';
// Element text: "Welcome back, John Doe!"
test('toHaveText -- strict matching', async ({ page }) => {
await page.goto('/banking/dashboard');
const greeting = page.getByTestId('greeting');
// PASS -- exact match
await expect(greeting).toHaveText('Welcome back, John Doe!');
// FAIL -- missing part of the text
// await expect(greeting).toHaveText('Welcome back');
// PASS -- regex for flexible matching
await expect(greeting).toHaveText(/Welcome back, .+!/);
});
test('toContainText -- substring matching', async ({ page }) => {
await page.goto('/banking/dashboard');
const greeting = page.getByTestId('greeting');
// PASS -- substring is enough
await expect(greeting).toContainText('Welcome back');
await expect(greeting).toContainText('John Doe');
await expect(greeting).toContainText('John');
});When a locator matches multiple elements, you can pass an array to check each element in order. This is perfect for lists, menu items, and table headers.
import { test, expect } from '@playwright/test';
test('text assertion with arrays', async ({ page }) => {
await page.goto('/banking/dashboard');
// Verify all nav links in order
await expect(page.getByRole('navigation').getByRole('link')).toHaveText([
'Dashboard',
'Transfers',
'Bills',
'Transactions',
'Settings',
]);
// Verify list items with toContainText (partial match per item)
await expect(page.getByRole('listitem')).toContainText([
'Savings', // matches "Savings Account - $5,000"
'Current', // matches "Current Account - $12,000"
'Fixed', // matches "Fixed Deposit - $50,000"
]);
});import { test, expect } from '@playwright/test';
test('regex text assertions', async ({ page }) => {
await page.goto('/banking/dashboard');
// Match a dollar amount
await expect(page.getByTestId('balance')).toHaveText(/^\$[\d,]+\.\d{2}$/);
// Case-insensitive match
await expect(page.getByTestId('status')).toHaveText(/active/i);
// Match date format
await expect(page.getByTestId('last-login')).toContainText(
/\d{2}\/\d{2}\/\d{4}/
);
// Match dynamic name
await expect(page.getByTestId('greeting')).toHaveText(
/Welcome back, [A-Za-z ]+!/
);
});import { test, expect } from '@playwright/test';
test('case-insensitive text assertions', async ({ page }) => {
await page.goto('/banking');
// Using ignoreCase option (string only, not regex)
await expect(page.getByTestId('status')).toHaveText('active', {
ignoreCase: true,
});
// Matches "Active", "ACTIVE", "active", "AcTiVe"
// With toContainText
await expect(page.getByTestId('message')).toContainText('success', {
ignoreCase: true,
});
});ignoreCase only works with string arguments, not regex. If you pass a regex, use the /i flag instead: toHaveText(/active/i). Passing both a regex and ignoreCase is an error.
Use toHaveText for critical content where exact text matters -- error messages, amounts, usernames. Use toContainText when you only care about a keyword being present -- "Transfer successful" just needs to contain "successful".
Q: What is the difference between toHaveText and toContainText in Playwright?
A: toHaveText matches the entire text content of the element, after trimming whitespace. If the element says "Welcome, John" and you assert toHaveText("Welcome"), it fails because "Welcome" is not the full text. toContainText checks if the text includes the given substring -- so toContainText("Welcome") passes. Both support regex and arrays. Both auto-retry. I use toHaveText when exact text matters -- like verifying an error message. I use toContainText when I only need to confirm a keyword is present, like checking that a status contains "approved" regardless of the full sentence.
Key Point: toHaveText is strict -- matches full text. toContainText is lenient -- matches substring. Both support regex, arrays, and ignoreCase.