Steve Kinney

Locators and the Accessibility Hierarchy

If you only fix one thing about your Playwright suite before letting an agent touch it, fix how you locate elements.

I say this because the locator is the contact surface between your test and your UI—and everything downstream of that (flakiness, waiting, maintenance cost, the agent’s ability to write new tests without breaking them) depends on that surface being solid. A brittle locator poisons the whole loop. An accessible locator makes the rest of Playwright feel almost easy.

The hierarchy

Playwright ships with a bunch of locator APIs. They are not equivalent, and the order you reach for them matters. Here’s the order I want in your head and in your CLAUDE.md by the end of this lesson:

  1. page.getByRole('button', { name: 'Add book' })—by semantic role and accessible name
  2. page.getByLabel('Book title')—by form label
  3. page.getByPlaceholder('Search books...')—by placeholder text
  4. page.getByText('Added to your shelf')—by visible text
  5. page.getByTestId('add-book-button')—by data-testid attribute
  6. page.locator('.btn-primary')—raw CSS

Rules one through four are what I want the agent reaching for first. Rule five is the escape hatch when the UI genuinely doesn’t have an accessible name you can match on. Rule six is an anti-pattern—it’s in the list so I can tell you not to use it.

Why this ordering, and not some other ordering

Because the ordering is aligned with what a user sees.

A screen reader user navigating Shelf doesn’t see .btn-primary or .css-3f7g8h. They see “Add book, button.” If your test targets the same thing the screen reader targets, two things happen automatically. One, your test keeps working across refactors because roles and accessible names are stable in a way that CSS classes are not. Two, you get a rough accessibility audit for free—if you can’t find the element by its role, it doesn’t have a role, and that’s a real bug, not a Playwright problem.

This is the single best argument for locator discipline: the refactor-proof test and the accessible component are the same component. You cannot write a getByRole test against an inaccessible button. The locator forces you to fix the component, and the fixed component helps real users. Free wins don’t get much freer.

What the agent does by default, and why it’s wrong

Left to its own devices, an agent writing a Playwright test does this:

await page.locator('.book-card button.primary').click();

I get why. The agent looked at the rendered DOM, saw a button inside a book card, and pulled the CSS selector that was there. It’s technically correct—the test passes on the day it was written. Then tomorrow someone renames .book-card to .shelf-entry because that’s what it’s actually called in the data model now, and the test explodes, and the agent blames the “flaky test suite.”

The test wasn’t flaky. The locator was coupled to an implementation detail that had no business being part of a test.

The version the agent should write looks like this:

await page
  .getByRole('article', { name: /Station Eleven/ })
  .getByRole('button', { name: 'Rate this book' })
  .click();

Longer. Less clever-looking. Immune to the class rename, the stylesheet rewrite, the migration from CSS-in-JS to Tailwind. It targets what the user sees: a book with a specific name, and a button inside it with a specific action.

Scoping with locators, not selectors

Notice the chained getByRole calls in the good version. That’s the other habit I want to burn in: use locator chaining to scope your search, not string concatenation or complex CSS.

// Don't do this
await page.locator('.shelf-entry[data-title="Station Eleven"] button.rate').click();

// Do this
const book = page.getByRole('article', { name: /Station Eleven/ });
await book.getByRole('button', { name: 'Rate this book' }).click();

The chained version reads in the same order a person thinks: find the book, then find the rate button inside it. And book is a reusable Locator—you can assert on it, hover it, re-scope off it, pass it to another helper. The CSS version is a single string that does one thing and then you throw it away.

When data-testid is fine

I don’t want to tell you data-testid is always wrong. Sometimes it’s the right answer. Specifically:

  • You have three buttons with the exact same accessible name on the page for real product reasons.
  • You’re targeting a wrapper element with no semantic role (a layout div that the test needs to check visibility on).
  • You’re working around a component library that doesn’t expose an accessible name and the fix is not in your repo.

In those cases, add a data-testid and move on. But the rule in your CLAUDE.md should be that data-testid is the third-choice answer, not the first, and the agent should have to write a sentence in the commit message explaining why role and label didn’t work. (That sentence doesn’t need to be enforced mechanically—it needs to exist as a speed bump so the agent doesn’t reach for data-testid by default.)

What goes in CLAUDE.md

Drop this into the instructions file, or something like it:

## Playwright locators

Order of preference when locating elements:

1. `page.getByRole(role, { name })`—try this first. Always.
2. `page.getByLabel(labelText)`—for form inputs with visible labels.
3. `page.getByPlaceholder(text)`—for inputs without labels (and fix the missing label if you can).
4. `page.getByText(text)`—for static visible text and confirmation messages.
5. `page.getByTestId(id)`—only when 1–4 genuinely do not work. If you use this, add a line to the commit message explaining why.
6. `page.locator(cssSelector)`—never. If you find yourself here, the component needs an accessible name.

For nested elements, scope with chained locators, not compound CSS selectors.

That’s nine lines. It’s the most valuable nine lines in your instructions file for the next month. It will prevent more flaky tests than any retry configuration you could possibly write.

Wiring it into the loop

Two pieces of feedback hook into locator discipline directly, and you’ll see both later today.

ESLint rule for page.locator. In Module 8 we’re going to set up an ESLint rule that warns (or errors) whenever page.locator appears in a file under tests/end-to-end/. The agent gets a red squiggle the moment it reaches for the escape hatch, which is the fastest possible feedback.

Playwright’s built-in accessibility debugging. When a getByRole query fails, Playwright’s error message prints the accessibility tree of the page at the point of failure. That tree is gold for the agent—it shows exactly what roles and names are available, so the agent can correct its query without guessing. We’ll lean on this when we talk about failure dossiers.

The one thing to remember

Locate by role and accessible name first. Everything else is an escape hatch, and escape hatches should feel slightly uncomfortable to use. If your agent is reaching for CSS selectors, your instructions file isn’t doing its job yet.

Additional Reading

Last modified on .