Lesson 03 · ~15 minutes · Durability

Stable Selectors: Point at Elements That Won't Break

A test that breaks every time you restyle a button isn't protecting you — it's nagging you. This lesson teaches Claude (and you) to grab elements the way a human sees them.
Why this, why now → Your mission is Claude catching real regressions as you build. But if a test fails just because you renamed a CSS class, you'll learn to ignore failures — and a test you ignore is worse than none. Selectors are what separate "this broke because the feature broke" from "this broke because the HTML moved." Get this right and your checks stay trustworthy as your app evolves.

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 buttongetByRole('button', {name:'Subscribe'})"the Subscribe button"
#email or input:first-of-typegetByLabel('Email address')"the Email field"
.msg.successgetByText('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:

Prompt to Claude
Open 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.

Prompt to Claude
I want to prove selector durability. Do this in order:
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>
Prompt to Claude
Add 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

✓ You've won this lesson when… You changed the page's structure, a role/label-based check kept passing, and you can explain in one sentence why a nth-child selector would have broken. You now know how to keep Claude's tests trustworthy as your real app changes shape.
The accessibility dividend The best locators (role, label, alt text) come straight from accessibility. So writing testable markup — real <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.
Your teacher is in the room. Ask me anything — "what's an ARIA role?", "my button is just an icon with no text — now what?" (answer: 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

  1. 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