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
getByRolewith an accessible namegetByLabelfor form fieldsgetByPlaceholder, when there's no labelgetByTextfor non-interactive contentgetByTestId, when the UI gives you nothing stable- 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: truestops "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-sitesuggests 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.