In Playwright, when do you use expect(locator).toHaveText() versus toContainText()?
answer
- Whole text versus a fragment of it
- Container text includes label and timestamp
- Scope the locator or loosen the matcher
- A regex is unanchored by default
- Array form also pins the count
basics
~20 sBoth retry and normalize whitespace, but toHaveText requires the element's entire text to match while toContainText only requires a substring. Use the exact form on tightly-scoped elements and the containment form when surrounding text is volatile.
solid answer
~40 s`toHaveText` compares the element's **whole** normalized text against the expected string, so it catches truncation, duplication and stray extra content. `toContainText` passes as long as the expected text appears somewhere inside. On a balance widget whose text reads `Available balance £1,240.00 Updated 09:41`, `toHaveText('£1,240.00')` can never pass — either scope the locator to the amount element, or use `toContainText`. Both normalize whitespace, both retry, and both accept a regex or an array for lists; with `toHaveText` an array must match the list element for element, so it also asserts the count. Watch the regex form: a regex is unanchored, so `toHaveText(/1,240/)` behaves like containment unless you write `^` and `$`. `{ useInnerText: true }` switches the comparison to rendered text when a hidden label pollutes `textContent`.
code
typescript · 10 linesimport { test, expect } from '@playwright/test';
test('balance widget shows the settled amount', async ({ page }) => {
await page.goto('/statements/2026-08');
const widget = page.getByTestId('balance-widget'); // "Available balance £1,240.00 Updated 09:41"
await expect(widget).toContainText('£1,240.00');
await expect(widget.getByTestId('amount')).toHaveText('£1,240.00');
await expect(widget.getByTestId('amount')).toHaveText(/^£[\d,]+\.\d{2}$/);
});go deeper
Recall the split: toHaveText means the element's entire text, toContainText means somewhere inside it. Reaching for containment because the exact form failed is usually a sign the locator is too wide.
Explain whitespace normalization, the unanchored-regex behaviour, and why an array passed to toHaveText also asserts how many elements matched.
Show judgment about assertion strength: exact assertions on stable labels catch real regressions, while over-loose containment lets a wrong balance through and still reports green.
Set the house convention — which elements get test hooks so exact assertions are possible at all — because assertion strength is decided by markup the test team does not own.
## What each matcher actually compares Both matchers retry, both read the element's text, and both normalize whitespace before comparing — runs of spaces, tabs and newlines collapse to a single space and the ends are trimmed. The difference is the comparison itself: - `toHaveText('Available balance')` demands that the **whole** normalized text equals that string. - `toContainText('balance')` demands only that the normalized text **contains** it somewhere. On the statement page that distinction decides which locator you can get away with. The balance widget renders a label, the amount and a timestamp inside one container, so its text content reads `Available balance £1,240.00 Updated 09:41`. `toHaveText('£1,240.00')` on that container fails permanently, because the whole string is not the amount. ## Three ways out of the container trap 1. **Tighten the locator** and keep the exact assertion: `await expect(widget.getByTestId('amount')).toHaveText('£1,240.00')`. Best when the markup gives you a hook — the assertion stays strict and the failure message points at one element. 2. **Loosen the matcher**: `await expect(widget).toContainText('£1,240.00')`. Best when the surrounding text is genuinely volatile, such as a timestamp you do not want to encode in the test. 3. **Match a shape with a regex**: `await expect(amount).toHaveText(/^-?£[\d,]+\.\d{2}$/)` when the exact number is data-dependent but the format is the requirement. ## The regex gotcha `toHaveText` accepts a regular expression, and a regular expression is **not anchored** unless you anchor it. `toHaveText(/1,240/)` therefore behaves like containment and passes on `Available balance £1,240.00`. If you reach for a regex to get exactness, write `^` and `$` yourself. The corollary: a regex handed to `toContainText` is looser still, and `toContainText(/£1,2/)` will happily accept `£1,299.99`. ## Options worth knowing | Option | Effect | |---|---| | `{ useInnerText: true }` | compares rendered `innerText` instead of `textContent`, so visually hidden descendants drop out | | `{ ignoreCase: true }` | case-insensitive comparison for the string form | | `{ timeout }` | overrides the expect timeout for this one assertion | `useInnerText` is the fix when a container carries an off-screen accessibility label that pollutes `textContent`, and it is also the reason two developers can disagree about "the element's text" while both being right — they are reading different properties. ## Lists, in one assertion Both matchers take an array to check a set of elements in document order. With `toHaveText` the list must match element for element, so it doubles as a count assertion: ```ts const amounts = page.getByRole('row').getByTestId('amount'); await expect(amounts).toHaveText(['£12.00', '-£82.40', '£1,310.40']); ``` That single line is stronger than three separate assertions: it pins the ordering and rejects an extra or missing row, which is exactly what a transaction table regression looks like. ## Choosing between them in review - Prefer `toHaveText` for **stable, complete labels**: a button caption, a table header, a status chip. An exact assertion is the one that catches a truncated or duplicated value. - Prefer `toContainText` when the element deliberately mixes **stable and volatile** text, or when you are asserting a message inside a paragraph you do not control. - Beware containment that is too generous. `toContainText('£1,2')` passes for `£1,299.99` and for `£1,240.00` alike; assert enough of the string to be wrong when the value is wrong. - If you find yourself writing `toContainText` because the locator picks up half the page, fix the locator instead — a loose matcher on a loose locator asserts almost nothing. ## Quick reference | What you need to assert | Reach for | |---|---| | the full label of a tightly-scoped element | `toHaveText('Export CSV')` | | a fragment inside a larger, changing block | `toContainText('£1,240.00')` | | a format rather than a value | `toHaveText(/^£[\d,]+\.\d{2}$/)` | | a whole column, in order, with its count | `toHaveText([...])` |
- Why can toHaveText with a regular expression still pass on text that is longer than the pattern?Because a regex is matched, not equated, and it is unanchored unless you anchor it. `toHaveText(/1,240/)` finds that substring anywhere in the normalized text and passes. Add `^` and `$` when you mean exactness, or pass a plain string, which is compared against the whole text.
- When is { useInnerText: true } the right option on a text matcher?When the element's `textContent` includes text that is not rendered — a visually hidden accessibility label, or content in a collapsed child. `innerText` reflects what a user actually sees, so the assertion matches the visible label rather than the DOM's full string.
saying these in an interview costs you the question
- Thinks toHaveText matches a substring like toContainText
- Asserts an exact amount against a whole container element
- Assumes a regex passed to toHaveText is anchored
- Uses containment so loose it passes on wrong values
- Believes the matchers compare raw untrimmed whitespace