How to Write Resilient Test Selectors That Don't Break
A resilient test selector targets an element by something a user perceives — its accessible role and name, a visible label, or an explicit test id you control — rather than its position in the DOM or an auto-generated CSS class. The rule is a priority order: prefer the attributes a user perceives, fall back to a test id you own, and never anchor to structure the layout can change. That single order is what keeps a renamed button or a reordered list from failing your whole test suite.
What makes a test selector break?
A selector breaks when the thing it points at changes for a reason unrelated to behavior. The three usual culprits:
- Position selectors. Something like li:nth-child(3) or tr:last-child breaks the moment a row is added, removed, or reordered, even though the feature still works.
- Generated class names. CSS Modules, styled-components, and utility frameworks emit hashed classes like .Button_root__x7f2a that change on nearly every build.
- Deep descendant chains. A chain like div > div > span.label couples the test to markup nesting, so any refactor of the layout snaps it.
None of these describe what the element is to a user. That is the root cause: the selector encodes how the page is built instead of what the control does.
The selector priority order that survives UI changes
Reach for locators in this order, and stop at the first one that works. It mirrors the guidance behind Testing Library and Playwright locators — target the element the way a person would.
- Role plus accessible name. Query by the element's ARIA role and its visible label — for example, the button named “Save changes” or the checkbox named “Remember me.” This is the most stable handle because it is tied to what the user sees and what assistive tech reads, not to markup.
- Label or associated text. For form fields, target the input by its label text. For unique on-screen copy, target the visible text directly.
- Explicit test id. When there is no natural accessible handle — an icon-only control, a chart region, a wrapper element — add a data-testid attribute you own.
- CSS or XPath, last resort. Only when nothing above applies, and even then scope it tightly and avoid position and generated classes.
The payoff: a designer can restyle a button, a developer can move it in the DOM, and the top three selector types keep matching because none of them depend on how the element is styled or nested.
How to add a stable test id the right way
A data-testid attribute is a contract between the UI and the test — treat it like one.
- Name it by intent, not position. Use a name like invite-member-submit, not button-3. The name should read as what the element does so anyone can grep for it.
- Put it on the interactive element itself. Attach the id to the actual button, input, or link — not a parent wrapper — so clicks and assertions land on the right node.
- Keep it out of styling and analytics. Don't reuse class names or tracking attributes as test hooks; those get changed by people who aren't thinking about your tests.
- Strip it in production if you must, but strip it consistently. If a build step removes test ids, make sure your tests run against a build that keeps them, or you'll pass locally and fail in CI.
Selectors to avoid (and what to use instead)
- Avoid a hashed class like .css-1a2b3c — use role plus name, or a test id.
- Avoid a positional list match like ul li:nth-child(2) — use the row's unique text, or a per-row test id built from a stable database id.
- Avoid positional XPath like //div[3]/span — use a labeled query scoped to a container.
- Avoid matching full sentences of body copy that marketing rewrites weekly — use the stable control label or a test id.
One more habit that prevents a whole class of failures: scope every selector to a container. If “Delete” appears in three cards, a page-wide query is ambiguous and will match the wrong one or throw. Find the card first, then find “Delete” within it.
How to make selectors survive design changes automatically
Even a disciplined priority order needs maintenance when the UI is under active development — an accessible name gets reworded, a test id gets renamed in a rush. This is where self-healing end-to-end tests help: when a selector no longer resolves, the runner looks at the surrounding context — nearby text, role, position relative to stable anchors — and re-binds to the element the test meant, then flags the change for review instead of failing the run. It doesn't excuse brittle selectors; it absorbs the small, honest drift that happens between the fix and the test update. Pair a resilient priority order with self-healing and you cut the two biggest sources of red CI: the selector that was fragile to begin with, and the one that was fine until yesterday's redesign. For the fragility that self-healing can't fix, see our guide on fixing flaky end-to-end tests.
Key takeaways
- Select by role and accessible name first, before any CSS or XPath
- Add data-testid for elements with no natural accessible handle
- Never anchor to nth-child, hashed class names, or deep DOM chains
- Scope selectors to a container so duplicates don't go ambiguous
FAQ
What is the best selector to use in end-to-end tests?
Prefer a role-based locator (the element's accessible role plus its visible name), because it matches how a user finds the control and stays stable across markup changes. When no accessible handle exists, fall back to a data-testid attribute you add on purpose. Use raw CSS or XPath only as a last resort, and never anchor to auto-generated class names or DOM position.
Should I use data-testid or CSS class selectors?
Use data-testid. CSS classes exist for styling and change whenever the design changes, and modules or utility frameworks often emit hashed, non-deterministic class names. A data-testid is a contract owned by the test, so it only changes when someone deliberately changes it.
Why do my tests break every time the UI changes?
Almost always because the selectors are tied to structure — nth-child positions, deep descendant chains, or generated class names — rather than to stable, user-facing attributes. Rewriting them in the priority order (role, label, text, test id) removes most of that fragility.
Catch bugs the moment a human sees them
Klavity: right-click bug reports, AI personas that review your product, and self-healing tests.
Get started free