Resource library

QA How-To

How to Use Playwright expect toHaveText (2026)

Learn playwright expect toHaveText with exact strings, regex, arrays, whitespace rules, options, timeouts, debugging, and maintainable text assertions.

24 min read | 2,693 words

TL;DR

Pass a Locator directly to `await expect(locator).toHaveText(expected)`. A string checks complete normalized text, a regex checks a variable format, and an array checks collection count, content, and order. Use dedicated matchers for input values and accessible properties.

Key Takeaways

  • Use `await expect(locator).toHaveText(expected)` to retain Playwright's automatic retry behavior.
  • Choose strings for complete normalized copy, constrained regexes for variable formats, and arrays for ordered collections.
  • Remember that string expectations normalize whitespace while regular expressions match actual text as is.
  • Use `toHaveValue` for input values and accessible assertions for programmatic labels and errors.
  • Enable `ignoreCase` and `useInnerText` only when those semantics match a documented requirement.
  • Keep locators scoped to the intended component and avoid positional shortcuts for duplicated messages.
  • Use call logs and traces to diagnose identity, copy, text-property, and timing failures before relaxing assertions.

Use playwright expect toHaveText by passing a Locator to expect, awaiting the assertion, and providing an exact string, regular expression, or ordered array of expected texts. It is a web-first assertion, so Playwright re-resolves the locator and retries the text check until it passes or the assertion timeout expires.

The matcher is simple to type but easy to misuse. Exact strings normalize whitespace, regular expressions use different matching behavior, arrays assert both collection size and order, and useInnerText changes which DOM text property is read. This 2026 guide explains the contract, selection strategy, options, debugging process, and framework decisions that make text checks reliable.

TL;DR

import { test, expect } from '@playwright/test';

test('confirms the save result', async ({ page }) => {
  await page.goto('/profile');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('status')).toHaveText('Profile saved');
});
Expected value Meaning
'Profile saved' Full normalized text must equal the string
/Profile saved at \d{2}:\d{2}/ Actual text must satisfy the regular expression
['Draft', 'Review', 'Published'] Locator must match three elements with these texts in this order

Prefer toHaveText over extracting textContent() for dynamic UI assertions. Use toContainText when only a meaningful substring matters, and use toHaveValue for editable input values.

1. What playwright expect toHaveText does

toHaveText belongs to Playwright's LocatorAssertions. It verifies the text associated with the element or elements resolved by a locator. Nested descendants contribute to the text calculation. Because the assertion receives a locator, it can retry while the DOM changes.

import { test, expect } from '@playwright/test';

test('status changes after submission', async ({ page }) => {
  await page.setContent(`
    <button type="button">Submit report</button>
    <p role="status">Draft</p>
    <script>
      document.querySelector('button').addEventListener('click', () => {
        setTimeout(() => {
          document.querySelector('[role=status]').textContent = 'Submitted';
        }, 120);
      });
    </script>
  `);

  await page.getByRole('button', { name: 'Submit report' }).click();
  await expect(page.getByRole('status')).toHaveText('Submitted');
});

The expected string describes the element's complete normalized text, not merely any substring. The assertion waits through the temporary Draft state and passes when Submitted appears. No explicit wait is required between the click and assertion.

This is different from using text to find an element. page.getByText('Submitted') is a locator strategy, while expect(status).toHaveText('Submitted') is a state assertion on an already chosen element. Tests are clearest when the locator identifies the UI object and the assertion states its expected result.

2. Exact strings, regular expressions, and arrays

Choose the expected type based on the requirement, not convenience.

const status = page.getByRole('status');

await expect(status).toHaveText('Payment approved');
await expect(status).toHaveText(/^Payment approved for order #\d+$/);

const steps = page.getByRole('list', { name: 'Progress' })
  .getByRole('listitem');
await expect(steps).toHaveText(['Cart', 'Delivery', 'Payment']);

An exact string is best for stable product copy that the user should see as a complete message. A regular expression is appropriate for a stable format containing variable data, such as an order number. Anchor it with ^ and $ when the whole value must follow the format. An unanchored expression such as /approved/ can match extra text unintentionally.

An array changes the contract. The locator must resolve to the same number of elements as the expected array, and each element is compared with the corresponding expected entry. This checks count, content, and order in one assertion. If order is not a product requirement, do not sort the UI's actual text inside the test merely to make the assertion pass. Locate items by stable identity and assert them individually, or clarify the expected ordering rule.

The matcher also accepts arrays containing both strings and regular expressions. That is useful when most labels are fixed but one contains dynamic content. Keep the pattern specific enough that a malformed value cannot slip through.

3. Whitespace normalization and text semantics

When the expected value is a string, Playwright normalizes whitespace and line breaks in both actual and expected text. Leading and trailing whitespace is ignored, and runs of whitespace are treated consistently. This allows a readable expectation even when the HTML is formatted across lines.

test('normalizes whitespace for string expectations', async ({ page }) => {
  await page.setContent(`
    <div data-testid="summary">
      Ready
      <strong> for review </strong>
    </div>
  `);

  await expect(page.getByTestId('summary')).toHaveText('Ready for review');
});

Do not conclude that all expected types normalize identically. When you provide a regular expression, Playwright matches the actual text as is. A pattern that assumes one space can fail when the raw text contains a line break or indentation. If whitespace is irrelevant but a regex is necessary, represent it deliberately with \s+ or another constrained pattern.

Text is assembled from the target element and its descendants. An icon label, visually hidden helper, or nested badge can become part of the text content. Before weakening the expectation, inspect what the component exposes and decide what users actually consume. If the requirement concerns the accessible name of a control, toHaveAccessibleName may express it better. If it concerns an input's current editable value, use toHaveValue. Choosing the right semantic property is more important than forcing every string check through one matcher.

Whitespace normalization should improve resilience to markup formatting, not erase meaningful content rules. A price displayed as USD 1 000 may use a nonbreaking space, and a code sample may intentionally preserve indentation. If exact presentation is business-critical, inspect the actual characters and choose a matcher that represents them without relying on accidental HTML indentation. For ordinary headings, labels, and messages, a normalized string remains the most readable expectation.

Case handling is separate from whitespace handling. A string expectation is case-sensitive unless ignoreCase is enabled. Do not switch to a case-insensitive regex merely because normalization handled spaces. Product names, status codes, and identifiers often require exact case even when surrounding layout whitespace is flexible.

4. toHaveText versus toContainText and textContent

These APIs answer different questions.

API Retries Checks Use when
expect(locator).toHaveText(expected) Yes Complete text or ordered text list Full copy or format matters
expect(locator).toContainText(expected) Yes Substring, pattern, or ordered subset Stable fragment matters inside richer copy
await locator.textContent() No assertion retry Returns string | null snapshot Code must consume raw DOM text
await locator.innerText() No assertion retry Returns rendered text snapshot Code must consume layout-aware text

The common fragile pattern is:

const text = await page.getByRole('status').textContent();
expect(text).toBe('Submitted');

textContent() returns one snapshot. If the application is still updating, the generic toBe assertion compares the stale value once. Preserve the locator instead:

await expect(page.getByRole('status')).toHaveText('Submitted');

Use toContainText only when extra surrounding text is allowed by the product contract. A notification like Saved by Maya at 10:42 may warrant toContainText('Saved by Maya'). A validation message required to say exactly Email is required deserves toHaveText. Loose substring checks can hide duplicated words, debug suffixes, and incorrect qualifiers.

For a deeper matcher-family explanation, see how to fix Playwright expect received value must be a Locator.

Another choice is getByText followed by toBeVisible, for example await expect(page.getByText('Submitted')).toBeVisible(). That can be valid when the requirement is that some uniquely identifiable text is visible. It is weaker for a known status component because it lets the text choose the element, possibly matching a toast, hidden duplicate, or unrelated section. Locating the status by role and asserting its text separates identity from state and produces a more precise failure.

Likewise, toHaveText does not require the element to be visible. Text content and visibility are distinct properties. If users must see the message, add toBeVisible() when visibility is not already guaranteed by the scenario or component contract. Avoid assuming that correct DOM text proves correct presentation.

5. How to use playwright expect toHaveText with collections

Collection assertions are one of the matcher's strongest features. Select the repeated elements themselves, not their parent container.

import { test, expect } from '@playwright/test';

test('shows deployment stages in order', async ({ page }) => {
  await page.setContent(`
    <ol aria-label="Deployment stages">
      <li>Build</li>
      <li>Security scan</li>
      <li>Deploy</li>
    </ol>
  `);

  const stages = page.getByRole('list', { name: 'Deployment stages' })
    .getByRole('listitem');

  await expect(stages).toHaveText(['Build', 'Security scan', 'Deploy']);
});

Passing the outer ol locator with an expected array would be wrong because the locator resolves to one element, not three. Playwright expects the locator result count and array length to match. The resulting failure is valuable feedback that the locator and expected structure disagree.

Arrays can mix exact and variable entries:

await expect(stages).toHaveText([
  'Build',
  /^Security scan \(\d+ findings?\)$/,
  'Deploy',
]);

If the list can reorder due to user-selected sorting, first establish the selected sort option, then assert the expected order. If items load incrementally but settle into a defined sequence, the web-first assertion retries the whole collection contract. Pair text arrays with Playwright toHaveCount examples when cardinality needs separate emphasis or different diagnostics.

6. Options: ignoreCase, useInnerText, and timeout

toHaveText supports three important options.

await expect(page.getByRole('status')).toHaveText('READY', {
  ignoreCase: true,
  timeout: 8_000,
});

await expect(page.getByTestId('menu-copy')).toHaveText('Visible option', {
  useInnerText: true,
});

ignoreCase enables case-insensitive matching. When supplied with a regular expression, the option takes precedence over that expression's case flag. Use it only when casing is truly irrelevant. If brand capitalization, an acronym, or a case-sensitive identifier matters, keep the default behavior and assert the correct case.

useInnerText tells Playwright to read element.innerText instead of element.textContent. innerText is layout-aware and generally represents rendered text, while textContent includes text nodes regardless of visibility. The option can help when hidden descendants are intentionally excluded from the user-facing string, but it also introduces layout semantics. Prefer accessible assertions when the requirement is specifically what assistive technology announces.

timeout bounds how long the assertion retries. The default comes from the Playwright Test expect configuration. A local override should reflect a known slow operation, not compensate for an unreliable test environment. When many ordinary text assertions need a huge timeout, investigate the page readiness, test data, or locator scope instead of normalizing slow feedback.

7. Text inside buttons, inputs, and accessible components

Not every visible string lives in ordinary element text. Choose the assertion that maps to the component's user-facing property.

const saveButton = page.getByRole('button', { name: 'Save changes' });
await expect(saveButton).toHaveText('Save changes');

const email = page.getByLabel('Email');
await expect(email).toHaveValue('qa@example.com');

const iconButton = page.getByRole('button', { name: 'Delete report' });
await expect(iconButton).toHaveAccessibleName('Delete report');

For a normal <button>Save changes</button>, toHaveText can verify its content. For <input type="submit" value="Save changes">, the label is represented by the value rather than a child text node, so toHaveValue is the direct state assertion. For an icon-only button labeled with aria-label, there may be no element text to assert. toHaveAccessibleName captures the interaction contract.

Validation is similar. An input's invalid value belongs to toHaveValue, its linked visible message may use toHaveText, and its programmatic accessible error can use toHaveAccessibleErrorMessage when the application exposes that relationship. One broad string assertion cannot replace all three layers.

This distinction also improves interview answers. A senior SDET should explain not only how a matcher is called, but why the chosen matcher corresponds to the DOM and accessibility property under test.

8. Configuration, custom messages, and soft assertions

Project-level expect configuration keeps ordinary timing consistent:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { baseURL: 'http://127.0.0.1:3000' },
  expect: { timeout: 6_000 },
});

A custom message makes the business intent visible in reports:

await expect(
  page.getByRole('status'),
  'profile save should confirm completion',
).toHaveText('Profile saved');

Use messages to add context, not restate the code. status should have text Profile saved adds little. The message above explains which workflow failed.

Soft assertions let a test continue after a mismatch while still marking the test failed:

await expect.soft(page.getByTestId('plan-name')).toHaveText('Enterprise');
await expect.soft(page.getByTestId('billing-cycle')).toHaveText('Annual');
expect(test.info().errors).toHaveLength(0);

Soft checks are useful when several independent fields should be reported together. Do not continue into destructive actions or dependent calculations after a critical text assertion fails. Check test.info().errors before the next phase, or use a hard assertion for the prerequisite. A long test filled with soft assertions can produce confusing cascades rather than better diagnostics.

9. Framework patterns for maintainable text checks

Page objects should expose locators with business names and hide navigation or interactions that truly belong to the page. Tests can then state expected copy directly.

import type { Locator, Page } from '@playwright/test';

export class CheckoutPage {
  readonly confirmation: Locator;
  readonly lineItems: Locator;

  constructor(private readonly page: Page) {
    this.confirmation = page.getByRole('status');
    this.lineItems = page
      .getByRole('list', { name: 'Order items' })
      .getByRole('listitem');
  }

  async open(): Promise<void> {
    await this.page.goto('/checkout');
  }
}
const checkout = new CheckoutPage(page);
await checkout.open();
await expect(checkout.lineItems).toHaveText(['QA course', 'Practice exam']);
await expect(checkout.confirmation).toHaveText(/Order #\d+ is confirmed/);

Avoid a generic helper such as verifyText(selector, expected) that converts every locator into a string selector. It discards locator composition, accessible naming, type information, and useful call-site intent. A domain method can be worthwhile when it coordinates multiple related states, but a one-line wrapper around toHaveText rarely improves the test.

Keep expected copy close to the scenario unless the product has a well-managed message catalog. Centralizing every string in a giant constants file can make reviews harder because the behavior and expectation are separated. Reuse stable domain phrases where it reduces duplication, but keep dynamic regex construction small and explicit.

Typed locator properties also protect against a common abstraction leak. A page-object method that returns Promise<string | null> forces every caller into snapshot assertions and null handling. Exposing a Locator lets callers choose toHaveText, toContainText, visibility, accessibility, and collection assertions without reopening selector details. Extract a string only when the test must calculate or persist a settled value.

For components reused across several pages, a small component object can own the status locator and interaction methods. Keep its public surface aligned with user concepts, such as save() and confirmation, instead of low-level getText() utilities. This makes the expected behavior visible at the test call site and keeps Playwright's diagnostics intact.

10. Debugging a toHaveText failure

Start with the Playwright error. It normally shows the expected text, received text, locator, timeout, and call log. Determine which of four contracts is wrong: element identity, expected copy, text property, or timing.

During local diagnosis, inspect snapshots without replacing the main assertion:

const status = page.getByRole('status');
console.log('textContent:', await status.textContent());
console.log('innerText:', await status.innerText());
console.log('matches:', await status.count());

If the locator matches multiple elements, scope it to the correct region. If textContent includes hidden helper text, decide whether useInnerText, an accessible matcher, or a narrower descendant is the right product contract. If the string differs only in punctuation or casing, confirm the requirement before relaxing the check. If the expected state never appears, inspect the trigger, network response, and console errors instead of increasing timeout.

Also check whether the UI replaces a status with several parallel notifications. A strict locator error is not a signal to add .first() automatically. Determine which notification belongs to the action, then scope by region, accessible name, or test-owned content. Positional shortcuts can make the test pass while asserting an older message left on the page.

Run the scenario with deterministic locale, timezone, and fixture data when text contains formatted values. A failure that appears only on one worker may be caused by environment-dependent formatting rather than retry timing. Preserve the received text in the trace, then correct configuration or expectations at the source.

Trace Viewer is especially effective because it preserves DOM snapshots around each action and assertion. You can see whether the element was replaced, whether an intermediate message persisted, and which locator matched. Read how to use Playwright Trace Viewer for a complete debugging workflow.

Interview Questions and Answers

Q: Why is await expect(locator).toHaveText() more reliable than expect(await locator.textContent()).toBe()?

The locator assertion re-resolves the element and retries until its text matches or the timeout expires. textContent() captures one string | null snapshot, and the generic matcher evaluates it once. The first form therefore matches asynchronous UI behavior without a manual wait.

Q: What happens when toHaveText receives an array?

The locator must resolve to the same number of elements as the expected array. Playwright compares each element's text with the expected entry at the same index. It therefore checks cardinality, text, and ordering together.

Q: How does whitespace handling differ between a string and a regular expression?

For a string expectation, Playwright normalizes whitespace and line breaks in both actual and expected text. For a regular expression, the actual text is matched as is. I use explicit whitespace tokens in a regex when formatting variations are allowed.

Q: When would you enable useInnerText?

I use it when rendered, layout-aware text is the explicit requirement and hidden descendant text should not contribute. I do not enable it automatically. If the true requirement is an accessible label or error, I choose the corresponding accessible assertion instead.

Q: When should toContainText replace toHaveText?

Use toContainText when surrounding copy is allowed to vary and one substring or ordered subset carries the contract. Use toHaveText when the complete normalized message matters. I avoid substring checks that could let broken prefixes, suffixes, or duplication pass.

Q: Can toHaveText assert an input's typed value?

Usually toHaveValue is the correct matcher for an input's current value. Inputs do not represent editable content as child text nodes. I select the matcher according to the DOM property and user behavior being verified.

Common Mistakes

  • Forgetting await, which allows the asynchronous assertion promise to escape the test flow.
  • Passing await locator.textContent() to toHaveText instead of passing the locator.
  • Using an unanchored regular expression when the complete format matters.
  • Expecting regex matching to use the same whitespace normalization as a string.
  • Passing a parent list locator with an expected array for its child items.
  • Using toHaveText for input values, accessible names, or attributes that have dedicated matchers.
  • Enabling ignoreCase simply to silence a product copy defect.
  • Applying useInnerText globally without understanding layout and visibility semantics.
  • Increasing timeouts before checking the action, data, and locator.

Conclusion

The reliable playwright expect toHaveText pattern is await expect(locator).toHaveText(expected). Choose an exact string for stable full copy, a constrained regular expression for variable formats, and an array for ordered collections. Preserve the locator so Playwright can retry, and select toHaveValue or accessible assertions when text content is not the real property.

Take one flaky text test and identify its actual contract: element identity, complete copy, dynamic format, or ordered list. Replace snapshots and sleeps with the corresponding locator assertion, then use the trace to confirm that the test observes the same UI state a user depends on.

Interview Questions and Answers

Explain Playwright toHaveText as a web-first assertion.

It receives a Locator and retries the text comparison during the assertion timeout. Playwright re-resolves the element as the DOM changes, so it can observe asynchronous updates. That is more reliable than extracting text before a generic assertion.

How do string and regular-expression expectations differ in toHaveText?

A string checks complete text after Playwright normalizes whitespace and line breaks. A regular expression matches the actual text as is and can model controlled variation. I anchor the expression when the full format matters.

What happens when toHaveText receives an expected array?

The locator must resolve to the same number of elements as the array. Playwright compares each match with the corresponding expected string or regex in order. This combines cardinality, content, and ordering in one retrying assertion.

When would you choose toContainText instead of toHaveText?

I choose `toContainText` when stable surrounding copy is allowed to vary and only a substring or ordered subset is required. I use `toHaveText` when complete normalized text matters. A substring matcher should not be used merely to silence legitimate copy failures.

How do ignoreCase and a regex case flag interact?

The `ignoreCase` option controls case-insensitive matching and takes precedence over a regular expression's case flag when provided. I use one clear mechanism and only relax case when the product contract says casing is irrelevant.

Why might toHaveAccessibleName be better than toHaveText?

An icon-only control can have a programmatic label through `aria-label` without child text. `toHaveAccessibleName` verifies the interaction label exposed to assistive technology. Matcher choice should follow the user-facing property, not just the visible appearance.

How would you debug a strict-mode or text mismatch failure?

I inspect the call log, locator count, received `textContent`, and rendered `innerText` in the trace. I scope duplicated components by region or stable identity instead of adding `.first()` automatically. Then I confirm locale, fixture data, and the action that should produce the expected state.

Frequently Asked Questions

How do I use expect toHaveText in Playwright?

Pass a Locator to `expect`, await the assertion, and provide a string, regular expression, or array. For example, `await expect(page.getByRole('status')).toHaveText('Saved')` retries until the status has that text.

Does Playwright toHaveText require an exact match?

A string expectation checks the element's complete normalized text. Use `toContainText` when an allowed substring is enough, or use a constrained regular expression when part of the complete value varies.

Does toHaveText normalize whitespace?

Playwright normalizes whitespace and line breaks for string expectations. Regular expressions are matched against the actual text as is, so represent allowed whitespace explicitly when using a regex.

Can Playwright toHaveText check multiple elements?

Yes. Pass an array of strings or regular expressions to a locator that resolves to the repeated elements. Playwright checks that the collection size matches and compares each element in order.

What is useInnerText in Playwright toHaveText?

The `useInnerText: true` option reads layout-aware `innerText` instead of `textContent`. Use it when rendered text is explicitly the contract, not as a universal workaround for unexpected hidden descendants.

Should I use toHaveText for an input value?

Use `toHaveValue` for the current value of an input, textarea, or select. `toHaveText` is intended for element text content and does not represent an editable control's value accurately.

Why does toHaveText time out even though the text appears on the page?

The locator may target a different element, several matches, hidden descendant text, or a parent with extra content. Inspect the received text and locator in the call log and trace, then correct the scope or matcher semantics.

Related Guides