QA How-To
How to Use Playwright getByTestId (2026)
Master Playwright getByTestId with naming rules, custom attributes, scoped locators, assertions, debugging, and maintainable TypeScript test patterns.
19 min read | 2,805 words
TL;DR
Playwright getByTestId returns a retryable Locator for the configured test ID attribute, which is `data-testid` by default. Use it for a deliberate testing contract, keep values semantic and stable, and verify visible behavior after every important action.
Key Takeaways
- Use getByTestId when a stable explicit test contract is better than text or accessible semantics.
- Configure one test ID attribute centrally and use it consistently across the application.
- Name IDs by business meaning, not layout, styling, or list position.
- Scope repeated components before selecting an inner test ID.
- Assert user-visible outcomes even when a test ID triggers the action.
- Keep test IDs stable through visual refactors but update them when the product concept changes.
Playwright getByTestId locates an element by a dedicated test identifier, using data-testid by default. It is valuable when accessible roles, labels, and visible text do not provide a stable unique identity, but it should express a deliberate product testing contract rather than become a shortcut for every selector.
This guide covers setup, custom attributes, naming, component design, dynamic data, assertions, debugging, and migration from brittle selectors. Every example uses a live Playwright Locator, so it benefits from lazy resolution, actionability checks, and retrying assertions.
TL;DR
<button data-testid='checkout-submit'>Place order</button>
import { test, expect } from '@playwright/test';
test('submits checkout', async ({ page }) => {
await page.goto('/checkout');
await page.getByTestId('checkout-submit').click();
await expect(page.getByRole('heading', { name: 'Order confirmed' }))
.toBeVisible();
});
- Default attribute:
data-testid. - Best IDs:
checkout-submit,invoice-total, andprofile-avatar. - Weak IDs:
blue-button,right-column-3, and generated database values. - Best practice: use the test ID to target, then assert what the user can observe.
1. What Playwright getByTestId Does
page.getByTestId('checkout-submit') creates a Locator whose selector is based on the configured test ID attribute. With the default configuration, it matches an element whose data-testid value is exactly checkout-submit. The method is available on page, frame, and locator contexts, which means you can search globally or within a component.
<section data-testid='cart-summary'>
<span data-testid='cart-total'>$84.00</span>
<button data-testid='checkout-submit'>Place order</button>
</section>
const cart = page.getByTestId('cart-summary');
await expect(cart.getByTestId('cart-total')).toHaveText('$84.00');
await cart.getByTestId('checkout-submit').click();
The locator is lazy. Playwright does not capture one DOM node when the variable is declared. When an action or assertion runs, it evaluates the locator against the current page. That makes the same locator useful across a rerender, provided the ID contract remains present.
A test ID is not globally special to the browser. It is an application-owned attribute that Playwright agrees to query. It does not add accessibility semantics, make a control keyboard-operable, or prove that a user can understand it. It only gives automation a stable hook. That distinction is why a test can click by ID yet should usually assert a visible heading, status, dialog, URL, or value as its outcome.
Test IDs are exact contracts. Keep a value unique within the intended scope unless repeated components are intentionally scoped by a parent. Duplicate global IDs do not violate HTML rules in the way duplicate id attributes do, but they can still cause Playwright strictness errors.
2. Add and Run Your First Test ID Test
Install Playwright with its official project initializer, or add @playwright/test to an existing Node project. The generated setup provides a test directory, configuration, and browser projects.
npm init playwright@latest
npx playwright test
Here is a small, runnable flow. The test ID identifies a composite product card whose visible content varies by fixture data. User-facing locators handle the controls inside it.
<article data-testid='product-card-coffee-grinder'>
<h2>Precision Coffee Grinder</h2>
<button>Add to cart</button>
</article>
import { test, expect } from '@playwright/test';
test('adds a product from a stable card contract', async ({ page }) => {
await page.goto('/catalog');
const card = page.getByTestId('product-card-coffee-grinder');
await expect(card.getByRole('heading')).toHaveText('Precision Coffee Grinder');
await card.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByRole('status')).toHaveText('Added to cart');
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
This hybrid style is often stronger than an all-ID test. The card has an explicit identity needed by the scenario, while the nested button is selected as a user would understand it. If the button loses its accessible name, the test fails for a meaningful reason.
Run a single test during development with a visible browser or UI mode:
npx playwright test tests/catalog.spec.ts --headed
npx playwright test tests/catalog.spec.ts --ui
For installation, configuration, fixtures, and CI patterns, consult the complete Playwright testing guide.
3. Configure a Custom Test ID Attribute
Many codebases already use data-test, data-qa, or data-cy. Playwright can treat one of these as its test ID attribute, letting teams use getByTestId without rewriting application markup. Configure the attribute once in playwright.config.ts.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
testIdAttribute: 'data-qa',
},
});
The application markup and test then look like this:
<button data-qa='account-save'>Save account</button>
await page.getByTestId('account-save').click();
You can also set the attribute through the selectors API, but project configuration is clearer for a test suite because every worker and test shares the same rule. Avoid changing it inside individual tests. A locator's meaning should not depend on execution order or hidden global mutations.
Choose one attribute across teams unless separate applications have a strong integration constraint. Supporting data-testid, data-qa, and data-cy simultaneously forces contributors to remember which helper or raw CSS selector applies. A centralized convention also makes lint rules, component helpers, and code review simpler.
Do not configure the native id attribute as a test ID substitute. Native IDs serve document relationships, fragment navigation, labels, and application logic; they may be generated or refactored for reasons unrelated to testing. A dedicated data-* attribute makes ownership explicit.
If production policy removes test attributes at build time, verify the production-like build used by end-to-end tests still contains them. Stripping attributes can reduce HTML slightly, but it also creates a difference between tested and deployed artifacts. Many teams keep non-sensitive test IDs because they contain no secret information and have negligible runtime behavior. Decide through architecture policy, not an assumed security benefit.
4. Create a Durable data-testid Naming Convention
A useful ID describes business purpose. It should survive changes to color, CSS framework, DOM nesting, and screen position. Prefer lowercase kebab case and a small vocabulary that moves from feature or component to element purpose.
| ID | Quality | Why |
|---|---|---|
checkout-submit |
Strong | Identifies a business action |
invoice-total |
Strong | Identifies stable domain information |
profile-avatar-upload |
Strong | Connects feature and operation |
primary-blue-btn |
Weak | Couples to style and vague priority |
left-panel-item-2 |
Weak | Couples to layout and position |
div-48 |
Weak | Contains no product meaning |
user-839274-delete |
Risky | Couples test markup to volatile data |
A practical grammar is <feature>-<component>-<purpose>, shortened when context already supplies part of the meaning. billing-card-number is clear globally. Inside a billing-form component, a scoped card-number may be enough. Avoid encoding implementation types such as react-select, because a framework migration should not change the automation contract.
For repeated domain objects, separate component identity from fixture identity. Give every row data-testid='invoice-row', then filter by a visible invoice number or accessible cell. Another valid pattern is a controlled suffix based on a stable fixture key, such as invoice-row-overdue-acme, when the key is intentionally part of the test data contract. Do not expose personal data, access tokens, secrets, or internal authorization facts in an attribute.
Document the convention beside component contribution guidance. Reviewers should reject test IDs added without a consumer or with layout-based names. A small naming policy prevents the repository from becoming an unsearchable collection of test1, button2, and stale hooks.
5. Use Test IDs with Actions and Assertions
getByTestId returns the same Locator type as getByRole, getByText, and CSS-based locator. It supports actions such as click, fill, check, selectOption, press, and setInputFiles, subject to the control's capabilities. It also works with Playwright's web-first assertions.
test('updates notification preferences', async ({ page }) => {
await page.goto('/settings/notifications');
const form = page.getByTestId('notification-preferences');
const weeklyDigest = form.getByTestId('weekly-digest');
await weeklyDigest.check();
await expect(weeklyDigest).toBeChecked();
await form.getByTestId('preferences-save').click();
await expect(form.getByTestId('save-status')).toHaveText('Preferences saved');
});
The ID does not bypass actionability. A click still waits for a unique, visible, stable, enabled target that can receive pointer events. If a test ID exists on a hidden duplicate, Playwright can report strictness or actionability failures. Fix component rendering or scope the target instead of defaulting to { force: true }.
Prefer assertions that describe behavior: toBeVisible, toHaveText, toHaveValue, toBeChecked, toHaveAttribute, and toHaveCount. They retry until their timeout. A one-time assertion against await locator.textContent() can race an asynchronous update.
A test ID can be the assertion target when the user-visible output lacks a convenient semantic locator, such as a canvas-adjacent calculated value. Still assert the visible content or state, not the continued existence of the attribute. expect(locator).toHaveAttribute('data-testid', 'total') usually proves only that test markup exists.
6. Scope Repeated Components and Dynamic Lists
Repeated test IDs are acceptable when a component instance provides the scope. For example, every cart row can use cart-row, and every row can contain quantity and remove-item. Locate the correct row by user-visible or domain-stable content before acting.
const rows = page.getByTestId('cart-row');
const grinderRow = rows.filter({
has: page.getByRole('heading', { name: 'Precision Coffee Grinder' }),
});
await grinderRow.getByTestId('quantity').fill('2');
await grinderRow.getByTestId('remove-item').click();
await expect(grinderRow).toHaveCount(0);
This structure makes the repeated nature explicit. It also avoids creating a unique test ID for every production record. When the visible identifier can change during localization, filter by another stable nested ID whose text comes from a controlled fixture.
Use filter({ hasText }) thoughtfully. It performs substring matching within each candidate and can become ambiguous when one item contains another item's text. A nested locator with an exact accessible name is often safer.
const acmeInvoice = page.getByTestId('invoice-row').filter({
has: page.getByRole('cell', { name: 'INV-1048', exact: true }),
});
await acmeInvoice.getByTestId('invoice-menu').click();
Avoid nth() unless order is the requirement. Selecting getByTestId('cart-row').nth(2) makes the test depend on sorting, inserted fixtures, and filtering. If the scenario is specifically about the third result, assert the ordering first so the positional action is justified.
For virtualized lists, only rendered rows exist in the DOM. Scroll through the component or search using the product workflow before locating the row. A test ID does not make an unmounted item discoverable.
7. Playwright getByTestId vs User-Facing Locators
Test IDs optimize stability and control, while role, label, and text locators optimize resemblance to user interaction. Mature suites use both deliberately.
| Question | Prefer | Example |
|---|---|---|
| Can a user identify the control by role and name? | getByRole |
Submit, menus, tabs, links |
| Is it a labeled form control? | getByLabel |
Email, date, address fields |
| Is visible copy the behavior under test? | getByText |
Success and validation messages |
| Is the node nonsemantic or copy deliberately unstable? | getByTestId |
Chart point layer, composite shell |
| Is DOM structure itself the contract? | Short CSS locator | Rare structural integration checks |
The Playwright getByRole tutorial shows how names and roles produce resilient user-centered locators. A test ID is stronger when translation, A/B copy, icon-only visual surfaces, or complex composite widgets make text identity unstable. It is also useful for component boundaries such as search-results, followed by role locators inside the boundary.
Do not measure quality by the percentage of tests using one locator. A suite made entirely of test IDs may remain mechanically stable while missing broken labels and roles. A suite forced to use roles for every implementation detail can become awkward and misleading. Evaluate whether each locator expresses the stable behavior relevant to that step.
Test IDs should not replace assertions. Clicking checkout-submit and then checking order-confirmation by ID is fine if you assert the confirmation's visible content. Merely verifying both elements exist says little about whether the order data, status, and navigation are correct.
8. Build Testable React Components Without Leaking Details
In React, pass a test ID only where a consumer needs a stable boundary or identity. Preserve the standard data-testid attribute instead of inventing a component-only prop that never reaches the DOM.
type SaveButtonProps = {
pending: boolean;
onSave: () => void;
'data-testid'?: string;
};
export function SaveButton({
pending,
onSave,
'data-testid': testId = 'profile-save',
}: SaveButtonProps) {
return (
<button
type='button'
data-testid={testId}
disabled={pending}
onClick={onSave}
>
{pending ? 'Saving...' : 'Save profile'}
</button>
);
}
The component still uses a native button, a visible name, and a disabled state. Its test ID is an additional contract, not compensation for inaccessible markup. The calling screen can accept the default or supply a meaningful context-specific value when multiple instances appear.
Do not attach the same ID to a wrapper and its inner input. One value should identify one target in a scope. If a composite needs separate hooks, name them date-range, date-range-start, date-range-end, and date-range-apply, or scope shorter child values under the root.
Create a small helper only when it preserves Playwright behavior and adds domain meaning. A helper that constructs arbitrary CSS strings around test IDs hides standard APIs and complicates debugging. A component object is more useful:
class AddressCard {
constructor(private readonly root: Locator) {}
async remove() {
await this.root.getByTestId('remove-address').click();
}
}
const card = new AddressCard(
page.getByTestId('address-card').filter({ hasText: '18 River Road' }),
);
await card.remove();
This object receives a scoped locator, remains lazy, and exposes a business operation without rebuilding selector logic.
9. Debug Missing, Duplicate, and Unstable Test IDs
A zero-match failure usually means the attribute is missing from the rendered build, the configured attribute name differs, the component has not mounted, or the target lives in another frame. A strictness failure means multiple matches exist in the action scope. An actionability timeout means the match is present but hidden, covered, moving, disabled, or unable to receive events.
Use Playwright UI mode, the inspector, and traces before changing the selector.
npx playwright test tests/checkout.spec.ts --ui
PWDEBUG=1 npx playwright test tests/checkout.spec.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
testIdAttribute: 'data-testid',
trace: 'retain-on-failure',
},
});
Temporarily inspect the count and DOM attributes in a debug session. Turn durable expectations into web-first assertions.
const submit = page.getByTestId('checkout-submit');
console.log('matches:', await submit.count());
await expect(submit).toHaveCount(1);
await submit.highlight();
Check that a React wrapper forwards the attribute to a DOM element. Custom components can silently discard unknown props if they destructure a limited prop list. Inspect the rendered DOM, not only JSX source. Confirm the test is not looking in the top page when the application is inside an iframe. Enter it through frameLocator before calling getByTestId.
If the selector is intermittently absent, find the state transition responsible. Wait on an observable UI or network outcome, not a fixed delay. page.waitForTimeout(2000) only shifts the race and makes the suite slower. A correct locator plus expect(locator).toBeVisible() communicates the condition directly.
10. Migrate Brittle Selectors to a Test ID Contract
Start with selectors that fail frequently or conceal intent. Record what the scenario actually needs to identify, then decide whether a user-facing locator already expresses it. Add a test ID only when it provides a clearer stable boundary.
// Before: layout and generated classes control the test.
await page.locator('.checkout-grid > div:nth-child(2) button.btn-primary').click();
// After: a product-owned action contract controls the test.
await page.getByTestId('checkout-submit').click();
Migration is a product-code change and a test-code change. Review both together. The application commit should explain why the contract is needed, and the test should prove a business outcome. Search for old selectors after migration so unused classes or helper abstractions do not linger.
Do not mechanically replace every CSS selector with a test ID. Convert a button to getByRole when its accessible name is stable, a field to getByLabel, and a message to getByText or a named status role. Reserve IDs for cases where they add clarity or stability. The locator strategy guide for UI automation can help a team standardize that decision.
Track IDs as an API. When a visual refactor moves the checkout action, keep checkout-submit. When checkout is redesigned into a review workflow with a different business action, update the ID and tests because the contract changed. Stability means resistance to incidental refactoring, not permanent preservation of obsolete concepts.
A code review checklist can ask: Is the ID necessary? Is its name semantic? Is its scope unique? Does it expose sensitive data? Does the test assert a user-observable result? These questions keep an initially clean strategy from degrading over time.
Interview Questions and Answers
Q: When should you use getByTestId instead of getByRole?
Use it when user-facing semantics cannot uniquely or stably identify the target, or when the team intentionally defines an automation contract for a component boundary. Continue using native semantics inside that boundary when possible. Test IDs should complement accessible markup, not replace it.
Q: How do you change the attribute queried by getByTestId?
Set use.testIdAttribute in playwright.config.ts, for example data-qa. Then getByTestId('save') queries data-qa='save'. Keep the setting consistent across the project.
Q: Are duplicate data-testid values allowed?
They can exist in HTML, especially in repeated components, but a single-element Playwright action is strict. Scope the repeated locator to a unique row, card, region, or dialog before selecting the child. Avoid arbitrary indexes unless order is part of the requirement.
Q: Why should a test assert visible behavior after clicking by test ID?
The ID only proves that automation found a hook. It does not prove the user saw the correct result, that navigation completed, or that data was saved. A user-visible assertion validates the business outcome.
Q: Should test IDs be removed from production builds?
Not automatically. They usually contain no sensitive information and have little behavioral cost, while removing them creates a difference between tested and deployed markup. Follow an explicit architecture policy and never put secrets or personal information in the values.
Q: How do you name test IDs for dynamic lists?
Prefer a repeated component ID such as invoice-row, then filter by controlled visible content or a stable nested key. If a suffix is necessary, use a stable non-sensitive fixture or domain identifier, not an array index or volatile database value.
Common Mistakes
- Adding a test ID to every element without first considering role, label, or text.
- Naming hooks after color, CSS classes, framework components, or page position.
- Embedding secrets, emails, or other sensitive values in attributes.
- Reusing one test ID for several descendants within the same component scope.
- Using
nth()to silence strictness failures caused by an under-specified target. - Asserting only that the test ID exists instead of validating user-visible behavior.
- Forgetting to configure
testIdAttributewhen the application usesdata-qaordata-cy. - Assuming a JSX prop reached the DOM without inspecting wrapper component behavior.
- Waiting with fixed sleeps when the locator or expected state can be awaited directly.
- Renaming stable IDs during cosmetic refactoring and creating needless test churn.
Conclusion
Playwright getByTestId is a precise tool for creating an explicit, durable automation contract. Configure one attribute, name values by business purpose, scope repeated components, and keep locators lazy by passing Locator objects rather than captured elements.
Use test IDs where they add stable identity, then prove outcomes through visible content, accessible state, URLs, and domain data. Review one brittle selector at a time and replace it with the best contract, whether that is a role, label, text, or thoughtfully designed test ID.
Interview Questions and Answers
What is the main advantage of getByTestId?
It creates an explicit selector contract that can remain stable across copy, layout, and styling changes. Because it returns a Locator, it retains Playwright's lazy resolution and waiting behavior. Its tradeoff is that it does not test how users or assistive technology identify the element.
How would you define a good data-testid naming standard?
I use lowercase kebab case based on feature, component, and purpose, such as `checkout-submit`. I exclude styling, position, framework names, secrets, and volatile database values. The name should survive an incidental visual refactor.
How do you configure Playwright for an existing data-qa convention?
I set `use.testIdAttribute` to `data-qa` in `playwright.config.ts`. All tests can then use the standard `getByTestId` method. I avoid per-test changes because locator meaning should be consistent.
How do you locate one item when several rows share the same test ID?
I locate all row components by the shared ID, filter to the row containing a stable visible or semantic identifier, and query the child within that row. This preserves component reuse and avoids positional selectors.
What would you assert after an action found by test ID?
I assert the business result a user can observe, such as a named dialog, confirmation message, updated total, URL, or control state. I do not stop at checking that the test hook exists. The target and the outcome can use different locator strategies.
How would you debug an intermittent getByTestId failure?
I classify it as missing, duplicate, or non-actionable, then inspect a trace or UI mode. I verify the configured attribute, rendered DOM, frame context, component prop forwarding, and state transition. I wait for an observable condition instead of adding a fixed timeout.
Frequently Asked Questions
What attribute does Playwright getByTestId use?
It uses `data-testid` by default. You can change the project-wide attribute through `use.testIdAttribute` in `playwright.config.ts`.
Does getByTestId support auto-waiting?
It returns a Playwright Locator, so actions use relevant auto-waiting and actionability checks. Web-first assertions on the locator also retry until their timeout.
Can I use data-cy with getByTestId?
Yes. Set `testIdAttribute: 'data-cy'` in the Playwright configuration, then continue calling `getByTestId` with the attribute value.
Should every element have a data-testid?
No. Prefer roles, labels, and text when they accurately express user-facing identity. Add a test ID when it provides a useful stable contract that those locators cannot.
How do I handle duplicate test IDs in Playwright?
Scope the locator to a meaningful unique parent such as a row, card, form, or dialog. Then locate the repeated test ID within that parent instead of relying on `first()` or `nth()`.
Can getByTestId use a regular expression?
The Playwright API accepts a string or regular expression as the test ID value. Prefer exact, stable strings for ordinary contracts and use a regular expression only for a controlled family of identifiers.
Are data-testid attributes a security risk?
The attribute itself is not a security boundary. Keep values free of secrets, personal data, internal authorization facts, and unpredictable identifiers, just as you would for any client-rendered markup.