How-To7 min read

Playwright locators: how to write ones that survive UI changes

By qtrl Team · Engineering

Open any end-to-end suite that's a couple of years old and search for nth(, xpath=, or a CSS selector with four levels of div > in it. You'll find them. Each one was written in a hurry to get a test green, and each one is waiting for a designer to wrap a button in one more container.

A lot of tests that get labelled flaky are really pointed at the wrong thing. Locators are the part of a test you touch most and think about least, so here's how to pick them so they survive the next redesign.

Locate things the way a user would

Playwright's own best practices guide says it directly: prefer user-facing attributes over CSS and XPath. A user doesn't see .btn-primary.checkout-v2. They see a button labelled "Place order." If your test finds the button the same way, it keeps working through any refactor that doesn't change what the user sees, and it breaks exactly when the user-visible thing changes, which is when you want it to.

In practice that means reaching for the built-in locators in roughly this order:

  • getByRole with an accessible name. The default choice for anything interactive
  • getByLabel for form fields, since it also checks the label is wired up
  • getByText for non-interactive content like headings and messages
  • getByTestId when there's no stable user-facing handle
  • CSS or XPath as a last resort, with a comment saying why
What each locator is tied to, and how long it lastsLOCATORHOW MANY UI CHANGES IT SURVIVESgetByRole + nameSurvives markup and styling changes, checks accessibility for freegetByLabel / getByTextSurvives markup changes, breaks when the copy changesgetByTestIdExplicit contract, stable as long as nobody removes itCSS class / idBreaks on refactors and design system updatesXPath / nth-childBreaks when anything above it movesStart at the top. Drop a rung only when the one above has nothing stable to grab

getByRole does more work than it looks

A role locator is a small accessibility test hiding inside a functional one. getByRole('button', { name: 'Place order' }) only matches if the element is exposed to assistive tech as a button and has that accessible name. A <div onClick> styled to look like a button won't match, and that's a real bug you just found for free.

// Brittle: tied to markup and styling
await page.locator('div.checkout > div:nth-child(3) button.primary').click()

// Stable: tied to what the user sees
await page.getByRole('button', { name: 'Place order' }).click()

// Scoped: same idea, narrowed to one region
const summary = page.getByRole('region', { name: 'Order summary' })
await expect(summary.getByText('Total')).toBeVisible()

The catch is that role locators are only as good as your markup. If the app is full of unlabelled icon buttons and generic divs, you'll hit a wall fast. That's worth raising with the team rather than working around, because the same gaps are failing screen reader users and, in the EU, a growing list of accessibility obligations.

Test IDs are fine. Use them on purpose

There's a camp that treats data-testid as cheating. We don't. Some things genuinely have no stable user-facing handle: a chart canvas, one row among many identical rows, a container that exists only for layout. A test ID is an explicit contract between the app and the test suite, and explicit contracts are good.

The problems start when test IDs become the default for everything. You lose the free accessibility check, and you end up with tests that pass while the button has no label at all. Our rule of thumb: if a user could describe how to find it, use a role or text. If they'd have to say "the third one," add a test ID.

If your app already uses a different attribute, you don't need to rename anything. Set testIdAttribute in the Playwright config and getByTestId will read that one instead.

Filter and chain instead of indexing

nth(), first(), and last() are where suites go to rot. They encode position, and position is the first thing to change when someone sorts a list differently or adds a promo banner. Most of the time there's a better anchor nearby.

// Breaks when the list order changes
await page.getByRole('listitem').nth(2).getByRole('button').click()

// Finds the row by what's in it
await page
  .getByRole('listitem')
  .filter({ hasText: 'Annual plan' })
  .getByRole('button', { name: 'Upgrade' })
  .click()

filter({ has: ... }) works the same way with another locator instead of text, which is handy for rows identified by a badge or an icon. Chaining reads top-down like a description of the page, and when it fails, the error tells you which link in the chain didn't match.

Let strictness work for you

Playwright locators are strict: an action on a locator that matches more than one element throws instead of picking one. People sometimes read that error as Playwright being difficult and reach for first() to make it go away. Don't. The error is telling you the locator is ambiguous, and an ambiguous locator that happens to hit the right element today will hit the wrong one after the next layout change. Tighten it instead: scope to a region, add a name, or filter.

Pair good locators with web-first assertions

A stable locator still flakes if the assertion around it doesn't wait. expect(locator).toBeVisible() and friends retry until the condition holds or the timeout runs out. expect(await locator.isVisible()).toBe(true) checks once, right now, and fails if the page is half a frame behind. The two lines look almost identical in review, which is why the second one survives so long.

The same goes for manual waits. If you're writing waitForTimeout, the locator or the assertion is usually what needs fixing. Our flaky tests guide goes deeper on timing, but locator choice alone clears out a surprising share of it.

Use codegen as a starting point, not an answer

npx playwright codegen and the locator picker in UI mode both suggest locators, and they lean toward roles, text, and test IDs. That makes them useful for learning what's available on a page. But the tool picks the most specific locator it can find at the moment you click, which isn't always the one that best describes intent. Read what it gives you and ask whether a person would describe the element that way.

Where locators stop helping

Good locators protect you against markup changes. They don't protect you against flow changes. If checkout goes from one page to three, or "Place order" becomes "Continue to payment," the test should break, and a human needs to decide whether the new behavior is right. That boundary is also where self-healing stops being a selector problem and turns into a question about what the test was supposed to check.


qtrl runs your test cases in a real browser and reads the page through its accessibility tree, the same roles and names getByRole relies on. There are no stored selectors to maintain for the tests you hand to it. When a flow genuinely changes, the run shows you where it diverged, and you decide whether to update the case.

It sits happily next to an existing Playwright suite. Keep the scripted tests where they earn their keep, and let qtrl cover the journeys that change too often to be worth hand-maintaining. See how it works.

Have more questions about AI testing and QA? Check out our FAQ