Walkthrough
Playwright locator strategy for beginners: stop inspecting HTML, start reading the accessibility tree
Every beginner guide for Playwright locators gives you the same priority list: getByRole, getByLabel, getByText, getByTestId. None of them tells you the harder thing: how to pick the right role string and the right accessible name for a real button on a real page, without right-clicking and inspecting HTML. This guide answers that, by reading the accessibility tree the same way Playwright MCP and Assrt's agent do.
Direct answer (verified 2026-05-12)
Reach for these locators in this order, every time:
page.getByRole(role, { name })for anything interactive (buttons, links, inputs, headings, tabs).page.getByLabel(text)for form fields with a real<label>.page.getByPlaceholder(text)for inputs with placeholder text but no label.page.getByText(text)for non-interactive static copy only.page.getByTestId(id)only when none of the above can pin the element uniquely.
Never CSS chains, never XPath, never :nth-child. To find the exact role string and accessible name, do not inspect HTML. Read the accessibility tree (DevTools Accessibility panel, Playwright MCP browser_snapshot, or Assrt's snapshot tool). It hands you the answer.
Source for the priority order: playwright.dev/docs/locators.
The priority ladder, in five rungs
- 1
1. getByRole
Buttons, links, inputs, headings. Always your first choice.
- 2
2. getByLabel
Form fields with a real <label>.
- 3
3. getByPlaceholder
Inputs with no label, only placeholder text.
- 4
4. getByText
Static non-interactive content only.
- 5
5. getByTestId
Last resort. Add data-testid in source.
Five rungs and that is the entire ladder. The first one covers about 80% of what you will ever click in a test; the last one is your escape hatch for the elements that genuinely have no semantic identity. Everything in between exists because forms are a special case: inputs deserve their own locators because they often lack accessible names a screen reader would speak, and Playwright builds those locators on top of the <label> association the browser already knows about.
Why every beginner picks the wrong locator the first time
You open the page. You right-click the Sign In button. DevTools opens. You see a giant tree of div elements, two of which have classes that look like .btn-primary and .cta-submit. You pick one. You write a locator. It works. Then a week later it does not, and you do not know why.
The problem is the layer you are looking at. The DOM is for browsers and CSS. It changes whenever a designer reorganises a layout, a CSS-in-JS library rehashes a class name, or a developer wraps something in a new container. The accessibility tree is for assistive technology. It changes when the meaning of the page changes, not when the styling does. Locators that ride the accessibility tree survive layout refactors. Locators that ride the DOM do not.
The same button. Two ways to locate it.
// Open DevTools. Right-click the button. "Inspect". // Copy the selector Chrome gives you. await page .locator("#root > div.app-shell > main > section:nth-child(2) > form > div.cta > button.btn.btn-primary.cta-submit") .click(); // One week later the design team wraps <main> in a banner. // Your locator now matches a different button. // The test fails. You spend an hour figuring out why.
- Couples the test to the current DOM tree
- Breaks when a banner is added above main
- DevTools 'Copy selector' produces this by default
The decision tree, in plain English
Walk this list from top to bottom. The first rule that matches is your locator. You do not need to memorise ARIA roles, and you do not need to know how Playwright resolves them internally. You just have to ask one question at a time about the element you are looking at.
Pick the first rule that matches
- It is a button, a link, a checkbox, a tab, a menu item, or any interactive control. Use getByRole.
- It is an input with a visible <label for=...>. Use getByLabel.
- It is an input with no label, only placeholder text. Use getByPlaceholder.
- It is a paragraph, a heading you do not want to click, or a piece of static copy. Use getByText.
- It is an image with meaningful alt text. Use getByAltText.
- None of the above can pin it uniquely. Add data-testid in the source and use getByTestId.
Never do these (no matter how tempting)
- Never use .first(), .last(), or .nth() to disambiguate. Chain locators instead.
- Never copy a selector from DevTools 'Copy selector'. It is a CSS chain tied to the current DOM.
- Never reach for XPath unless you genuinely have no other option (a third-party iframe you cannot annotate, for example).
- Never click a div with onClick by its className. Add role='button' and an aria-label, then getByRole.
The accessibility tree is the shortcut nobody tells beginners about
The decision tree above tells you which locator type to use. It does not tell you the exact role string or accessible name to pass into it. That is the part where beginners get stuck. The trick is to stop trying to derive the answer from the HTML and to ask the browser directly. Both Chrome DevTools and Playwright MCP will hand you a serialised tree where every node is already labelled with its role and accessible name. Copy them in.
What is a snapshot?
A serialized accessibility tree of the current page. Each interactive node appears on its own line with its role and accessible name, plus a runtime-only ref ID. Playwright MCP and Assrt both expose it as a single tool call.
What you get for free
The exact role string and the exact accessible name to put into getByRole. No DOM inspection. No guessing. The tree is what a screen reader would say out loud.
How to run it locally
npx @playwright/mcp@latest in one terminal, then call browser_snapshot from your MCP client. Or install Assrt and run npx @m13v/assrt and read the tree printed during a test run.
Why beginners benefit most
You stop trying to memorise which role maps to which HTML element. The tree tells you. Once you have seen ten snapshots, the ARIA role vocabulary sticks.
Once you have done this five or six times, you stop needing the tree. The vocabulary becomes muscle memory: a clickable element with text on it is almost alwaysrole="button" with that text as its accessible name. A link is role="link". A toggle is role="switch". A tab strip is role="tab" inside role="tablist". The tree is the training wheels; once it has taught you what the page exposes, you do not need to call it for every locator.
What an AI test agent does (and why the algorithm matches the beginner advice)
The agent inside Assrt does exactly what the decision tree above prescribes, just on a tighter loop. It calls snapshot before every interaction, reads the accessibility tree, picks the node by role and accessible name, performs the action, and re-snapshots after. When the test is written back to disk, it serialises the locators as getByRole and getByLabel, the exact things this guide is asking you to write by hand. You can read the strategy verbatim:
That 5-step runbook is the load-bearing piece of the agent's system prompt. It is the same five steps you should run in your own head when you are picking a locator: look at the tree, find the element by what it is, use its identity, and re-look if it moves. The package is MIT-licensed and published on npm as @m13v/assrt, so you can clone the repo and verify line 206 yourself.
How an AI agent picks the locator for you
The thing to notice about this sequence is that you, the beginner, only ever say one thing: "click Sign In". The agent does the snapshot, the role lookup, and the click on your behalf, and the test file it commits to your repo is the same getByRole('button', { name: 'Sign In' }) you would have written by hand if you had read the tree yourself. There is no proprietary locator format hiding in the artifact; what is on disk is standard Playwright.
When one locator is not enough: chain, do not index
Sooner or later a page will have two Submit buttons. The instinct most beginners have is to reach for .first() or .nth(1). Do not. Both methods couple your test to the render order, which is fine until someone adds a third Submit button at the top of the page and your locator silently shifts targets.
The chain-rather-than-index pattern is in the Playwright docs and it goes like this:
Same idea for repeated list items. Use .filter({ hasText }) to pin a row by something a user can read, and then drill into it for the action. The test becomes self-documenting: the next person who reads it can see exactly which row was clicked, without running the test to find out.
What to commit to your repo (and what not to)
The accessibility-tree refs the agent uses at runtime are not selectors. They are snapshot-relative IDs that only mean something inside the snapshot they came from. They do not exist in the DOM and they would be useless in a committed test file two minutes later. So when an agent writes a test, or when you write it yourself, never put a ref in the file.
What you commit is the user-facing locator that resolved the same node, expressed in the form a teammate can read: getByRole, getByLabel, getByText, getByTestId. Two layers, two artifacts, one consistent vocabulary.
Want help porting a flaky locator suite to the role-based strategy?
Bring one of your existing Playwright files. We will walk it through together, find the structural locators, and rewrite them against the accessibility tree.
Common beginner questions about Playwright locator strategy
What is the very first Playwright locator I should reach for as a beginner?
page.getByRole(role, { name: 'Accessible Name' }). It mirrors how a screen reader sees the page, it is the locator Playwright's own docs put at the top, and it is the one that survives a CSS refactor. If you can write the locator by reading the button out loud the way a screen reader would, you have got it. Example: a Sign In button is page.getByRole('button', { name: 'Sign In' }). Do that everywhere you can. Fall back only when role and accessible name cannot disambiguate.
How do I figure out the right role and accessible name without guessing?
Open Chrome DevTools, switch to the Accessibility panel, and click the element. You will see its 'role' and its 'name'. Both strings are exactly what you pass to getByRole(role, { name }). Alternatively, run Playwright MCP's browser_snapshot tool, or Assrt's snapshot tool, and read the serialized accessibility tree. Every interactive node is already labelled with its role and accessible name, e.g. button "Sign In" [ref=e42]. That is the answer, copy it into getByRole.
When should I drop down to getByLabel or getByPlaceholder?
Form fields. An input with a real <label> element should be located by page.getByLabel('Email'). An input that only has placeholder text (no label) should be page.getByPlaceholder('you@company.com'). The Playwright docs explicitly recommend getByLabel for labelled form fields and getByPlaceholder as the fallback when the form has no labels. Both are still user-facing strings, which is the whole point of the strategy.
What about getByText? When does it win over getByRole?
Only for non-interactive elements. A paragraph, a status banner, a chart legend, a piece of static copy. The Playwright docs are explicit: 'For interactive elements like button, a, input, use role locators.' If you reach for getByText to click a button, you will get strict-mode errors as soon as the button text appears twice on the page. getByRole pins it to the actual button.
Where does getByTestId fit in?
It is the escape hatch. Use it when none of the user-facing locators can pin the element uniquely: think component libraries that render anonymised wrappers, dynamic SVG icons, or visually-hidden controls. Add data-testid in the source, then page.getByTestId('apply-promo'). It is durable, but it is a string only your tests care about, so beginners should treat it as the third or fourth choice, not the default.
What do I do when getByRole('button', { name: 'Submit' }) matches two buttons?
Chain another user-facing locator instead of indexing. The Playwright pattern is page.getByRole('form', { name: 'Billing' }).getByRole('button', { name: 'Submit' }). You scope the search to the form by its accessible name first, then ask for the button inside it. Avoid .first(), .last(), .nth(). They couple your test to render order, which changes the moment someone adds a banner.
Why do the guides keep saying CSS and XPath are last-resort?
Because they tie the test to the markup, not the meaning. A locator like 'div.app > main > section.checkout > button.btn-primary' starts pointing at a different element the moment someone adds a banner div, or the CSS team renames .btn-primary to .btn-cta. The page still works, the user can still click it, but your test fails. getByRole and getByLabel ride on top of the accessibility contract, which is more stable than the DOM tree.
Is there a way to skip the locator decision entirely?
Yes, that is what Assrt does. The agent calls snapshot before every interaction, reads the accessibility tree, and acts by the ref of the node it just read. When the test is written back to disk, it serializes user-facing locators (getByRole, getByLabel, getByTestId) so a human can still read the file in three months. The snapshot-then-act loop lives in /Users/matthewdi/assrt-mcp/src/core/agent.ts at lines 206 through 211, and is the same accessibility-tree-first strategy you should run in your head as a beginner.
More Playwright reading from the Assrt guides
Keep going
Playwright structural locators in AI testing
Why CSS and XPath are not the right primitive for AI-driven tests, and what the two-layer (runtime ref + disk getByRole) model looks like.
Playwright for beginners
The shortest path to your first Playwright run, plus the ten concepts you can skip by letting an agent drive the browser for you.
AI Playwright cached selector staleness
The silent-pass failure mode when a cached locator still resolves but points at the wrong element. How to spot it and how to invalidate the cache.
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.