QA How-To
Playwright expect toHaveText: Examples and Best Practices
Use playwright expect toHaveText examples for status messages, validation, lists, tables, dynamic text, localization, soft checks, and stable UI tests.
24 min read | 2,504 words
TL;DR
Reliable playwright expect toHaveText examples combine a component-scoped Locator with an exact string, anchored regex, or ordered array. Control dynamic data, choose the text property deliberately, and keep prerequisite assertions hard while using soft checks only for independent fields.
Key Takeaways
- Scope repeated validation, table, and card text to the component that owns the state.
- Use anchored regexes for generated identifiers and variable formats instead of broad keyword patterns.
- Pass an expected array to verify collection size, content, and order together.
- Derive expected copy from independent fixture facts, never from the UI value being tested.
- Use soft assertions only for independent peer fields and add a hard boundary before dependent actions.
- Control locale, time, and shared data when displayed text depends on environment state.
- Review whether each assertion would detect wrong context, duplicate text, hidden stale content, and malformed values.
These playwright expect toHaveText examples show how to test real UI outcomes, including status changes, validation messages, ordered lists, data tables, dynamic identifiers, hidden text, localization, and soft assertion groups. Every recipe keeps a Locator as the received value so Playwright can retry while the page settles.
Copying syntax is easy. Choosing the correct boundary is the senior skill. A text assertion should identify the intended component, model only the variation allowed by the requirement, and fail when user-facing meaning changes. The examples below use current Playwright Test APIs and explain why each pattern is reliable.
TL;DR
| Scenario | Pattern |
|---|---|
| Stable complete message | await expect(status).toHaveText('Saved') |
| Dynamic but constrained message | await expect(status).toHaveText(/^Order #\d+ confirmed$/) |
| Ordered collection | await expect(items).toHaveText(['One', 'Two']) |
| Case is irrelevant by requirement | await expect(status).toHaveText('ready', { ignoreCase: true }) |
| Rendered text must exclude hidden descendants | await expect(panel).toHaveText('Visible', { useInnerText: true }) |
The default should be the narrowest stable locator and the strongest expectation supported by the product contract. Use a string before a regex, and an anchored regex before an unconstrained substring.
1. Start with a fully runnable status example
This file can run as a Playwright Test spec without an application server. It creates the page, triggers an asynchronous transition, and asserts the final status.
import { test, expect } from '@playwright/test';
test('publishes an article', async ({ page }) => {
await page.setContent(`
<button type="button">Publish</button>
<p role="status">Draft</p>
<script>
document.querySelector('button').addEventListener('click', () => {
document.querySelector('[role=status]').textContent = 'Publishing';
setTimeout(() => {
document.querySelector('[role=status]').textContent = 'Published';
}, 150);
});
</script>
`);
const status = page.getByRole('status');
await page.getByRole('button', { name: 'Publish' }).click();
await expect(status).toHaveText('Published');
});
There is no assertion for the transient Publishing state because this scenario cares about completion. If that intermediate state is a requirement, assert it immediately after the click and then assert Published. Be careful with very brief transient states, since observing them can couple a test to implementation speed rather than product behavior.
The locator is defined once but resolved repeatedly. It remains valid even if a framework replaces the status node during rendering. This is a key advantage over storing an element handle or extracting text before the update.
2. Validation-message examples that preserve context
Validation text often repeats across a form, so locating only by message text can create strictness failures or assert the wrong field. Scope through the field group or its accessible relationship.
test('shows an email validation message', async ({ page }) => {
await page.setContent(`
<form>
<div data-testid="email-field">
<label>Email <input name="email"></label>
<p role="alert"></p>
</div>
<button>Continue</button>
</form>
<script>
document.querySelector('form').addEventListener('submit', event => {
event.preventDefault();
document.querySelector('[role=alert]').textContent =
'Enter a valid email address';
});
</script>
`);
await page.getByRole('button', { name: 'Continue' }).click();
const emailField = page.getByTestId('email-field');
await expect(emailField.getByRole('alert'))
.toHaveText('Enter a valid email address');
});
This asserts the full normalized message within the email component. If punctuation is part of approved copy, include it. Do not convert the expectation to /email/ simply because the product currently changes wording frequently. Frequent unplanned copy changes are a requirement or ownership problem, not a reason to make regression checks meaningless.
Also assert focus, aria-invalid, or accessible error relationships when those behaviors matter. Text alone proves the message content, not that keyboard or screen-reader users can associate it with the invalid control. The Playwright accessibility testing guide can help expand this scenario beyond visible copy.
3. Dynamic numbers, dates, and identifiers
Dynamic text needs constrained patterns. Match the stable language and validate the changing portion's format.
const confirmation = page.getByRole('status');
await expect(confirmation).toHaveText(
/^Order #ORD-\d{6} was created$/,
);
This pattern rejects missing prefixes, short identifiers, extra debug text, and incorrect status wording. Compare it with /created/, which could pass Order was not created because the substring is still present.
For values that have business meaning, capturing text and parsing may be better than one large regex. First use a retrying text assertion to establish the stable shape, then extract the settled text and validate relationships with generic assertions:
await expect(confirmation).toHaveText(
/^Delivery: \d{4}-\d{2}-\d{2}$/,
);
const deliveryText = await confirmation.innerText();
const dateText = deliveryText.replace('Delivery: ', '');
const deliveryDate = new Date(`${dateText}T00:00:00Z`);
expect(Number.isNaN(deliveryDate.getTime())).toBe(false);
The extraction is safe after the web-first assertion establishes the final format. For a date that must be after another date, parse both and compare timestamps. A regex checks syntax, not calendar validity or business ordering.
4. Ordered-list playwright expect toHaveText examples
Passing an array checks the number, content, and order of locator matches.
test('sorts priorities from highest to lowest', async ({ page }) => {
await page.setContent(`
<button type="button">Sort by priority</button>
<ul aria-label="Work items">
<li>Low</li><li>Critical</li><li>High</li>
</ul>
<script>
document.querySelector('button').addEventListener('click', () => {
document.querySelector('ul').innerHTML =
'<li>Critical</li><li>High</li><li>Low</li>';
});
</script>
`);
await page.getByRole('button', { name: 'Sort by priority' }).click();
const priorities = page.getByRole('list', { name: 'Work items' })
.getByRole('listitem');
await expect(priorities).toHaveText(['Critical', 'High', 'Low']);
});
The locator must target the list items. Passing the parent ul with three expected strings fails because it resolves to only one element. This structural rule is useful because it prevents a broad container from masquerading as an item collection.
Do not compare sorted arrays if the UI order is the feature being tested. Sorting both expected and actual values erases the ordering contract. If order is irrelevant, assert each expected record through a unique locator or compare a safely extracted set only after the collection has reached a stable count. Read Playwright toHaveCount examples and best practices for collection-size patterns.
5. Table-row and card examples
Repeated components often combine several text nodes. Scope the row by stable identity, then assert individual cells or a deliberate row summary.
test('shows the approved expense row', async ({ page }) => {
await page.goto('/expenses');
const table = page.getByRole('table', { name: 'Expenses' });
const row = table.getByRole('row').filter({ hasText: 'EXP-2048' });
await expect(row.getByRole('cell').nth(0)).toHaveText('EXP-2048');
await expect(row.getByRole('cell').nth(1)).toHaveText('Conference');
await expect(row.getByRole('cell').nth(3)).toHaveText('Approved');
});
This assumes stable column order. If headers can be rearranged, component-specific test IDs or a table helper that maps header names to cell positions may better represent the interaction. Avoid asserting the entire row with one exact string when whitespace, optional icons, or unrelated columns make the expectation hard to review.
For cards, locate the card by heading and assert fields inside it:
const project = page.getByRole('article')
.filter({ has: page.getByRole('heading', { name: 'Mobile checkout' }) });
await expect(project.getByTestId('owner')).toHaveText('Owner: Priya');
await expect(project.getByTestId('health')).toHaveText('At risk');
This prevents identical status labels on other cards from satisfying the wrong assertion. The locator carries context, while the expected text carries the state.
6. Text that includes nested elements
toHaveText considers descendant text. This is convenient for sentences split by styling elements.
test('reads text across nested elements', async ({ page }) => {
await page.setContent(`
<p data-testid="quota">
You used <strong>8</strong> of <strong>10</strong> test runs
</p>
`);
await expect(page.getByTestId('quota'))
.toHaveText('You used 8 of 10 test runs');
});
String expectations normalize whitespace, so line breaks and indentation in the markup do not require a brittle literal. If one number varies, constrain it:
await expect(page.getByTestId('quota'))
.toHaveText(/^You used \d+ of 10 test runs$/);
Remember that a regex sees actual text without the string-normalization behavior. Inspect the received value before adding .* everywhere. A pattern such as /You used\s+\d+\s+of\s+10 test runs/ can permit intentional whitespace variation while keeping the wording strict.
Nested decorative or visually hidden text may also contribute. If a screen-reader-only suffix is part of accessible output but not visual copy, decide whether text content, rendered text, or accessible name is the required property. Matcher selection should follow that decision.
7. Hidden descendants and useInnerText
The default text source is based on textContent. The useInnerText option switches to layout-aware innerText, which can exclude hidden descendants.
test('asserts only rendered panel copy', async ({ page }) => {
await page.setContent(`
<section data-testid="result">
Passed
<span style="display:none">internal-code-17</span>
</section>
`);
const result = page.getByTestId('result');
await expect(result).toHaveText('Passed', { useInnerText: true });
});
Do not treat useInnerText: true as a universal fix for unexpected text. It changes the property under test and depends on rendering. First ask why the hidden text exists. It may be an accessibility label that should be verified differently, stale content that signals a product defect, or implementation metadata that belongs in an attribute rather than text.
When an element is icon-only with an aria-label, toHaveText may see no useful content regardless of this option. Assert toHaveAccessibleName. When text lives in an input's value, use toHaveValue. These dedicated assertions communicate the expected interface more accurately.
8. Case-insensitive and localization examples
ignoreCase is appropriate only when casing is explicitly outside the requirement:
await expect(page.getByRole('status')).toHaveText('connected', {
ignoreCase: true,
});
The option takes precedence over a regular expression's case flag. Prefer one clear mechanism rather than combining an /i expression with a conflicting option.
Localization tests should not use ignoreCase or broad regexes to make every language resemble English. Set the locale through a project or context, load deterministic translations, and assert the expected localized copy:
import { test, expect } from '@playwright/test';
test.use({ locale: 'fr-FR' });
test('shows the French completion message', async ({ page }) => {
await page.goto('/checkout/complete');
await expect(page.getByRole('heading', { level: 1 }))
.toHaveText('Commande confirmee');
});
The sample omits accents only if that is the application's approved fixture copy. Real expectations should exactly match the product translation, including accents and punctuation. For locale-dependent numbers and dates, assert a controlled fixture and the formatter's expected output. Do not generate the expected string with the same production formatter, since the same defect could affect both actual and expected results.
9. Soft assertions for independent text fields
Soft assertions are useful for a read-only summary where several independent mismatches should appear in one report.
import { test, expect } from '@playwright/test';
test('shows account summary', async ({ page }) => {
await page.goto('/account');
await expect.soft(page.getByTestId('plan')).toHaveText('Professional');
await expect.soft(page.getByTestId('region')).toHaveText('Asia Pacific');
await expect.soft(page.getByTestId('renewal')).toHaveText(/^Renews on /);
expect(test.info().errors).toHaveLength(0);
});
Each failure is recorded, and the test continues. The final hard check prevents the scenario from moving into a later phase after summary validation has failed. Soft assertions still mark the test as failed even without that check, but an explicit boundary communicates that subsequent steps depend on a clean summary.
Do not make a login confirmation soft and then continue to billing actions. A failed prerequisite can cause misleading secondary failures or unsafe actions. Use hard text assertions for gates and soft assertions for peer fields whose diagnostics benefit from aggregation.
Custom messages can improve reports:
await expect.soft(
page.getByTestId('region'),
'account summary should use the provisioned region',
).toHaveText('Asia Pacific');
The message explains the data source and business expectation rather than repeating the matcher.
10. Timeout and slow-transition examples
Pass a local timeout when a known workflow has a longer service-level expectation:
await page.getByRole('button', { name: 'Generate report' }).click();
await expect(page.getByRole('status')).toHaveText('Report ready', {
timeout: 20_000,
});
This says the report can legitimately take up to 20 seconds in this test environment. It does not make a broken trigger correct. If the received text stays Idle, inspect whether the click occurred. If it stays Generating, inspect the network and backend outcome. If the locator matches several statuses, fix its scope.
For normal interactions, configure an expect timeout centrally:
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: { timeout: 7_000 },
});
Avoid setting a huge global value because one slow feature exists. Long defaults delay feedback for misspelled locators and impossible expectations across the entire suite. A local override documents the exceptional operation and keeps ordinary failures fast.
Never add page.waitForTimeout() solely before toHaveText. The locator assertion already waits on its condition. A fixed sleep adds elapsed time without improving the text contract.
11. Page-object example with typed locators
Expose component locators and let the scenario supply expected business values.
import type { Locator, Page } from '@playwright/test';
export class ImportPage {
readonly status: Locator;
readonly errors: Locator;
constructor(private readonly page: Page) {
this.status = page.getByRole('status');
this.errors = page
.getByRole('list', { name: 'Import errors' })
.getByRole('listitem');
}
async open(): Promise<void> {
await this.page.goto('/imports/new');
}
}
const importPage = new ImportPage(page);
await importPage.open();
await page.getByLabel('CSV file').setInputFiles('fixtures/invalid-users.csv');
await page.getByRole('button', { name: 'Import' }).click();
await expect(importPage.status).toHaveText('Import failed');
await expect(importPage.errors).toHaveText([
/Row 2: email is required/,
/Row 4: role is not supported/,
]);
The page object knows where status and errors live. The test knows which fixture was uploaded and therefore which messages should appear. A generic assertText(selector, value) helper would weaken both roles by passing string selectors and hiding Playwright's expressive locator composition.
If expected messages are reused across many import tests, a small domain builder can create them from known fixture facts. Keep it independent of production formatting code so it remains capable of detecting a formatting defect.
12. Reviewing playwright expect toHaveText examples for reliability
Review each assertion with a short risk checklist. First, does the locator identify exactly one component or the intended ordered collection? Second, does the expected value model only legitimate variation? Third, is the text property actually the user contract? Fourth, does the test own the data and trigger that produce the message?
Strong examples contain evidence on both sides of a transition. A save test fills a known value, clicks Save, and asserts a completion message plus the persisted field after reload when persistence matters. A search test enters a controlled term, asserts result titles, and distinguishes empty state from loading state. The text assertion is one observation inside a coherent scenario.
Weak examples assert generic words such as Success, use page-wide text locators, or accept .* around every phrase. They may pass when another banner contains the word or when the product includes contradictory copy. Strong locators and constrained expectations prevent these false positives.
Review the failure mode as well as the passing path. Ask whether the assertion would fail if the message appeared in the wrong card, if two copies were rendered, if a hidden stale message remained, or if an identifier used the wrong format. A useful assertion is sensitive to the defects the scenario claims to cover. Mutation-style thinking often reveals that a passing regex is too broad or that the locator lacks component scope.
Test data should make expected text calculable before the browser action. Create a user named for the scenario, upload a fixture with known row errors, or stub a response with a controlled identifier pattern. Avoid taking the current UI text and using it to construct the expected text, because that compares the page with itself. When calculations are necessary, derive expected output from independent fixture facts.
For highly dynamic notifications, distinguish allowed variation from nondeterminism. A server timestamp may warrant a controlled clock, not a wildcard. A generated ID may warrant a format check plus an API consistency check. A translated message may warrant a locale-specific expected catalog. Each approach preserves meaningful coverage while removing accidental instability.
When a failure occurs, inspect the call log and Trace Viewer before editing the pattern. The guide on debugging Playwright tests with Trace Viewer explains how DOM snapshots reveal intermediate text, replaced nodes, and locator ambiguity.
Finally, check parallel behavior. If a message includes a shared account name or record count, another worker may alter the expected copy. Isolate users and records instead of increasing the text timeout. A retry that passes after shared state changes is evidence of contamination, not a durable resolution.
Interview Questions and Answers
Q: Why should the locator be passed directly to toHaveText?
The matcher can re-resolve the locator and retry as the DOM changes. Extracting textContent() first produces one string | null snapshot and loses that retry behavior. Keeping the locator also yields better call logs and selector diagnostics.
Q: What does an expected array verify?
It verifies that the locator resolves to the same number of elements, then compares text item by item in order. That makes it suitable for sorted lists, workflow steps, and deterministic menus. It is not the right shortcut when order is intentionally undefined.
Q: How would you test a message containing a generated order ID?
I use an anchored regular expression that includes stable copy and constrains the identifier format, such as ^Order #ORD-\d{6} was created$. If the ID must be used later, I first wait for the complete shape, then extract and parse the settled text.
Q: Why can /success/ be a dangerous expectation?
It is unanchored and may match unintended or contradictory content. It could also match a different component if the locator is broad. I use full strings or specific anchored patterns that reject extra content unless the requirement allows it.
Q: When are soft text assertions appropriate?
They are appropriate for independent peer fields where collecting several mismatches improves diagnosis. They are inappropriate for prerequisites such as authentication or navigation state. I place a hard boundary before dependent actions.
Q: How do you decide between toHaveText, toHaveValue, and toHaveAccessibleName?
I map the assertion to the property users depend on. Child text uses toHaveText, an editable control's current value uses toHaveValue, and a control's programmatic label uses toHaveAccessibleName. The visible appearance alone does not determine the DOM property.
Common Mistakes
- Extracting text before the assertion and expecting web-first retries.
- Using page-wide
getByTextlocators for repeated validation messages. - Passing the parent list element with an expected array for child items.
- Using broad, unanchored regex patterns that accept contradictory or malformed text.
- Sorting actual UI values when the test is supposed to verify their displayed order.
- Enabling
useInnerTextwithout deciding whether rendered text is the true contract. - Using
toHaveTextfor input values or accessible labels. - Making prerequisite assertions soft and continuing into dependent actions.
- Adding fixed sleeps before a matcher that already retries.
- Generating expected localized output with the same formatter as production.
Conclusion
The most useful playwright expect toHaveText examples pair a precisely scoped locator with the strongest stable expectation. Use exact strings for approved copy, anchored regexes for controlled variation, arrays for ordered collections, and dedicated matchers when the state belongs to a value or accessible property.
Choose one recipe that matches your current risk, then adapt the locator and data to your application. Keep the assertion web-first, review every allowed variation, and use trace evidence to fix failures instead of weakening the expected text.
Interview Questions and Answers
Show a reliable pattern for asserting an asynchronous status message.
I trigger the user action, retain a locator for the status region, and write `await expect(status).toHaveText('Published')`. The locator assertion retries through intermediate states. I add a destination or persisted-state assertion if the status alone does not prove the workflow.
How would you assert a generated order confirmation message?
I use an anchored regex that includes stable copy and constrains the ID, for example `^Order #ORD-\d{6} was created
How do you avoid asserting the wrong repeated validation message?
I locate the field group or form region first, then locate its alert within that scope. This separates component identity from message state. I avoid page-wide message locators and positional shortcuts unless order is itself stable and meaningful.
How should ordered collection text be asserted?
I target each repeated item and pass an expected array to `toHaveText`. Playwright checks the element count and compares entries in order while retrying. I do not sort actual values if displayed order is the feature under test.
What is a safe use of soft text assertions?
A read-only account summary with several independent fields is a good use because one mismatch should not hide the others. I check `test.info().errors` before moving to another phase. Critical prerequisites remain hard assertions.
How do you make localization text tests independent?
I configure locale and timezone explicitly and use known fixture values. Expected translations come from approved test data or requirements, not the same formatter used by production. This allows the test to detect translation and formatting defects.
What review questions improve toHaveText examples?
I ask whether the locator owns the message, whether all permitted variation is justified, and whether the correct property is being checked. I also consider duplicates, hidden text, malformed dynamic values, parallel data, and the failure evidence available in traces.
Frequently Asked Questions
What is a simple Playwright toHaveText example?
After the action that changes a status, write `await expect(page.getByRole('status')).toHaveText('Published')`. The assertion waits for the locator's complete normalized text to match.
How do I test dynamic text with Playwright?
Use a constrained, usually anchored regular expression for the variable format. For business relationships such as date ordering, first wait for a stable shape, then extract and parse the settled value with independent assertions.
How do I assert validation messages in Playwright?
Scope the alert or message through the field group that owns it, then use `toHaveText` with approved copy. Add focus, invalid state, or accessible error assertions when those relationships are also requirements.
Can toHaveText test sorted lists?
Yes. Target the list items and pass the expected texts in display order. Do not sort the actual UI values in the test because that removes the ordering behavior you intended to verify.
How do I exclude hidden text from a Playwright text assertion?
Use `{ useInnerText: true }` when layout-aware rendered text is the actual contract. First confirm whether the hidden content should instead be tested through an accessible matcher or treated as a product issue.
When should I use expect.soft with toHaveText?
Use soft text assertions for independent summary fields when collecting several mismatches helps diagnosis. Keep navigation, authentication, and other prerequisites as hard assertions, and stop before dependent actions if soft failures exist.
How do I test localized text without flaky expectations?
Set an explicit locale, timezone, and controlled fixture data, then assert the approved translation and formatted output. Do not create the expected string with the same production formatter because one defect could affect both sides.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright expect soft: Examples and Best Practices
- Playwright expect toBeVisible: Examples and Best Practices
- Playwright expect toHaveCount: Examples and Best Practices
- Playwright expect toHaveURL: Examples and Best Practices
- Playwright expect toHaveValue: Examples and Best Practices