QA How-To
Playwright getByTestId: Examples and Best Practices
Learn Playwright getByTestId examples for naming, custom attributes, React components, dynamic data, debugging, migration, and reliable UI tests in CI.
22 min read | 2,942 words
TL;DR
Playwright `getByTestId()` targets `data-testid` by default and returns a full Locator with lazy resolution and auto-waiting. Use it for deliberate, stable component identity, then interact semantically and assert a user-visible result.
Key Takeaways
- Use test IDs selectively when semantic locators cannot provide stable identity.
- Configure one test ID attribute for the project and document it.
- Name IDs after business purpose, never style, position, or framework details.
- Keep dynamic identifiers stable, deterministic, and free of sensitive data.
- Use roles and labels inside a test-ID component boundary when possible.
- Verify visible business outcomes instead of testing only implementation hooks.
Playwright getByTestId examples should demonstrate how to create a stable automation contract, not how to avoid thinking about locators. The method targets a dedicated test identifier, data-testid by default, and returns a Playwright Locator with lazy resolution, strictness, actionability checks, and retrying assertions.
The strongest use of getByTestId is selective. Give identity to components or controls that cannot be described reliably by role, label, or visible copy. Continue to assert outcomes that a user can observe. This guide covers naming, configuration, component design, repeated data, debugging, and safe migration from brittle selectors.
TL;DR
<article data-testid="subscription-enterprise">
<h2>Enterprise</h2>
<button>Request a demo</button>
</article>
import { test, expect } from '@playwright/test';
test('opens the enterprise inquiry form', async ({ page }) => {
await page.goto('/pricing');
const plan = page.getByTestId('subscription-enterprise');
await plan.getByRole('button', { name: 'Request a demo' }).click();
await expect(page.getByRole('dialog', { name: 'Request a demo' }))
.toBeVisible();
});
| Decision | Strong choice | Weak choice |
|---|---|---|
| Test ID meaning | checkout-submit |
green-button |
| Component identity | Stable product key | Array index |
| Action inside a component | Role or label when available | Test ID on every descendant |
| Verification | Visible status, URL, value, or state | Presence of the same test ID |
| Attribute convention | One configured data-* name |
Mixed attributes across teams |
1. What getByTestId Matches
With default configuration, page.getByTestId('order-summary') matches an element whose data-testid attribute equals order-summary. The argument can be a string or regular expression. The method is available on Page, Locator, and FrameLocator contexts, so a test can begin globally or inside a meaningful boundary.
<section data-testid="order-summary">
<p data-testid="order-total">$126.00</p>
<button data-testid="place-order">Place order</button>
</section>
const summary = page.getByTestId('order-summary');
await expect(summary.getByTestId('order-total')).toHaveText('$126.00');
await summary.getByTestId('place-order').click();
The returned Locator does not store a one-time element handle. It resolves against the current DOM when an action or assertion needs it. A React rerender can replace the section, and the same locator can still find the new matching node.
Test IDs do not provide accessibility or behavior by themselves. A data-testid attribute does not make a div keyboard accessible, calculate an accessible name, or prove that the user saw a result. It is simply an explicit agreement between application code and test code. That agreement is powerful when it is purposeful and risky when it becomes a substitute for semantic HTML.
Values should normally be unique within their intended scope. Playwright actions are strict, so two visible matching elements cause an error rather than an arbitrary click. That behavior protects the suite from silent false passes.
2. Set Up a Runnable Test ID Flow
A standard Playwright TypeScript project needs no extra selector library. The official initializer creates the runner configuration and example test structure.
npm init playwright@latest
npx playwright test
Here is a complete workflow that combines a test ID boundary with user-facing locators and assertions.
import { test, expect } from '@playwright/test';
test('adds a recommended product to the cart', async ({ page }) => {
await page.goto('/recommendations');
const recommendation = page.getByTestId('recommendation-travel-mug');
await expect(recommendation.getByRole('heading'))
.toHaveText('Insulated Travel Mug');
await recommendation.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByRole('status')).toHaveText(
'Insulated Travel Mug added to cart'
);
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
The test ID handles product identity even if the recommendation heading includes a promotional suffix. The nested role locator checks that the component remains operable through an accessible button. The status message verifies the user-visible outcome, while the cart count verifies persistent interface state.
Configure a baseURL in playwright.config.ts if relative URLs are used. Run a single file in UI mode while developing:
npx playwright test tests/recommendations.spec.ts --ui
A reliable test is not one that merely finds an element. It proves a useful behavior and leaves failures understandable. The Playwright end-to-end testing tutorial covers broader project, fixture, and CI setup.
3. Configure a Custom Test ID Attribute
Existing applications often use data-qa, data-test, or data-cy. Playwright can treat one chosen attribute as the test ID contract. Set it once in project configuration.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
testIdAttribute: 'data-qa',
},
});
Application markup and test code can then remain concise:
<button data-qa="billing-save">Save billing details</button>
await page.getByTestId('billing-save').click();
Central configuration is preferable to raw attribute selectors spread through the suite. It makes the convention visible and allows a controlled migration later. Pick one attribute for a product unless a genuine integration constraint requires otherwise. A mixture of data-testid, data-test, and data-qa increases cognitive cost and weakens review standards.
Do not configure the native id attribute as a shortcut. HTML IDs may support labels, fragment navigation, scripts, or third-party widgets. Those responsibilities can force changes unrelated to testing. A dedicated data attribute clearly communicates ownership.
Playwright also exposes selector test-ID configuration programmatically, but changing global selector behavior during tests makes meaning dependent on execution context. Project configuration is safer for parallel workers and easier to discover. If multiple applications in one repository need different attributes, define separate Playwright projects with explicit settings rather than mutating configuration inside scenarios.
4. Design a Durable Naming Convention
Good identifiers describe product purpose and survive layout, style, and framework changes. Use lowercase kebab case and a small, documented grammar such as <feature>-<component>-<purpose>. Shorten it when the parent scope already supplies context.
| Test ID | Assessment | Reason |
|---|---|---|
checkout-payment-submit |
Strong | Names feature and action |
invoice-balance |
Strong | Names stable domain information |
profile-avatar-upload |
Strong | Describes a clear capability |
blue-primary-button |
Weak | Couples to styling |
right-panel-row-3 |
Weak | Couples to layout and order |
mui-box-47 |
Weak | Couples to implementation |
customer-918274-delete |
Risky | May expose volatile data |
Do not use database IDs, email addresses, access tokens, or personal details in client-side test attributes. Test IDs appear in the DOM, screenshots, traces, and reports. Prefer a non-sensitive stable key from controlled test data, such as customer-alpine-retail, when a dynamic identity is necessary.
Avoid including the element tag in the name. checkout-submit-button becomes inaccurate if the design changes to an input or menu item while preserving behavior. checkout-submit describes the business action. Likewise, react-select-country ties the contract to a library; shipping-country expresses the purpose.
A naming convention should define ownership. Component maintainers should treat removing or renaming a test ID like changing a supported interface. The automation team should remove unused IDs and avoid demanding hooks for scenarios that semantics already support.
5. Playwright getByTestId Examples for React Components
React components can accept a test ID intentionally without spreading it to every internal node. A stable root ID gives the test a component boundary, and semantic locators handle controls inside it.
type AccountCardProps = {
accountKey: string;
displayName: string;
onOpen: () => void;
};
export function AccountCard({
accountKey,
displayName,
onOpen,
}: AccountCardProps) {
return (
<article data-testid={`account-card-${accountKey}`}>
<h2>{displayName}</h2>
<button type="button" onClick={onOpen}>
View account
</button>
</article>
);
}
test('opens the Alpine Retail account', async ({ page }) => {
await page.goto('/accounts');
const account = page.getByTestId('account-card-alpine');
await expect(account.getByRole('heading')).toHaveText('Alpine Retail');
await account.getByRole('button', { name: 'View account' }).click();
await expect(page).toHaveURL(/\/accounts\/alpine$/);
});
The application uses a controlled, non-sensitive fixture key rather than a generated list index. Reordering the cards does not change the identity. The test still validates the heading, accessible action, and resulting URL.
Do not add a generic prop that allows callers to inject arbitrary test IDs into every component unless the design system has a clear policy. That pattern can create inconsistent names and leak testing concerns through the entire component tree. For shared components, a well-named root hook or a semantic API is often enough.
If server rendering or hydration replaces markup, Locator laziness protects the test as long as the stable attribute remains. Avoid capturing element handles before hydration and avoid relying on generated framework attributes.
6. Repeated Rows, Virtualized Lists, and Dynamic Data
Repeated data creates a legitimate question: where should identity live? If visible business text is stable, filter a row or list item by that content. If text is localized, truncated, or intentionally volatile, a test ID on the repeated component can provide stable identity.
<tr data-testid="invoice-acme-july">
<td>INV-2026-0714</td>
<td>Acme Design</td>
<td>$920.00</td>
<td><button>Open</button></td>
</tr>
const invoice = page.getByTestId('invoice-acme-july');
await expect(invoice.getByRole('cell', { name: 'Acme Design' }))
.toBeVisible();
await invoice.getByRole('button', { name: 'Open' }).click();
For virtualized lists, an offscreen item may not exist in the DOM until the application renders it. A test ID cannot bypass virtualization. Interact through the product's search, filtering, or scrolling behavior and wait for the intended item to be attached or visible.
await page.getByRole('textbox', { name: 'Search invoices' })
.fill('Acme Design');
const result = page.getByTestId('invoice-acme-july');
await expect(result).toBeVisible();
await result.getByRole('button', { name: 'Open' }).click();
Do not build regular expressions that accidentally match several dynamic keys. getByTestId(/^invoice-/) is useful for counting or asserting a collection, but an action on that locator fails strictness if several invoices exist. Narrow by a full controlled key or scope further.
When test data is generated, return a stable alias from the fixture setup rather than embedding an unpredictable database identifier into selectors. The test should own enough data identity to remain deterministic across parallel workers.
7. Combine Test IDs with Roles, Labels, and Text
An all-test-ID suite misses useful signals. If a Save control loses its accessible name but still has data-testid='save', the test continues clicking even though keyboard or assistive technology users may struggle. A hybrid strategy uses a test ID where it adds identity and semantic locators where the UI already supplies meaning.
test('changes the shipping address for one order', async ({ page }) => {
await page.goto('/orders');
const order = page.getByTestId('order-ocean-2026');
await order.getByRole('button', { name: 'Change shipping address' })
.click();
const form = page.getByTestId('shipping-address-form');
await form.getByLabel('Street address').fill('42 Market Street');
await form.getByLabel('City').fill('Portland');
await form.getByRole('button', { name: 'Save address' }).click();
await expect(page.getByRole('status')).toHaveText('Shipping address saved');
await expect(order).toContainText('42 Market Street');
});
The test IDs select an order and form boundary. Labels and roles operate the actual controls. Visible assertions verify success. This division makes each locator explain why it exists.
For interactive controls with stable accessible names, begin with the Playwright getByRole examples. Use getByText for visible noninteractive content and getByLabel for associated form fields. Reserve CSS or XPath for cases where DOM structure itself is the requirement and no user-facing or explicit test contract fits.
A team may define a locator order, but exceptions should remain reasoned. The goal is not to minimize the count of test IDs. The goal is to maximize stable, understandable product contracts.
8. Assert Outcomes Instead of Selector Implementation
A common weak pattern clicks a test ID and then checks another hidden test ID without proving user behavior. Prefer visible headings, statuses, values, URLs, dialogs, enabled states, or downloaded files as the outcome.
test('archives a completed project', async ({ page }) => {
await page.goto('/projects/completed');
const project = page.getByTestId('project-northstar');
await project.getByRole('button', { name: 'Archive' }).click();
const confirm = page.getByRole('dialog', { name: 'Archive project' });
await expect(confirm).toContainText('Northstar');
await confirm.getByRole('button', { name: 'Archive project' }).click();
await expect(project).toHaveCount(0);
await expect(page.getByRole('status')).toHaveText('Northstar archived');
});
Playwright web-first assertions retry until the condition passes or times out. toHaveCount(0) proves the matched component left the current list. The status assertion proves feedback was given. Both are stronger than checking that a private Redux flag changed.
It is valid to assert a test ID when presence of a component is the intended public UI state. Even then, add a semantic or visible assertion that establishes what the component represents. Avoid assertions about CSS classes unless styling state is specifically under test.
For async work, do not add waitForTimeout. Locator actions already perform actionability checks, and assertions retry. If a scenario requires a download, popup, or response event, register that event wait before triggering it. Test IDs solve identity, not synchronization.
9. Debug Missing, Duplicate, and Stale IDs
A zero-match failure usually means the attribute was renamed, removed from the tested build, placed on a different node, not rendered in the current state, or configured under another attribute name. A strictness failure means the value is duplicated within the locator's scope. An action timeout can mean the matching element exists but is hidden, covered, disabled, or moving.
Use UI mode and the inspector to examine the live DOM and action log:
npx playwright test tests/checkout.spec.ts --ui
npx playwright test tests/checkout.spec.ts --debug
Temporarily inspect count and visibility when diagnosing:
const submit = page.getByTestId('checkout-submit');
console.log('matches:', await submit.count());
await expect(submit).toBeVisible();
Remove diagnostic logging once identity is corrected. If the production-like build strips test attributes, decide whether that transformation is intended. Testing one artifact while deploying another reduces confidence. Keeping non-sensitive data attributes usually has negligible behavioral impact, but the choice belongs in the application's build policy.
A locator cannot cross an iframe boundary. Select the frame first:
const editor = page.frameLocator('iframe[title="Document editor"]');
await editor.getByTestId('document-title').fill('Quarterly plan');
Traces help distinguish a missing ID from a transient state. Review the DOM snapshot before the failure and check whether a rerender created duplicates. Fix the component lifecycle or selector scope rather than increasing the timeout.
10. Encapsulate Contracts in Page and Component Objects
Component objects are a natural fit for test ID boundaries. They can own a root hook and expose tasks through semantic descendants. Keep scenario-specific expectations in the test while allowing the component to verify its reusable contract.
import { expect, type Locator, type Page } from '@playwright/test';
export class CartDrawer {
readonly root: Locator;
constructor(page: Page) {
this.root = page.getByTestId('cart-drawer');
}
item(itemKey: string): Locator {
return this.root.getByTestId(`cart-item-${itemKey}`);
}
async remove(itemKey: string): Promise<void> {
const item = this.item(itemKey);
await item.getByRole('button', { name: 'Remove' }).click();
await expect(item).toHaveCount(0);
}
async checkout(): Promise<void> {
await this.root.getByRole('button', { name: 'Checkout' }).click();
}
}
This object does not expose every leaf locator. It models tasks and keeps the dynamic ID grammar in one place. A test can assert the cart total before calling checkout(), then verify navigation or confirmation afterward.
Do not turn test IDs into a second page-object language with deeply nested constants for every component. Names should remain readable at their point of use. Centralize only repeated construction rules and boundaries that genuinely reduce duplication.
When component IDs change, a small object can localize the update. That benefit does not justify hiding unclear names. Reviewers should still understand that cart-item-travel-mug represents product identity rather than DOM position.
11. Migrate from CSS or XPath Safely
Start migration with selectors that fail frequently or obscure business intent. Replace one selector at a time, add an outcome assertion, and run the focused test. Avoid a mechanical global rewrite because different selectors encode different contracts.
| Existing selector | Better direction | Reason |
|---|---|---|
.btn.btn-primary:nth-child(2) |
Named role | The action already has user-facing identity |
#react-root > div > div[3] |
Component test ID | A stable boundary is missing |
//tr[td[2]='Acme'] |
Row filter or controlled row ID | Expresses business identity clearly |
[data-testid='save'] |
getByTestId('save') |
Uses the configured locator API |
| Generated CSS module class | Role, label, text, or test ID | Class is implementation detail |
When adding an attribute, agree with the component owner on a durable name. Do not insert an ID solely in test-time DOM manipulation. The deployed application and tested artifact should share the contract.
Run the migrated test across configured browser projects and inspect traces for unexpected matches. Then remove obsolete helper methods and unused attributes. A transition period can support old raw selectors and new locators, but establish a direction so conventions do not remain mixed forever.
The Playwright locator best practices guide can help classify each legacy selector before changing it. Migration is successful when the new test explains product intent and fails less ambiguously, not merely when it uses a newer method.
12. Playwright getByTestId Examples as a Product Interface
Test IDs cross application and automation ownership, so they need lightweight governance. Document the configured attribute, naming grammar, privacy rules, expected uniqueness, and review responsibilities. Add examples from the product rather than abstract placeholders.
A useful pull request checklist asks:
- Can role, label, or stable visible text already identify the target?
- Does the ID describe business purpose rather than style or position?
- Is dynamic data controlled, stable, and non-sensitive?
- Is the value unique in its intended scope?
- Does the test use semantic locators inside the component where possible?
- Does the assertion prove a user-visible outcome?
- Will the tested production-like build preserve the attribute?
Some teams add a small helper type or constant builder for dynamic names. Keep it transparent and deterministic. Do not generate opaque hashes, because they make traces and reviews harder to interpret.
Unused attributes should be removable, but coordinate deletion with the test suite. Conversely, tests should not assume every ID is permanent if no test consumes it. A searchable naming convention and ownership in code review are usually enough governance without a large registry.
The strongest standard treats a test hook as a narrow interface. It receives the same care as an API field, but only where an explicit hook produces real stability.
Interview Questions and Answers
Q: When should an SDET use getByTestId?
Use it when roles, labels, and stable visible content cannot uniquely or reliably identify the target, or when a component needs explicit business identity. It should be a deliberate contract rather than the automatic locator for every element. I still prefer semantic locators for controls inside the boundary.
Q: What attribute does getByTestId use by default?
It uses data-testid. A project can configure another attribute, such as data-qa, with use.testIdAttribute in playwright.config.ts.
Q: Does getByTestId have Playwright auto-waiting?
Yes. It returns a normal Locator, so actions resolve it lazily and apply relevant actionability checks. Web-first assertions on that locator retry their conditions. The test still needs an outcome assertion.
Q: How would you name dynamic row IDs?
I use a controlled, stable, non-sensitive business key, often prefixed by component purpose. I avoid array indexes, random database IDs, email addresses, and values that change between runs. If stable visible content is enough, I may filter the row instead of adding an ID.
Q: Why not put a test ID on every element?
It duplicates semantics already available through roles and labels, increases maintenance, and allows accessibility regressions to go unnoticed. Selective IDs provide component identity while semantic descendants keep tests aligned with user interaction.
Q: How do you diagnose duplicate test IDs?
I inspect the live DOM and use locator count temporarily to find the scope of duplication. Then I fix unintended duplicate markup or scope intentionally repeated components through a parent. I do not choose the first match unless order is the requirement.
Q: Should test IDs be removed from production builds?
There is no universal requirement to remove non-sensitive data attributes. If a build strips them, the end-to-end environment may no longer test the same artifact that ships. I make the decision explicit with application and security owners and never place secrets or personal data in an ID.
Common Mistakes
- Adding
data-testidto every control even when role or label is clear. - Naming IDs after color, position, DOM tag, or framework component.
- Embedding database IDs, emails, or sensitive values in client-visible attributes.
- Generating IDs from array indexes that change when data is sorted or inserted.
- Mixing
data-testid,data-test, anddata-qawithout configuration policy. - Using a broad regular expression for an action that resolves to several elements.
- Verifying only another implementation hook after a click.
- Expecting an ID to bypass virtualization, iframe boundaries, or actionability.
- Stripping attributes from the deployed build without testing that transformation.
- Increasing timeouts when the real problem is missing or duplicated identity.
Correct these issues through a small contract: one attribute, durable business names, meaningful scope, semantic interaction, and visible assertions. Test IDs are easiest to maintain when both product and automation contributors know why each one exists.
Conclusion
Playwright getByTestId examples are strongest when a test ID supplies stable component identity and Playwright's user-facing locators perform the interaction. Configure one attribute, use durable non-sensitive names, avoid position and styling, and assert the result the user sees.
Treat each test ID as a narrow interface between the application and its tests. Add it only where it improves clarity or stability, review it alongside component changes, and remove brittle CSS or XPath incrementally. That approach produces reliable selectors without disconnecting the suite from accessible behavior.
Interview Questions and Answers
When would you choose getByTestId over getByRole?
I choose it when accessible semantics and stable copy cannot express unique identity, or when a repeated component needs a controlled business key. I still use roles and labels inside that component when available. The decision should be explicit in code review.
How do you configure a custom test ID attribute?
I set `use.testIdAttribute` in the Playwright project configuration, for example to `data-qa`. Central configuration keeps every worker and test consistent. I avoid changing selector behavior during individual scenarios.
What makes a strong test ID name?
It describes stable business purpose in a predictable format, such as `billing-card-number` or `checkout-submit`. It avoids style, DOM position, implementation libraries, generated indexes, and private data. A reviewer should understand the target from the name.
Does getByTestId support auto-waiting?
Yes. It returns a Locator, so actions resolve the target lazily and apply relevant actionability checks. Assertions such as `toBeVisible()` and `toHaveText()` retry, but the test must still verify the workflow outcome.
How do you handle repeated dynamic components?
I prefer a controlled stable fixture key on the component or filter a semantic container by stable visible content. I never rely on array indexes for business identity. For virtualized content, I first use the product's search or scroll behavior to render the target.
What are the risks of using test IDs everywhere?
The suite can become detached from user semantics and continue passing through accessibility regressions. It also adds unnecessary markup contracts and maintenance. Selective component hooks combined with semantic inner locators provide a better balance.
How would you migrate a brittle CSS selector?
I identify the selector's real contract before replacing it. If the action has an accessible name, I use a role or label; if a stable component identity is missing, I add a reviewed test ID. I add a visible outcome assertion and run the focused test across browser projects.
Frequently Asked Questions
What does Playwright getByTestId select?
By default, it selects an element whose `data-testid` value matches the supplied string or regular expression. The configured attribute can be changed at the project level.
How do I change data-testid to data-qa in Playwright?
Set `use.testIdAttribute` to `data-qa` in `playwright.config.ts`. Tests can continue calling `getByTestId()` while application markup uses the chosen attribute.
Are test IDs better than role locators?
Not universally. Role locators are preferable for controls with stable accessible semantics, while test IDs are valuable for explicit identity when semantics or copy are insufficient.
Can getByTestId use a regular expression?
Yes, the method accepts a string or regular expression. Use a regex for collection checks or controlled naming patterns, but ensure actions still resolve to exactly one target.
How should I name data-testid values?
Use stable lowercase business names such as `checkout-submit` or `invoice-total`. Avoid colors, positions, tags, generated indexes, framework names, and sensitive data.
Does getByTestId work inside an iframe?
Yes, after selecting the frame context with `frameLocator()` or obtaining the Frame. A page-level locator does not automatically cross iframe boundaries.
Should data-testid attributes be removed in production?
That is an application policy decision, not a Playwright requirement. Never include sensitive data, and make sure end-to-end tests exercise a production-like artifact if attributes are stripped.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright getByAltText: Examples and Best Practices
- Playwright getByTitle: 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