An end-to-end browser test clicks a button using the CSS selector `.card > div:nth-child(2) > button.btn-primary`. It passes today and fails after a refactor that changed only markup nesting and class names, with no change to what a user can do. What makes that selector fragile, and what should the test anchor on instead?
answer
- what changes without behaviour changing
- class names are styling, not contract
- nth-child moves when markup moves
- user-perceivable identity survives refactors
- test id is a declared, greppable contract
basics
~20 sA structural CSS path couples the test to DOM nesting and styling class names, which change freely without changing behaviour. Anchor on what a user perceives — the element's role and visible name — or on an explicit test-id attribute the component deliberately exposes.
solid answer
~50 sThe selector encodes three things that are not part of the product's behaviour: the depth and order of wrapper elements, a positional index, and a class whose real job is styling. Any of those can change in a pure refactor — adding a layout wrapper, reordering markup, renaming a class, or switching to a CSS-in-JS library that emits hashed class names — and the test fails while the app is perfectly fine. That is the worst kind of failure: it costs review time and teaches the team to distrust the suite. A durable selector points at something that only changes when behaviour changes. In practice that is the element's accessible role plus its visible name (`getByRole('button', { name: 'Save changes' })` in the Testing Library and Playwright families), or a `data-testid` the component owns as a declared contract. Structural CSS is the last resort, not the default.
go deeper
Be ready to say plainly that class names and DOM nesting are implementation details, and that a test should find a button the way a user does — by what it is and what it says.
Explain each coupling separately: structural (> and nesting), positional (:nth-child), and presentational (class names), and show which realistic refactor breaks each one.
Show the judgment call: after a red suite, prove whether behaviour changed before touching anything, and argue why patching paths quietly erodes trust in the suite until people start re-running instead of reading failures.
Own the policy angle — a selector convention only holds if the app side supplies the anchors, so argue for labelled controls and component-owned test ids as product requirements, not test-team requests.
## What a selector is really promising Every selector in a test is a bet: *this string will still identify the same element next month*. The question is what the string is betting on. If it bets on facts that change only when the product's behaviour changes, the test survives refactors. If it bets on facts that a developer can change while shipping no behaviour change at all, the test is a tripwire on the wrong door. `.card > div:nth-child(2) > button.btn-primary` bets on four separate facts: 1. A `.card` element exists and is an ancestor. 2. Its **second element child** is the wrapper you want — a positional fact. 3. That wrapper is a `div` and is a **direct** child (`>`). 4. The button carries the class `btn-primary`. None of those four is something a user can perceive, and none is something anyone promised to keep stable. ## The three couplings, and why each one breaks **Structural coupling.** `>` and `:nth-child()` encode the shape of the tree. Wrapping content in an extra flex container for layout, moving a heading above the button, or letting a framework insert a fragment wrapper shifts the indices. This is the single most common cause of "the tests broke but nothing is wrong." **Styling coupling.** `btn-primary` is a presentation hook. Nothing stops a designer from renaming it to `btn-cta`, and with CSS Modules or CSS-in-JS the class in the DOM is generated — `.css-1x9zj4k` today, a different hash after the next build. A test that reads generated class names is reading a compiler artifact. **Positional coupling.** Indices also depend on *data*. `:nth-child(2)` in a list is the second row only for the data the test happened to seed; sorting, pagination or a new row silently repoints it at a different entity — which fails loudly if you are lucky and passes wrongly if you are not. XPath variants such as `//div[3]/button` are the same bet in a less readable syntax, and browser-recorder output is usually worse: recorders emit whatever unique path they can compute, which is precisely the brittle kind. ## What to anchor on instead There is a rough ladder, and it is ordered by how tightly the anchor is tied to behaviour: 1. **Role and accessible name** — the button *named* "Save changes". This is what a user (and a screen reader) perceives, so it changes only when the interface genuinely changes. 2. **An explicit `data-testid`** — an attribute the component adds on purpose, named after the thing (`checkout-submit`), treated like any other public surface: changing it is a deliberate act, not a side effect. 3. **Structural CSS or XPath** — acceptable only when nothing better exists, for example inside a third-party embed you cannot modify. Both top options share a property the CSS path lacks: **the app's own code declares them**. A role comes from the element you chose (`<button>`, not `<div onclick>`); a name comes from the label you wrote; a test id is a string somebody typed with tests in mind. A grep for `checkout-submit` finds the component; a grep for `:nth-child(2)` finds nothing useful. ```html <!-- fragile: the test rides on nesting and a styling class --> <div class="card"><div><h3>Plan</h3></div><div><button class="btn-primary">Save changes</button></div></div> <!-- durable: a real button with a real name, plus an owned test id --> <button data-testid="plan-save">Save changes</button> ``` ## When a structural selector is defensible Being pragmatic beats being pure. Locating a container that has no semantics — a chart wrapper, a canvas, a legacy table — sometimes leaves you with a CSS selector. The mitigations are to keep the selector as shallow as possible (one stable hook, not a five-level path), and to convert it into an owned contract at the first opportunity by adding a test id to that container. ## The habit to build When a selector breaks, the reflex should not be "patch the path." It should be: *did behaviour change?* If not, the selector was betting on the wrong fact, and the fix is to re-anchor it on the role and name or on a test id — otherwise the same failure returns on the next refactor, and each patch makes the suite a little less trusted.
- Is an XPath such as //div[3]/button any better than that CSS path?No — usually worse. It makes the same positional and structural bet, is harder to read in a failure message, and browser recorders emit long absolute paths that break on the first wrapper anyone adds. The one thing XPath adds is text matching, and modern locator APIs give you that without XPath.
- What if the class is a hand-written, stable BEM class the team never renames?Then it is an implicit contract: it works right up to the day someone treats it as what it looks like — a styling hook — and renames it during a CSS cleanup. Nothing in the codebase says tests depend on it. If you want a class-like hook, make it explicit with a `data-testid`, which reads as "do not delete".
- The refactor broke thirty tests. Do you fix the selectors or the app?Neither reflexively. First confirm behaviour is unchanged; if it is, the tests were coupled to implementation and the fix belongs in the tests — re-anchored on role and name or on test ids, not patched to the new path. If the refactor removed a label or replaced a real button with a clickable div, the app is what regressed.
Addressing an element by its DOM path is like giving directions as "third door on the left after the second corridor" — accurate until someone adds a corridor. A role and name is the nameplate on the door.
saying these in an interview costs you the question
- Says the app must be broken because the tests went red
- Patches the broken path instead of re-anchoring the selector
- Treats generated CSS-in-JS class names as stable identifiers
- Believes a longer, more specific selector is a more reliable one
- Re-records selectors with a browser recorder after every failure