Smoketest

getByRole vs data-testid: A Playwright Selector Strategy That Survives Redesigns

Your Playwright suite passed until a redesign moved a wrapper, changed a class, or renamed a button. Use role and label locators for user-facing contracts, reserve test IDs for elements without a dependable accessible identity, and migrate CSS selectors as you touch them.

10 min read

The redesign shipped, the product still works, and half the Playwright suite is red. A wrapper moved, generated classes changed, and nth-child(3) now points at the wrong row. You searched for getByRole vs data-testid because both look more stable than the CSS selectors you have now, but they protect different contracts.

Use getByRole first, then getByLabel or getByText, then getByTestId. Role locators test what users and assistive technology perceive, so they usually survive DOM refactors and expose accessibility mistakes. Test IDs are right when no dependable accessible role, label, or text exists. Avoid CSS and XPath for user tests.

This order applies to Playwright 1.61, as of mid-2026. It is a preference, not a rule that every element must satisfy.

Start with the contract your selector tests

A selector is an agreement between a test and the application. The important question is not which syntax is shortest. It is which change should make the test fail.

getByRole('button', { name: 'Save changes' }) says a user can find an enabled concept called “Save changes” and interact with it as a button. If a developer replaces a native button with an element that has no button role, the failure is useful. If they move the button into a different wrapper, the test keeps working.

getByTestId('save-changes') says the page contains an element carrying a private identifier. That identifier can survive copy and layout changes, but users cannot perceive it. The test passes even if the visible label becomes confusing or the element loses its intended role.

A CSS path such as #root > div:nth-child(2) button says the DOM has one exact shape. That shape is rarely a product requirement. Playwright's best-practices guide recommends user-facing attributes over CSS or XPath because DOM structure changes easily. The locator guide likewise recommends role locators as the closest match to how users and assistive technology perceive a page.

This gives a practical preference order:

  1. getByRole, with an accessible name when needed.
  2. getByLabel for form controls, then getByText for visible non-control content.
  3. getByTestId for elements without a dependable user-facing identity.
  4. CSS or XPath only for exceptional low-level work, not normal user tests.

Testing Library reaches a similar conclusion in its query priority: role first, labels for form fields, visible text where appropriate, and test IDs when role or text does not make sense.

Why getByRole is the strongest default

Role locators survive many refactors because they target the accessibility tree rather than the nesting of div elements. They also make failures easier to interpret. A locator named “Save changes” communicates intent in a way .css-1q2w3e cannot.

Here is the same test before and after replacing implementation-coupled selectors.

tests/settings.spec.ts

TypeScript
// Before
await page.locator('div.sidebar > ul > li:nth-child(3) a.nav-link').click();
await page.locator('#root > .MuiBox-root .css-1q2w3e button').click();

// After
await page.getByRole('navigation').getByRole('link', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Save changes' }).click();

The second version permits a redesign to change wrappers, class names, and list position. It still fails if navigation no longer exposes a Settings link or the save control stops behaving like a button. That is a better failure boundary.

This also acts as a limited accessibility check. It confirms the role and accessible name Playwright can query. It does not prove the whole interaction is accessible. A hand-built div with role="button" can match the same role locator as a native button, while still having keyboard or behavior defects. Use dedicated checks such as toHaveRole(), toHaveAccessibleName(), and ARIA snapshots where that distinction matters. Our guide to Playwright accessibility testing covers that layer.

Role names have one common trap. In Playwright 1.61, string names use case-insensitive substring matching by default. “Log” can therefore match “Log out.” exact: true switches to a case-sensitive whole-string match after trimming whitespace. Regular expressions ignore exact.

tests/auth.spec.ts

TypeScript
page.getByRole('button', { name: 'Log' });
page.getByRole('button', { name: 'Log', exact: true });
page.getByRole('button', { name: /^log in$/i });

Use exact matching or an anchored expression when nearby controls share words. Do not reach for first(), last(), or nth() to silence an ambiguous locator. Playwright locators are strict, so an action throws when multiple elements match. Position-based escape hatches can turn a clear failure into a click on the wrong element.

Scope the user-facing locator before adding an ID

Repeated controls are where teams often add test IDs too early. A table may contain ten Delete buttons, and each button correctly has the same accessible name. The missing information is the row, not the identity of the button.

Compose locators around user-visible context instead:

tests/members.spec.ts

TypeScript
await page.getByRole('row').filter({ hasText: '[email protected]' })
  .getByRole('button', { name: 'Delete' }).click();

This reads like the action a person takes: find the member's row, then delete that member. Playwright supports chaining plus filters such as hasText, hasNotText, has, hasNot, and visible, as documented in the locator filtering guide.

Use getByLabel for inputs whose label is the durable user contract. Use getByText when the content itself is what the user needs to see. A placeholder ranks below a label because, as Testing Library notes, a placeholder is not a substitute for a label.

Copy is still a dependency. If product wording changes from “Save changes” to “Update profile,” a role-and-name test will fail. Sometimes that is correct because the tested user instruction or assertion depends on those words. If copy changes frequently and is irrelevant to the behavior under test, an ID may express the intended contract better.

Data-testid best practice is to use it deliberately

Playwright's own position is more nuanced than “never use test IDs.” Its locator documentation calls test IDs the most resilient testing method when text or roles change, while warning that they are not user-facing. The docs recommend them when your team chooses a test-ID methodology or cannot locate an element by role or text.

Good cases include:

  • A canvas control with no useful accessible node.
  • A loading skeleton or transient state with no role or stable text.
  • Copy that changes across locales or experiments while the tested behavior stays constant.
  • A third-party widget whose accessibility markup you cannot repair.
  • Dynamic content where every user-facing value is expected to change.

Treat each test ID as a maintained API. Name the behavior or concept, not the current position or styling. billing-plan-card says more than blue-card-2. Do not add an ID merely because a role locator is ambiguous until you try scoping by a row, dialog, list item, or other visible context.

Playwright uses data-testid by default. If an existing suite already uses data-qa, data-cy, or another attribute, configure it once rather than editing all markup.

playwright.config.ts

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

export default defineConfig({
  use: {
    testIdAttribute: 'data-pw',
  },
});

Then query the configured attribute through getByTestId. This keeps the selector contract visible in test code and avoids spreading raw attribute selectors through the suite.

A Playwright selector strategy for an existing CSS suite

Do not rewrite every test in one pull request. Selector migrations are safer as a strangler process: set the rule for new tests, then convert old selectors when a file changes or a selector fails.

Start by measuring the obvious debt. Search for page.locator() calls containing class chains, IDs tied to framework roots, nth-child, and XPath. Group failures by shared page object or component so one conversion fixes several tests.

Next, use Playwright's current tooling to propose better targets. Codegen prioritizes role, text, and test-ID locators according to the best-practices guide. Playwright 1.59 also added page.pickLocator() for interactive picking and locator.normalize() for converting an implementation-detail locator into a locator that prioritizes test IDs, ARIA roles, and other user-facing attributes.

tests/migration.spec.ts

TypeScript
const better = await page.locator('.btn-primary').normalize();
await better.click();

Treat the result as a candidate, then review what contract it captures. Normalization cannot decide whether changing visible copy should fail your test. That remains an engineering choice.

Use this sequence for each touched selector:

  1. Identify the user action or observable outcome the test means to cover.
  2. Try a role plus accessible name.
  3. Scope it with a user-visible container or filter() if several elements match.
  4. Try a label or visible text where those are the actual contract.
  5. Add or reuse a named test ID if no dependable user-facing locator exists.
  6. Remove the old CSS selector and run the affected test through the redesign state that caused the failure.

Keep web-first assertions during the migration. Playwright recommends assertions such as await expect(page.getByText('welcome')).toBeVisible() because they retry until the expected condition is met, rather than checking visibility once.

AI agents solve the same targeting problem

An AI browser agent still needs to turn “open Settings” into a concrete element. The useful input is not a screenshot coordinate or a long CSS path. It is the page's semantic structure: navigation, links, buttons, labels, names, and states.

In Playwright 1.59 and later for JavaScript, ariaSnapshot({ mode: 'ai' }) returns an AI-oriented accessibility snapshot with element references. Playwright's source and tests use an aria-ref= selector engine to act on those references, although that engine is not documented as a public locator API. The same source-verified tests show a reference being passed to normalize() to obtain a durable locator.

tests/agent-targeting.spec.ts

TypeScript
const snapshot = await page.locator('body').ariaSnapshot({ mode: 'ai' });
await page.locator('aria-ref=e3').click();
const durable = await page.locator('aria-ref=e3').normalize();

The reference is useful during one observed page state. The normalized locator is the candidate to review before committing. Our deeper explanation of AI agents, the accessibility tree, and aria-ref covers re-snapshotting and fallback targeting.

This is why a page that is easy to test with getByRole is usually easier for an agent to operate. Both depend on meaningful roles, names, labels, and states. Selector quality and agent targeting meet at the accessibility tree.

The rule to keep in code review

Ask one question for every locator: what product change should make this test fail?

If the answer is “the user can no longer find or operate this control,” use a role, label, or visible text. If the answer is “this internal concept disappeared, regardless of copy or accessible identity,” a test ID can be correct. If the answer is “a wrapper or generated class changed,” the selector is testing structure the user never agreed to.

That is the full Playwright selector strategy. User contract first, explicit private contract when necessary, DOM shape never.

FAQ

Is getByRole better than data-testid in Playwright?

getByRole is the better default when an element has a dependable role and accessible name. It targets the interface users and assistive technology perceive, and it survives many DOM refactors. getByTestId is better when copy changes independently of behavior or no useful role, label, or visible text exists.

What are the Playwright best selectors?

Prefer getByRole, followed by getByLabel for form fields and getByText for visible content. Use getByTestId when those user-facing locators do not express the intended contract. Avoid CSS and XPath for user tests because DOM structure and generated classes can change without changing product behavior.

What is a good data-testid best practice?

Treat a test ID as a private API maintained by the application and tests. Give it a semantic name such as billing-plan-card, not a style or position such as blue-card-2. Add one only after role, label, visible text, and user-visible scoping fail to identify the intended element reliably.

How do I migrate CSS selectors to Playwright locators?

Convert selectors as tests change instead of rewriting the suite at once. Try role and accessible name first, scope repeated controls with filter(), then use labels, text, or a test ID. In Playwright 1.59 and later, locator.normalize() can propose a best-practice locator from an existing CSS locator for review.

Does getByRole guarantee accessibility?

No. A matching role and accessible name confirm useful parts of the accessibility tree, but they do not prove keyboard behavior, focus handling, or correct native semantics. Pair role-based tests with assertions such as toHaveRole() and toHaveAccessibleName(), plus ARIA snapshots and focused accessibility checks for critical tests.

Share this post

The QA column is no longer where the sprint goes to die.

Move one ticket. Watch it come back tested. Then decide.

Keep reading

All posts →