Playwright locators that survive redesigns: a practical guide

How to choose Playwright locators that keep working when your UI changes: getByRole, getByLabel, test IDs, filtering, and anti-patterns to avoid.

Most broken end-to-end tests aren't caused by bugs. A test fails because it can't find an element that's still on the page: the button moved into a new container, a class name changed, or a third column appeared in the table.

The fix is to locate elements the way your users do, by what they are and what they say, not by where they sit in the DOM. Here is how to do that in Playwright, in order of preference.

The priority order

  1. getByRole with an accessible name
  2. getByLabel for form fields
  3. getByPlaceholder, when there's no label
  4. getByText for non-interactive content
  5. getByTestId, when the UI gives you nothing stable
  6. CSS or XPath, as a last resort

This order mirrors Playwright's own recommendation, and for good reason: the first four are things a user can see, so they only change when the experience changes, which is exactly when you want the test to notice.

getByRole: the default choice

await page.getByRole('button', { name: 'Place order' }).click();
await page.getByRole('link', { name: 'Pricing' }).click();
await page.getByRole('checkbox', { name: 'I agree to the terms' }).check();
await page.getByRole('heading', { name: 'Order confirmed' }).waitFor();

Roles come from the accessibility tree: button, link, textbox, checkbox, combobox, dialog, row and so on. The name is what a screen reader would announce: the button text, an aria-label, or the associated label.

Two useful options:

  • exact: true stops "Save" from also matching "Save draft".
  • A regular expression for names with dynamic parts: { name: /Invoice \d+/ }.

A bonus: if getByRole can't find your button, a screen reader user probably can't either. Locator problems often turn out to be accessibility bugs.

getByLabel for form fields

await page.getByLabel('Email address').fill('jane@example.com');
await page.getByLabel('Country').selectOption('Canada');

This works with <label for>, wrapping labels, aria-label and aria-labelledby. If a field has no label at all, that's worth fixing in the app: it helps users and tests alike.

getByText for content, not controls

await expect(page.getByText('Your order has shipped')).toBeVisible();

Use getByText to check content. Avoid it for clicking, because text alone doesn't tell you whether you're clicking a button, a link or a paragraph that happens to contain the same words.

Test IDs: a stable contract

When the visible UI has nothing unique, such as an icon-only button or a chart, add a test ID:

<button data-testid="export-csv" aria-label="Export CSV">…</button>
await page.getByTestId('export-csv').click();

Test IDs are a contract between the app and its tests: they don't change when copy or layout changes. Their downside is that they're invisible to users, so a test can keep passing after the button has become unusable. Prefer roles, and use test IDs where roles can't work.

Narrowing down: filter and chain

Real pages repeat things. Ten rows each have a "Delete" button, and three cards each have "Upgrade". Scope the locator to its container instead of using an index:

const row = page.getByRole('row').filter({ hasText: 'jane@example.com' });
await row.getByRole('button', { name: 'Delete' }).click();
const plan = page.locator('article').filter({ has: page.getByRole('heading', { name: 'Growth' }) });
await plan.getByRole('link', { name: 'Start trial' }).click();

Avoid .first(), .last() and .nth(2) unless order really is the thing you're testing. They're the locator equivalent of a fixed wait: correct today, wrong after the next data change.

Anti-patterns to avoid

Anti-pattern Why it breaks Better
div:nth-child(3) > button Any layout change getByRole('button', { name: '…' })
.btn-primary Styling changes, many matches Role and name
//div[2]/span[1] Absolute XPath breaks on any change Label or text
#ember1234, #react-select-5 Generated IDs change per build Label or test ID
.nth(0) on a list Data order changes filter({ hasText })

What about dynamic and generated UIs?

Component libraries often generate class names and IDs, such as css-1x2y3z or mui-42. Ignore them completely. Even custom dropdowns and date pickers usually expose roles (combobox, option, grid, gridcell) and names. When they don't, a test ID on the trigger plus role-based locators inside the popup usually works. Date pickers are special enough that they get their own guide.

Check your locators with Playwright's tools

  • npx playwright codegen https://your-site suggests a locator as you hover and click.
  • The Pick locator button in the Playwright Inspector and VS Code extension does the same for an existing page.
  • In a trace (npx playwright show-trace trace.zip) you can see exactly which element each step matched.

Locators in ReplayQA

ReplayQA follows the same order when it turns a screen recording into a test: role and accessible name first, then label, placeholder, text and test ID, with CSS only as a fallback. Every run records which locator matched, so when one stops matching you can see whether the button was renamed, moved or removed, and ReplayQA proposes the fix for you to approve.