automationplaywrightseleniumui-testsqa

Locators That Survive a Redesign: A Selector Strategy for UI Automation

A familiar morning: the CI is red, you open the report expecting an honest bug, and instead you get TimeoutError: waiting for selector "div.css-1x7f9a2 > div:nth-child(3) > button". Nothing broke. A designer just reshuffled the card layout, the class hash regenerated, and the button moved one node sideways. The test “failed,” but the product works. You fix the locator, commit, and two weeks later it happens again.

That’s not a one-off — it’s the single most common way UI automation turns from an asset into a burden. A brittle locator breaks on any layout refactor, piles up false failures, the team stops trusting the run, and starts ignoring the red. The good news: a test’s resilience is almost entirely decided by how you find the element. And that’s within your control.

A locator is a contract, not a detail

When you write page.getByRole('button', { name: 'Pay' }), you’re pinning down intent: “there’s a button the user sees as ‘Pay’.” When you write div:nth-child(3) > button, you’re pinning something else entirely: “the third div from the top, and a button inside it.” The first is about product behavior — it changes rarely and meaningfully. The second is about the current DOM structure, which changes every sprint for reasons the user never sees.

A locator is the coupling point between your test and the app. The closer it sits to what the user sees and does, the less often it breaks on changes the user didn’t notice. Everything below follows from that.

Selector priority: top to bottom

This is a “take the highest one that fits, drop down only if you can’t” hierarchy. The order isn’t arbitrary — it runs from “most about the user” to “most about the implementation.”

  • 1. Role + accessible name. getByRole('button', { name: 'Pay' }), getByLabel('Email'). This is how the user (and a screen reader) perceive the element. It breaks only when the element’s meaning actually changes. Bonus: you’re testing accessibility for free — if a button has no accessible name, the test won’t find it, and that’s the right signal.
  • 2. data-testid. An explicit hook for tests: getByTestId('checkout-submit'). Independent of layout, text, and language. The trade-off: it needs an agreement with developers (see below), but it’s the most stable option when role/text don’t apply.
  • 3. Visible text / placeholder. getByText('Your cart is empty'), getByPlaceholder('Search'). Close to the user, but breaks on copy changes and localization (see the i18n note).
  • 4. A meaningful CSS attribute. input[name="email"], [aria-label="Close"]. Bound to a stable attribute rather than a position. Tolerable.
  • 5. CSS by class/structure and positional XPath. div.card > button, //div[2]/span[3]. Last resort. Brittle by definition — tied to the implementation, which changes most often.

The rule is simple: when the same element offers a choice, take the one higher on the list. Only drop down when the upper options are genuinely unavailable.

The two big traps at the bottom

Positional XPath is a time bomb. //div[2]/div/ul/li[3]/button works right up until the first added wrapper, a new banner on top, or a changed node order. It doesn’t express what you’re looking for — only where it happened to sit today. Such a locator won’t survive even a minor refactor. If XPath is unavoidable, bind to an attribute or text (//button[@data-testid="submit"], //*[text()="Pay"]), not to node indices.

Auto-generated classes are fake stability. css-1x7f9a2, sc-bdVaJa, jss42 from CSS-in-JS (styled-components, Emotion, MUI) and CSS Modules look like a solid CSS selector, but the hash changes on every style rebuild. Green today, red after a harmless change in a neighboring component. Never latch onto these.

data-testid: strike a deal with the developers

The most common objection: “I don’t want to litter production with test attributes.” The arguments that usually settle it:

  • data-* is valid HTML and the standard mechanism for custom data; it doesn’t affect styles, behavior, or SEO.
  • A few bytes per element in gzip is statistical noise next to the weight of the actual markup.
  • If you insist, the attributes can be stripped in the prod build via a babel/SWC plugin — but it’s often simpler to keep them: they also make debugging and analytics easier.

What to agree on up front so it doesn’t turn into chaos:

  • One attribute name. data-testid is the Testing Library default and getByTestId in Playwright/Cypress. Don’t mix data-test, data-qa, data-cy.
  • A value convention. Readable and stable: checkout-submit, cart-item-remove. Don’t tie to order (item-3) — use a business identifier (item-<sku>).
  • The developer adds the testid with the feature, not QA patching it in after the fact. Then the hook lives in the same PR as the component and doesn’t get lost in refactors.

Traps people forget

  • Localization. A test on visible text getByText('Add to cart') dies the moment a Russian locale ships or an A/B changes the copy. For multilingual apps, data-testid or role solves this. Keep text-based locators where the text itself is what you’re verifying.
  • Dynamic content. Lists, tables, feeds — don’t bind to “the third row.” Match by row content (getByRole('row', { name: /iPhone 15/ })) or a testid with a business key. Otherwise the test breaks on a sort change, pagination, or new data.
  • A non-unique locator. getByRole('button', { name: 'Delete' }) when there are five such buttons is either a strict-mode error (Playwright) or a silent click on whichever comes first (old Selenium). Scope the search to a container: find the right card first, then the button inside it.
  • Hidden and duplicate nodes in the DOM. Modals that are always in the markup, off-screen menus, elements duplicated for mobile/desktop layouts. The locator finds the invisible one — the test clicks “into the void.” Filter by visibility and scope.
  • Shadow DOM and iframes. Shadow DOM and iframes aren’t pierced by plain CSS. Playwright pierces open Shadow DOM automatically; iframes need frameLocator; in Selenium it’s an explicit switchTo().frame().

How it maps across tools

The principle is the same everywhere — “role/accessible name over structure” — only the API changes.

  • Playwright. Built-in getByRole, getByLabel, getByText, getByTestId follow exactly the recommended priority, plus auto-waiting and a strict mode that fails on a non-unique locator — i.e., it flags the problem immediately.
  • Testing Library (React/Vue/JS). It set the query-priority principle in the first place: getByRolegetByLabelTextgetByTextgetByTestId last. The “test the way the user uses it” philosophy.
  • Cypress. Officially recommends data-* attributes and cy.get('[data-testid=...]'), explicitly discouraging binding to tags/classes/ids that layout and styling churn.
  • Selenium. Lower-level: By.id and By.name are the stablest, By.cssSelector on a meaningful attribute is tolerable, By.xpath by position is the last resort. The Page Object pattern here isn’t about locators per se — it’s about keeping them in one place so you fix them in a single spot.
  • Appium (mobile). The data-testid analog is the accessibility id (content-desc on Android, accessibilityIdentifier on iOS). It’s also what blind users need, so it’s a useful attribute, not a “test crutch.”

Checklist: a good locator

  • Expresses what the element is (role/name/business key), not where it sits in the DOM.
  • Not bound to position (nth-child, div[2], row indices).
  • Doesn’t latch onto auto-generated classes (css-1a2b3c, jss42, sc-...).
  • Survives a language/copy change (or the text itself is what you verify).
  • Unique in the intended scope; when not unique — container first, then element.
  • Finds the visible element, not a hidden duplicate.
  • One data-testid convention (single attribute name, readable stable values).
  • testid added by the developer with the feature, not patched on top by QA.
  • Locators pulled out of the test body (Page Object / fixtures) — fix in one place.

Anti-patterns — red flags in review

  • //div[2]/div[1]/span[3] and any XPath by node indices.
  • .css-1x7f9a2, .sc-bdVaJa, .jss17 — CSS-in-JS/module hashes.
  • nth-child, first(), last() as a way to “hit the right one among many.”
  • A visible-text locator in a multilingual app.
  • A giant CSS path body > div > div > main > section > ....
  • The same locator copied into ten tests instead of a Page Object.

In short — what to take with you

  • UI tests fail from brittle locators more often than from bugs. Resilience is set by the selector you choose.
  • Priority: role/accessible name → data-testid → text → meaningful CSS → positional XPath last.
  • Index-based XPath and auto-generated classes are guaranteed brittleness.
  • data-testid isn’t “prod litter” — it’s a contract with development; agree on the name and convention up front.
  • A locator should express what the element is, not where it happened to sit today.

Further reading: Playwright — Locators · Playwright — Best Practices · Testing Library — query priority · Kent C. Dodds — Making UI tests resilient to change · Cypress — Best Practices · Selenium — Locator strategies