Early access The free plan is live — unlimited tests, no card, yours to keep Get started →
Resources

Choosing locators that survive UI changes

Most end-to-end maintenance is locator maintenance. This is the order to choose them in, and the reasoning behind it.

9 resources5 from official documentationThe craft

A browser test is two things: a sequence of intentions, and a set of answers to the question “which element did you mean?”. The intentions rarely change. The answers break constantly, because they are usually expressed in terms of things the application is free to change without telling anyone — a class name, a wrapper div, the position of a button inside a toolbar.

The fix is not a cleverer selector. It is choosing to identify elements by the things a user relies on, because those are the things a team cannot change silently. If the accessible name of a button changes, that is a product change somebody signed off. If its CSS class changes, that is Tuesday.

MOST RESILIENTgetByRole('button', { name: 'Sign in' })Role and accessible name. Survives a restyle, a renamed class, a rewritten component.getByLabel('Password')A form control found through the label a person reads. Breaks only when the label changes.getByText('Order placed')Copy on the page. Good for assertions, fragile for actions, and it moves when the wording does.getByTestId('checkout-submit')An attribute that exists only for tests. Stable by contract, invisible to users, needs the developers to agree.locator('.btn-primary > span:nth-child(2)')Styling and structure. Both change for reasons that have nothing to do with behaviour.locator('//div[3]/section/button[1]')A position in the tree. Changes when anything above it changes, and says nothing about intent.MOST BRITTLE
The order Playwright recommends, and the order almost every mature suite arrives at independently. Higher is more resilient.

The top two entries are not merely “nicer”. They are the only two that describe the element the way a person perceives it, which is why they survive refactoring: a rewritten component that no longer exposes a button called “Sign in” has broken the product, not the test. That is exactly what you want a failure to mean.

// Prefer the user-visible identity of the control.
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByLabel('Password').fill(secret);

// Scope before you index. Two 'Remove' buttons on a page are ambiguous;
// two 'Remove' buttons inside two different rows are not.
const row = page.getByRole('row', { name: 'Espresso machine' });
await row.getByRole('button', { name: 'Remove' }).click();

// A test id is a contract with the developers, not a workaround.
await page.getByTestId('checkout-submit').click();
Scoping a locator to a container beats adding an index to a global one.

Reach for

  • Role and accessible name, first, always.
  • The label a person reads, for form controls.
  • A container, then a locator inside it — row.getByRole(…).
  • A data-testid agreed with the developers and treated as API.

Avoid

  • Generated class names and hashed ids (.css-1x7bhk).
  • nth-child, :first and any positional index.
  • XPath that walks the tree from the root.
  • Text that is user-facing copy for anything except an assertion.
Strict mode is a feature, not an obstacle

Playwright refuses to act when a locator matches more than one element, and the instinct is to add .first() to make the error go away. That converts a loud, immediate ambiguity into a test that quietly clicks whichever element happens to come first in the DOM — and will keep passing after it starts clicking the wrong one. Scope the locator instead.

The reference material

4 entries
  1. The canonical page, and the source of the priority order above. Covers every built-in locator, filtering, chaining, and the strict-mode rules that catch ambiguity before it becomes a false pass.

    playwright.dev
  2. CSS, XPath, nth, layout selectors and React or Vue component selectors. Documented thoroughly and prefaced with a plain warning about when not to use them, which is most of the time.

    playwright.dev
  3. Short, and the locator section alone is worth the visit. Also the clearest official statement of the “test user-visible behaviour, not implementation detail” rule that everything else here follows from.

    playwright.dev
  4. One page on why tests should find things the way a user does. Playwright's locator API is explicitly modelled on this, so reading it explains the design rather than just describing it.

    testing-library.com

Why role-based locators work, and when they do not

3 entries

Locating by role only works when the application exposes roles. That is worth knowing before you promise it will.

  1. What roles, states and names each kind of control is supposed to have, with working examples. If getByRole cannot find your combo box, this is the page that tells you what the combo box should have been.

    www.w3.org
  2. The specification for how a browser works out an element's name from labels, aria-label, content and title, in that order of precedence. Dry, and the definitive answer when a name is not what you expected.

    www.w3.org
  3. The lookup table. Every role, what it means, and which HTML element already implies it — which is usually the better answer than adding the role by hand.

    developer.mozilla.org

When the locator breaks anyway

2 entries
  1. What a locator cascade can and cannot do, why a heal is proposed rather than applied silently, and the rule that a healed locator only becomes permanent after it has proven itself across runs. Written about our implementation; the failure modes are general.

    qaright.com
  2. The other half: finding the change that will break a locator before the test runs into it, by comparing the live application against what was recorded.

    qaright.com

What to take away

  • Identify elements the way a user does.Role and accessible name change only when the product changes, which is exactly when you want a test to notice.
  • Scope, do not index..first() silences ambiguity instead of resolving it, and the test keeps passing after it starts clicking the wrong thing.
  • A test id is a contract.It works, and only for as long as the developers know it is load-bearing. Agree it out loud.
Missing something? If there is a resource you send to every new tester on your team, tell us and we will read it. The list stays short on purpose; each entry has to earn its place, and entries that stop earning it get removed rather than left to rot.

Reading is the slow way to learn how a suite behaves. Record one against your own app, free, and watch what the report says.

Get started free See how it works