Stable Selectors: Point at Elements That Won't Break
1. The problem, made concrete
Imagine a check that finds the Subscribe button like this:
div.card > form > button:nth-child(3) ← "the 3rd child, inside a form, inside a .card"
It works today. Then you wrap the form in a new <section>, or add a "Reset" button above
Subscribe, or rename .card to .panel. The selector now points at the wrong element —
or nothing — and the test fails. Nothing about the feature broke. This is the single biggest
source of flaky, distrust-building tests.
2. The fix: grab elements like a human does
A user doesn't think "third child of the form." They think "the button that says Subscribe." Playwright's recommended locators work that way — by role, label, and visible text — and Playwright explicitly recommends them over CSS/XPath because the DOM changes but the human-facing meaning usually doesn't.1
| Instead of… | Prefer… | Reads as |
|---|---|---|
| div.card form button | getByRole('button', {name:'Subscribe'}) | "the Subscribe button" |
| #email or input:first-of-type | getByLabel('Email address') | "the Email field" |
| .msg.success | getByText('subscribed') | "the success message" |
Full priority order lives in your new Selector Cheat Sheet — keep it open while you work. The one-line rule: prefer role → label → text → testid; avoid CSS/XPath.1
3. See it for yourself (5 min)
Start the practice app again (from Lesson 02):
cd practice-app && python3 -m http.server 8000
Then ask Claude to show you its reasoning about selectors — this makes the invisible visible:
http://localhost:8000 and take an accessibility snapshot. For the Subscribe button and the
Email field, tell me: what role and accessible name does each have, and what
Playwright locator you'd use to target each (e.g. getByRole / getByLabel). Explain why
that's more stable than a CSS selector.
You'll see the snapshot exposes button "Subscribe" and a textbox labelled "Email address" —
exactly the human-facing handles the good locators use. That snapshot is why role/label locators are
reliable: they're reading the same structured data Claude reads.
4. Prove durability — the experiment that makes it click
This is the heart of the lesson. You'll change the page's structure without changing the feature, and watch which kind of selector survives.
1. Verify the signup happy path works (email
a@b.com, age 30 → success), targeting the
button by its role/name.2. Now edit
practice-app/index.html: wrap the <form> in a new
<section class="wrapper"> and add a <button type="reset">Clear</button>
just before Subscribe. Don't change any validation logic.3. Re-run the same happy-path check. Does targeting by role/name still find Subscribe? Then tell me: would a selector like
form > button:nth-child(1) still have worked? Explain.
The role-based locator still finds "the button named Subscribe" despite the new wrapper and the new Clear
button. A position-based CSS selector would now hit Clear instead. That contrast is the entire
lesson — feel it once and you'll never trust nth-child again.
5. When nothing user-facing is unique: data-testid
Sometimes there's no unique role or text — three identical "Delete" buttons in a list, say. The resilient escape hatch is an explicit test hook you add to your markup:1
<button type="submit" data-testid="signup-submit">Subscribe</button>
data-testid="signup-submit" to the Subscribe button in practice-app/index.html,
then verify you can target it with getByTestId('signup-submit') and complete a signup. Confirm it
still works.
Treat a data-testid as a promise: tests depend on it, so don't rename it on a whim. It's the
fallback, not the default — reach for role/label/text first.
6. Your turn
- Ask Claude for the accessible name of the Age field, then target it by label and fill it.
- Add a second submit-like button to the page, then confirm a role+name locator still hits the right one.
- Ask Claude to deliberately write a brittle CSS selector for the success message, then explain one change to the page that would break it. (Understanding the failure mode cements the lesson.)
- Review one of Claude's locator choices and, if it reached for CSS, ask it to switch to role/label/text.
nth-child selector would have broken. You now know how to keep Claude's tests trustworthy as your
real app changes shape.
<label>s, proper button roles, alt text — also makes your app usable by screen
readers. Testability and accessibility are the same craft pointed two directions.
aria-label or
data-testid), or "how do I make Claude always prefer role-based locators?" Anything sharp
we uncover, I'll fold into the cheat sheet.
7. Where this is heading
You can now drive your app (L2) and target elements durably (L3). Everything so far has been ephemeral — Claude tests live, then the session ends. Next lesson: persisting a passing check as a saved Playwright test file — so you (and eventually CI) can re-run it forever, without Claude in the loop each time. That's the leap from "Claude tested it once" to "this is tested."
References
- Playwright — Locators: recommended priority order (role → label → placeholder → text → testid) and explicit "CSS and XPath are not recommended as the DOM can often change." playwright.dev/docs/locators