QA How-To
Playwright getByText: Examples and Best Practices
Use Playwright getByText examples for alerts, cards, tables, validation, money, dates, localization, regex, scoping, and reliable TypeScript tests in CI.
22 min read | 2,617 words
TL;DR
Use Playwright `getByText()` for user-visible content, scope repeated phrases to meaningful components, and select substring, exact, or regex matching based on allowed variation. Operate controls with roles or labels and verify outcomes through retrying text assertions.
Key Takeaways
- Scope repeated text to the correct card, row, dialog, or region before asserting it.
- Use exact text for complete short statuses and anchored regex for documented formats.
- Control fixture data, locale, and timezone before asserting dynamic values.
- Use labels and roles for interaction, then text locators for user-visible outcomes.
- Choose toBeHidden or toHaveCount based on whether visibility or DOM removal matters.
- Diagnose application state and ambiguity before increasing assertion timeouts.
Playwright getByText examples are most valuable when they mirror the content problems that make real UI tests flaky: repeated status labels, dynamic totals, disappearing toasts, nested cards, localized copy, and validation messages. A reliable text locator uses the narrowest meaningful phrase, scopes it to the correct component, and pairs it with a retrying assertion.
This guide is a scenario-driven cookbook for TypeScript suites. Each pattern explains what the text represents, why the locator remains stable, and when a role, label, or test ID should replace it.
TL;DR
import { test, expect } from '@playwright/test';
test('shows a refund confirmation', async ({ page }) => {
await page.goto('/orders/4821');
await page.getByRole('button', { name: 'Issue refund' }).click();
const dialog = page.getByRole('dialog', { name: 'Confirm refund' });
await dialog.getByRole('button', { name: 'Refund $38.00' }).click();
await expect(
page.getByText('Refund submitted', { exact: true })
).toBeVisible();
await expect(page.getByText('Amount: $38.00')).toBeVisible();
});
| Content pattern | Matching choice | Example |
|---|---|---|
| Stable phrase in longer copy | Substring string | getByText('Refund submitted') |
| Short status beside similar statuses | Exact string | getByText('Paid', { exact: true }) |
| Controlled reference format | Anchored regex | getByText(/^REF-\d{6}$/) |
| Repeated copy | Scoped locator | card.getByText('In stock') |
| Changing text in one known node | Content assertion | expect(status).toHaveText('Ready') |
1. Playwright getByText Examples for Alerts and Toasts
Alerts and toasts are natural text-locator targets because their content communicates the result. Start the action through a semantic locator, then assert the message. If the application uses a live-region role, scope to it to avoid matching identical copy elsewhere.
test('confirms profile changes', async ({ page }) => {
await page.goto('/profile');
await page.getByLabel('Display name').fill('Nora Singh');
await page.getByRole('button', { name: 'Save profile' }).click();
const status = page.getByRole('status');
await expect(
status.getByText('Profile updated', { exact: true })
).toBeVisible();
});
For error feedback, verify both the high-level message and the detail the user needs.
await page.getByRole('button', { name: 'Upload document' }).click();
const alert = page.getByRole('alert');
await expect(alert.getByText('Upload failed')).toBeVisible();
await expect(alert.getByText('File must be smaller than 10 MB'))
.toBeVisible();
Do not add a fixed wait for a toast. Web-first assertions retry. If the toast disappears quickly, assert it immediately after the triggering action and verify a persistent outcome as well. A message that vanishes before users can perceive it may be a product issue.
When several toasts can stack, use the status or alert item that contains a distinctive phrase, then assert within that item. Avoid a global getByText('Success'), which can match page content, an old toast, or a hidden template. The Playwright assertion examples provide more retrying matcher patterns.
2. Verify Cards and Repeated Product Content
Cards frequently repeat words such as Available, Popular, and Add to cart. The correct strategy is to identify the card first, then check text inside it. A heading often provides stable user-facing identity.
test('shows availability for the selected product', async ({ page }) => {
await page.goto('/catalog');
const card = page.getByRole('article').filter({
has: page.getByRole('heading', { name: 'Mechanical Keyboard' }),
});
await expect(card.getByText('In stock', { exact: true })).toBeVisible();
await expect(card.getByText('Ships in 2 business days')).toBeVisible();
});
If the product title is deliberately unstable, a component test ID can establish identity while text locators validate its content.
const featured = page.getByTestId('featured-product');
await expect(featured.getByText('Limited stock')).toBeVisible();
await expect(featured.getByText(/Only \d+ left/)).toBeVisible();
The regular expression is appropriate only if the exact count does not matter. When inventory calculation is under test, control the fixture and assert the exact number.
Avoid locating the entire card with a common phrase and then using first(). A merchandising reorder would redirect the test. Stable card identity protects the scenario from unrelated inserts and layout changes. Keep the content assertions focused on business facts rather than every decorative label.
3. Test Tables Without Positional Selectors
Tables are another source of duplicated text. Find a row through a stable cell value or test ID, then assert status and amount in that row.
test('shows an overdue invoice', async ({ page }) => {
await page.goto('/invoices');
const table = page.getByRole('table', { name: 'Customer invoices' });
const row = table.getByRole('row').filter({
has: page.getByRole('cell', { name: 'INV-2048' }),
});
await expect(row.getByText('Alpine Retail', { exact: true }))
.toBeVisible();
await expect(row.getByText('Overdue', { exact: true })).toBeVisible();
await expect(row.getByText('$640.00', { exact: true })).toBeVisible();
});
This test survives sorting and pagination changes as long as the requested record is present. If pagination is part of the flow, navigate or search first instead of assuming a row index.
Use getByRole('cell', { name }) when cell semantics and accessible name are the clearest contract. Use getByText inside the row for copy that may live in a badge, nested span, or other noninteractive node. Both approaches are valid, and the choice should describe what the test needs to know.
For a collection assertion, a broad text locator can be intentional:
const overdueBadges = table.getByText('Overdue', { exact: true });
await expect(overdueBadges).toHaveCount(3);
Only assert an exact count with controlled data. Shared environments and background jobs can make collection totals nondeterministic. Stable fixtures are a test-design prerequisite, not a locator option.
4. Check Form Validation Messages
A good form test operates fields by label and validates the exact feedback near the relevant field or inside an alert summary. Text locators should verify meaningful copy, not locate the input.
test('validates an incomplete registration form', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('Work email').fill('not-an-email');
await page.getByRole('button', { name: 'Create account' }).click();
const form = page.getByRole('form', { name: 'Create account' });
await expect(
form.getByText('Enter a valid work email', { exact: true })
).toBeVisible();
await expect(
form.getByText('Password is required', { exact: true })
).toBeVisible();
});
If the application connects error text through aria-describedby, also assert accessible state or use Playwright accessibility assertions where appropriate. The visual message and accessible relationship are related requirements.
Avoid checking only Invalid. Short generic text can appear beside several fields. Exact field-specific wording improves diagnosis. When copy changes frequently but the validation rule is stable, consider asserting the input's invalid state plus a scoped error container. Coordinate with product owners before weakening a legally or operationally important message.
For server validation, submit through the UI and wait for the returned message with a web-first assertion. Do not wait for an arbitrary number of milliseconds. If the API response itself is relevant to the scenario, synchronize on that response while still verifying the displayed message.
5. Playwright getByText Examples for Money and Dates
Money and dates combine formatting, locale, and dynamic data. The most reliable solution is to control inputs and know the expected output. Exact text then validates the complete presentation.
test('formats an invoice total in US English', async ({ page }) => {
await page.goto('/invoices/fixture-usd');
const summary = page.getByRole('region', { name: 'Invoice summary' });
await expect(summary.getByText('Subtotal $1,250.00', { exact: true }))
.toBeVisible();
await expect(summary.getByText('Tax $100.00', { exact: true }))
.toBeVisible();
await expect(summary.getByText('Total $1,350.00', { exact: true }))
.toBeVisible();
});
For a date generated from a known fixture, calculate the expected display with the same declared locale and timezone policy used by the test environment. Do not assume the machine timezone.
const expectedDate = new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeZone: 'UTC',
}).format(new Date('2026-07-13T12:00:00Z'));
await expect(page.getByText(`Published ${expectedDate}`, {
exact: true,
})).toBeVisible();
A regex is useful when the test validates format rather than the specific value, but anchor it and document the intent. /^Total \$\d{1,3}(,\d{3})*\.\d{2}$/ checks a US-style total pattern. It does not validate calculation correctness.
Separate calculation tests from presentation tests when possible. One scenario can verify exact amounts from controlled fixture data; another can verify locale rendering. This produces better failure diagnosis than one permissive assertion attempting both.
6. Validate Search Results and Empty States
Search interfaces often have three content states: loading, results, and empty. Assert the state that matters instead of waiting for network silence alone.
test('shows an empty state for an unknown customer', async ({ page }) => {
await page.goto('/customers');
await page.getByRole('searchbox', { name: 'Search customers' })
.fill('Customer That Does Not Exist');
const results = page.getByRole('region', { name: 'Customer results' });
await expect(
results.getByText('No customers found', { exact: true })
).toBeVisible();
await expect(
results.getByText('Try a different name or email')
).toBeVisible();
});
Scope to the result region because the same guidance might appear in help content. If the query is submitted explicitly, perform that action before asserting. If results update on input, the assertion can retry through debounce and rendering.
A positive search should identify a specific result rather than asserting only that the query text exists, since the search box itself may contain it.
const result = results.getByRole('article').filter({
has: page.getByRole('heading', { name: 'Acme Design' }),
});
await expect(result.getByText('Portland, Oregon')).toBeVisible();
If a loading label appears, verifying its presence can be useful in a dedicated UX test, but critical flow tests should focus on the final state. Very fast environments may legitimately skip a perceptible loading indicator, making a mandatory loading assertion unnecessarily timing-sensitive.
7. Assert Appearance, Change, and Disappearance
Content does not only appear. It may change or disappear after a workflow. Choose a stable locator for the transition.
test('removes an expired banner after renewal', async ({ page }) => {
await page.goto('/subscription');
const expired = page.getByText('Your subscription has expired', {
exact: true,
});
await expect(expired).toBeVisible();
await page.getByRole('button', { name: 'Renew subscription' }).click();
await expect(page.getByText('Subscription renewed')).toBeVisible();
await expect(expired).toBeHidden();
});
toBeHidden() passes when the locator is not visible, including when no matching element remains. Use toHaveCount(0) when DOM removal itself is the contract. Those are different expectations.
For one status node whose text changes, locate the container by role and use toHaveText:
const syncStatus = page.getByRole('status');
await expect(syncStatus).toHaveText('Syncing...');
await expect(syncStatus).toHaveText('All changes saved');
This prevents the old and new text from resolving to different nodes. Conversely, separate getByText assertions are appropriate when independent notices appear.
Do not race transient copy with immediate one-time reads. Let assertions retry, and assert a persistent business state after a short-lived message whenever possible. The test should remain useful on both fast local machines and slower CI workers.
8. Handle Regex, Punctuation, and Special Characters
Regular expressions are powerful for reference formats and controlled alternatives. They also introduce escaping and false-match risks. Anchor patterns when the whole phrase matters.
await expect(page.getByText(/^Ticket #[A-Z]{2}-\d{5}$/))
.toBeVisible();
await expect(page.getByText(/^Status: (Queued|Processing|Complete)$/))
.toBeVisible();
await expect(page.getByText(/^Balance: -?\$\d+\.\d{2}$/))
.toBeVisible();
Literal regex characters such as $, ., (, and + need escaping. When values come from fixture data, a plain exact string avoids this complexity.
Punctuation can distinguish content. getByText('Saved', { exact: true }) does not match Saved!. Decide whether punctuation belongs to the product contract. A substring is more tolerant, but it could also match Not saved. Context and boundaries matter more than brevity.
Do not interpolate uncontrolled user input directly into a regular expression. Escape it or use a string locator. Tests should not become vulnerable to invalid patterns or unintended wildcard behavior.
Keep expressions close to the domain language and add a short variable name when the pattern is complex.
const orderReference = /^Order QF-\d{6} confirmed$/;
await expect(page.getByText(orderReference)).toBeVisible();
A reviewer can now understand the pattern's purpose without decoding it inside a long action chain.
9. Test Localization Without Making Locators Meaningless
Localized tests need a clear objective. If translation accuracy is under test, set a known locale and assert exact translated strings. If the scenario only verifies a workflow across locales, rely more on roles, labels derived from the locale resource, or stable test IDs, then assert a small number of representative messages.
test.use({ locale: 'fr-FR' });
test('shows the French empty cart state', async ({ page }) => {
await page.goto('/fr/cart');
const cart = page.getByRole('main');
await expect(
cart.getByText('Votre panier est vide', { exact: true })
).toBeVisible();
});
A locale setting affects browser locale signals, but the application may also use URL, cookie, profile, or server configuration. Establish the product's locale setup explicitly.
Do not use one regex containing fragments from many languages. That proves little and becomes difficult to maintain. Build locale-specific expected values from reviewed resources or use parameterized tests with a small explicit table.
const cases = [
{ path: '/en/cart', empty: 'Your cart is empty' },
{ path: '/fr/cart', empty: 'Votre panier est vide' },
];
for (const item of cases) {
test(`empty cart copy at ${item.path}`, async ({ page }) => {
await page.goto(item.path);
await expect(page.getByText(item.empty, { exact: true }))
.toBeVisible();
});
}
This keeps each accepted translation visible in the test review. For volatile marketing copy, test the localization resource or content system separately rather than making a critical checkout flow depend on the paragraph.
10. Combine Text with Filters and Semantic Boundaries
Text is often strongest as a filter or assertion inside a semantically identified component. Playwright's filter({ hasText }) narrows candidate locators based on descendant text.
test('opens the failed deployment', async ({ page }) => {
await page.goto('/deployments');
const deployment = page.getByRole('listitem').filter({
hasText: 'api-service',
}).filter({
hasText: 'Failed',
});
await deployment.getByRole('link', { name: 'View logs' }).click();
await expect(page.getByRole('heading', { name: 'Deployment logs' }))
.toBeVisible();
await expect(page.getByText('api-service')).toBeVisible();
});
When possible, replace a generic text filter with a descendant role or test ID that expresses identity more exactly. Two hasText filters are readable here, but a named heading and exact status badge could be even clearer depending on markup.
Text filters are relative to each candidate. Make sure the inner content actually belongs to that candidate rather than a broader page context. Store the narrowed locator in a variable and assert it is unique before a destructive action when the test data could drift.
The Playwright locator strategy guide explains role, label, text, and test-ID tradeoffs. Combining locators is not a workaround. It is how a test expresses relationships such as the Failed status belonging to the api-service deployment.
11. Debug Duplicate and Missing Text in CI
A missing text match can come from unexpected copy, wrong page state, server failure, localization, a frame boundary, or content that did not render. Duplicate matches often come from navigation, responsive duplicates, repeated cards, or hidden templates. A visibility failure means Playwright found text but it was not shown as required.
Reproduce with UI mode or inspect the recorded trace:
npx playwright test tests/orders.spec.ts --ui
npx playwright show-trace test-results/orders-retry1/trace.zip
Trace paths vary by project output, so use the artifact generated by the failed run. Inspect the DOM snapshot, action log, and network activity at the assertion. Confirm punctuation, case when exact matching is active, and the component scope.
A temporary count can reveal ambiguity:
const message = page.getByText('Order complete');
console.log(await message.count());
Do not leave a count followed by conditional selection that accepts whichever match is available. Fix the test data or locator contract.
If the content is in an iframe, use frameLocator. If it is behind a closed shadow root, test through the component's public behavior or add an intentional contract. If the page shows an error response instead of expected copy, diagnose the environment or application rather than weakening the assertion. Text failures often provide valuable evidence about the real user experience.
12. Turn the Examples into a Review Checklist
A useful text-locator review focuses on intent, uniqueness, and observability:
- Is readable copy the correct identity for this target?
- Would role, label, or test ID express the target better?
- Is the string, exact match, or regex as narrow as the requirement?
- Is repeated content scoped to its component?
- Are dynamic values controlled or deliberately patterned?
- Does the assertion retry and prove visible behavior?
- Are locale, timezone, and data assumptions explicit?
- Would an editorial change break an unrelated critical flow?
Keep flow tests focused on stable business phrases and place exhaustive copy checks in content-specific coverage. For example, checkout should verify the payment outcome and total, while a dedicated validation suite can verify each detailed error message.
Review positional methods carefully. first() and nth() are valid for rankings and ordered collections, but they should not stand in for missing business identity. Review broad regular expressions with the same care as broad CSS selectors.
When a flaky text test appears, reduce ambiguity before increasing timeouts. Better scope and controlled data solve more failures than longer waiting. This standard keeps getByText valuable rather than fragile.
Interview Questions and Answers
Q: How would you choose between substring, exact, and regex matching?
I use substring matching for a stable phrase inside longer copy, exact matching when the whole normalized string is the requirement, and regex for controlled alternatives or formats. I choose the narrowest form that permits intended variation. I avoid patterns that can match unrelated content.
Q: How do you test a repeated status such as Active?
I first identify the relevant card, row, or list item by stable business identity. Then I call getByText with exact: true inside that scope. This prevents unrelated records from satisfying the assertion.
Q: How do you test a toast that disappears?
I assert it immediately after the triggering action with a web-first visibility assertion, then verify a persistent business outcome. I do not add a fixed sleep. If the toast duration is too short to test or perceive, I discuss the UX requirement.
Q: When is toHaveText better than getByText?
It is better when I already have a stable element, such as one status region, and its content changes. The assertion keeps element identity constant across the transition. getByText is better when appearance of the phrase identifies the content.
Q: How do you test dynamic money or dates?
I control the underlying fixture and make locale and timezone explicit, then assert the exact formatted result. If only format matters, I use a bounded regex and keep calculation correctness in separate coverage. Broad digit patterns are not sufficient.
Q: What does a strictness failure tell you?
It tells me an action or single-target operation resolved to multiple elements. I treat that as an identity problem and improve scope or matching. I use a positional locator only when position is the product requirement.
Q: How do you handle localized getByText tests?
I set the application locale through its actual contract and assert reviewed locale-specific strings. For general workflows, I reduce copy dependence through semantic locators while keeping key translated outcomes. I do not combine many languages into a permissive regex.
Common Mistakes
- Searching globally for short repeated words such as
Active,Save, orSuccess. - Using getByText to operate controls when a named role expresses intent better.
- Using
first()after a duplicate match without establishing business scope. - Applying exact matching to copy with an intentional amount, date, or suffix.
- Using regex
/\d+/or/.*/and accepting almost any content. - Asserting shared-environment row counts without controlled test data.
- Reading text once instead of using a retrying Playwright assertion.
- Requiring a transient loading label in a fast flow where it may not appear.
- Ignoring locale and timezone while hard-coding formatted values.
- Checking DOM removal with
toBeHidden()when removal itself is the contract. - Weakening expected copy when the application actually entered an error state.
The correction is consistent: identify the right component, define allowed variation, control data, and assert an observable result. Text locators are stable when the content contract is explicit.
Conclusion
These Playwright getByText examples show that reliable content testing depends on scope and intent more than selector length. Use strings for stable phrases, exact matching for short complete statuses, and anchored regex for documented formats. Keep repeated copy inside its card, table row, dialog, region, or list item.
Combine text with roles, labels, and test IDs instead of forcing every target through one locator. Control dynamic data, make locale assumptions explicit, and rely on web-first assertions. Your suite will catch content regressions without turning harmless layout changes into failures.
Interview Questions and Answers
How do you select a text matching strategy?
I use a substring for a stable phrase within longer copy, exact matching for the complete normalized string, and an anchored regex for controlled formats or alternatives. The matcher should accept intended variation and reject unrelated content. I prefer the simplest readable form.
How do you avoid duplicate matches in cards or tables?
I locate the card or row by stable business identity, then search for the text inside it. Semantic containers and child filters make that relationship explicit. I do not use `first()` unless order is part of the requirement.
How would you verify a status transition?
If one stable status node changes, I locate it by role or test ID and use successive `toHaveText()` assertions. If separate notices appear and disappear, I use scoped text locators with visibility assertions. I also verify the persistent business result.
How do you test formatted money?
I control the input data and declare locale and currency, then assert the exact formatted amount. If the test is only about format, I use an anchored pattern and separate it from calculation coverage. I never let a broad number regex stand in for correctness.
What is wrong with a broad regex in getByText?
It can match unrelated content and create a false pass. The test may continue succeeding when the intended message disappears. I anchor expressions and encode only documented variation.
How do you debug text failures in CI?
I inspect the Playwright trace, DOM snapshot, and action log at the assertion. I verify the page state, exact wording, locale, scope, hidden duplicates, and frame context. I correct the contract before changing timeouts.
When do you choose toHaveCount(0) instead of toBeHidden?
`toHaveCount(0)` asserts that no matching element remains in the DOM. `toBeHidden()` asserts that the content is not visible and also accepts absence. I choose based on whether removal or user visibility is the requirement.
Frequently Asked Questions
What is a good Playwright getByText example?
A strong example performs an action with a semantic locator, scopes the expected message to the relevant component, and uses a web-first assertion. It controls dynamic data instead of relying on broad text fragments.
How do I assert exact text with getByText?
Use `page.getByText('Expected copy', { exact: true })` and a matcher such as `toBeVisible()`. Exact string matching uses the whole normalized text and is case-sensitive.
How can I test text in one table row?
Locate the table and identify the row through a stable cell or test ID. Then call getByText within that row so identical values elsewhere cannot satisfy the assertion.
How do I test a disappearing toast in Playwright?
Assert it immediately after the action with a retrying visibility assertion, then verify a persistent outcome. Use `toBeHidden()` for disappearance and avoid fixed delays.
Can getByText test dynamic values?
Yes. Prefer exact expected values from controlled fixture data; use an anchored regular expression when format or a documented range of alternatives is the requirement.
How should I test localized text?
Set the application locale through its real configuration and assert reviewed locale-specific strings. Use semantic locators for general workflow steps to avoid unnecessary copy coupling.
Why does getByText pass on hidden content?
Text matching identifies content, while visibility is a separate condition. Add `toBeVisible()` and scope to the active component when hidden or responsive duplicates exist.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices
- Playwright APIRequestContext: Examples and Best Practices
- Playwright aria snapshot: Examples and Best Practices