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.
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.
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();
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-testidagreed with the developers and treated as API.
Avoid
- Generated class names and hashed ids (
.css-1x7bhk). nth-child,:firstand any positional index.- XPath that walks the tree from the root.
- Text that is user-facing copy for anything except an assertion.
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-
Playwright: Locators
Official docsThe 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 -
Playwright: other locators
Official docsCSS, XPath,
playwright.devnth, 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: best practices
Official docsShort, 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 -
Testing Library: guiding principles
Official docsOne 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 entriesLocating by role only works when the application exposes roles. That is worth knowing before you promise it will.
-
ARIA Authoring Practices Guide
StandardWhat roles, states and names each kind of control is supposed to have, with working examples. If
www.w3.orggetByRolecannot find your combo box, this is the page that tells you what the combo box should have been. -
The specification for how a browser works out an element's name from labels,
www.w3.orgaria-label, content and title, in that order of precedence. Dry, and the definitive answer when a name is not what you expected. -
MDN: ARIA roles reference
Official docsThe 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-
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 -
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.
Reading is the slow way to learn how a suite behaves. Record one against your own app, free, and watch what the report says.