Home / Blog / Playwright locators
Playwright locators: which one to use, and what to do when it does not exist
Reach for getByRole first, then getByLabel,
getByPlaceholder, getByText and getByAltText, then
getByTestId, and only then CSS or XPath. Playwright locators are strict: one that
matches two elements throws instead of acting on the first, so the work is in describing one
element precisely enough that nothing else answers to it.
You have an element in front of you and six plausible ways to name it. Each one depends on a different property of your application, and which property you lean on decides what breaks the test in six months.
What is a Playwright locator, actually?
A locator stores the query, not the node. page.getByRole('button', { name: 'Retry' })
is a description of how to find something, and Playwright runs that description again every time
you use the locator. Nothing is held onto between one call and the next.
import { test } from '@playwright/test';
test('a locator is re-resolved every time it is used', async ({ page }) => {
await page.setContent(`
<div id="panel"><button onclick="redraw()">Retry</button></div>
<script>
function redraw() {
document.getElementById('panel').innerHTML =
'<button onclick="redraw()">Retry</button>';
}
</script>
`);
const retry = page.getByRole('button', { name: 'Retry' });
await retry.click(); // clicks the button that was there at setContent
await retry.click(); // that click replaced the node; this one finds the new button
});
Playwright 1.62 · TypeScript · passes
The first click destroys the element the locator just matched. The second click passes anyway,
because retry was never pointing at that node. This is why a Playwright suite has no
equivalent of the stale-element exception and no habit of re-finding elements after a re-render.
Resolution is also where Playwright does its waiting, and what it waits for and which timeout
runs out belong to the article on waiting.
Which locator should you use first?
Walk the list from the top and stop at the first entry that identifies your element and only your element. The order is not a style preference. Each step down swaps a property a user can perceive for a property of the markup, and the markup changes every time somebody refactors a component.
| Locator | What it matches | Reach for it when | What breaks it |
|---|---|---|---|
getByRole | The element's ARIA role plus its accessible name | Almost always, and always for something a user clicks, types into or reads as a heading | A name that changes with translation or a copy edit; a div with no role and no name |
getByLabel | A form control through its label | Inputs, selects and textareas that have an associated label | A label with no for and no wrapping; a design that dropped labels for placeholders |
getByPlaceholder | The placeholder attribute | An input with a placeholder and nothing better | The same placeholder on two inputs, which is what "Search" usually is |
getByText | Text inside a single element | Non-interactive content: a status, a paragraph, a cell | Substring matching catching a second element; a sentence split across sibling elements |
getByAltText | The alt attribute | Images and image maps | A decorative image with alt=""; alt text rewritten by whoever owns search rankings |
getByTitle | The title attribute | The rare element whose tooltip is the only thing naming it | Replacing the native tooltip with a component, which removes the attribute |
getByTestId | The configured test id attribute, data-testid by default | Nothing above identifies the element and you can change the source | A build that strips data attributes; a refactor that carries the id to the wrong node |
| CSS | Any CSS selector, plus Playwright's own pseudo-classes | A structural hook the application promises to keep, such as a form's submit button | Generated class names, nth-child chains, and any DOM shape a redesign moves |
| XPath | An XPath expression | A legacy suite you are converting a file at a time | Index-based paths, and shadow roots: XPath does not pierce them |
One button, six locators, all correct, ordered by how much of your application each one needs to hold still.
import { test, expect } from '@playwright/test';
const MARKUP = `
<form>
<label for="email">Work email</label>
<input id="email" placeholder="you@company.com">
<button id="btn-9f2a" class="css-1x2y3z" data-testid="trial-submit"
type="submit">Start trial</button>
</form>`;
test('five locators, one button', async ({ page }) => {
await page.setContent(MARKUP);
for (const candidate of [
page.getByRole('button', { name: 'Start trial' }), // the label a user reads
page.getByText('Start trial'), // the same words, no role
page.getByTestId('trial-submit'), // an attribute someone put there for us
page.locator('form button[type="submit"]'), // structure the form guarantees
page.locator('.css-1x2y3z'), // a class the bundler invented
page.locator('xpath=//form/button'), // a position in the tree
]) {
expect(await candidate.count()).toBe(1);
expect(await candidate.getAttribute('id')).toBe('btn-9f2a');
}
});
Playwright 1.62 · TypeScript · passes
Rename the button to "Start free trial" and the first two break, loudly, in a way a developer
reading the diff would predict. Ship a new bundle and .css-1x2y3z breaks with nobody
expecting it.
The documentation makes the case for test ids in the other direction: "Testing by test ids is the most resilient way of testing as even if your text or role of the attribute changes, the test will still pass. However testing by test ids is not user facing." Both halves are true. A test id survives everything, and it proves only that the element exists — a role locator that passes has also checked that the control is announced to a user the way your team thinks it is. Picking the highest locator that works also tends to make the assertion write itself.
Why does Playwright say "strict mode violation"?
Because your locator described two things and Playwright will not choose between them. The documentation states the behaviour: "Locators are strict. This means that all operations on locators that imply some target DOM element will throw an exception if more than one element matches." A locator that matches two elements is an error, not a first match.
If the framework your suite came from returned the first element it found, this is the change that fills your terminal on day one of a migration. Nothing is newly broken. The tests were already acting on whichever element the DOM happened to put first, and nobody had chosen that order.
import { test } from '@playwright/test';
const MARKUP = `
<header>
<input type="search" placeholder="Search" aria-label="Search all invoices">
</header>
<aside>
<input type="search" placeholder="Search" aria-label="Search this customer">
</aside>`;
test('ambiguous', async ({ page }) => {
await page.setContent(MARKUP);
await page.getByPlaceholder('Search').fill('overdue');
});
Playwright 1.62 · TypeScript · fails
Error: locator.fill: Error: strict mode violation: getByPlaceholder('Search') resolved to 2 elements:
1) <input type="search" placeholder="Search" aria-label="Search all invoices"/> aka getByRole('searchbox', { name: 'Search all invoices' })
2) <input type="search" placeholder="Search" aria-label="Search this customer"/> aka getByRole('searchbox', { name: 'Search this customer' })
Call log:
- waiting for getByPlaceholder('Search')
Playwright 1.62 · the run output, reproduced against the markup above
Read the two lines under the error before you touch the spec. Playwright printed every match,
and beside each one it printed the locator it would have used instead — both of them
getByRole with a name, one step up the ranking from the placeholder you wrote. On a
good day the repair is already on your screen.
A reviewer should prefer the answers in the order they appear below.
// 1. Narrow it. The locator now describes one element and says which one in English.
await page.getByRole('searchbox', { name: 'Search this customer' }).fill('overdue');
// 2. Scope it. Right when both boxes are genuinely the same control in two regions.
await page.getByRole('complementary').getByPlaceholder('Search').fill('overdue');
// 3. Retreat. Still ambiguous, now silent, and pinned to document order.
await page.getByPlaceholder('Search').last().fill('overdue');
Playwright 1.62 · TypeScript · all three pass
first(), last() and nth() turn the error off without
answering it, and the documentation says as much: "These methods are not recommended because
when your page changes, Playwright may click on an element you did not intend." Use them
on a list where any row genuinely will do. Everywhere else they hand the choice of
element to whoever next edits the template.
Strictness applies to operations that imply one element. Counting is not one of them, so
await page.getByRole('button').count() is fine on a page with forty buttons, and so
are all() and the list assertions built on it.
What do you do when there is no accessible name?
The ranking assumes the element has something to be named by. Plenty of markup does not: an
icon button whose only child is a decorative svg, a div behaving as a
table row, the third nested wrapper a component library emits. Below getByTitle the
list keeps going, and the last step on it is a change to the application.
First, check whether the name is missing or merely wrong
An accessible name can come from the element's own text, an aria-label, an
associated <label>, an alt or a title. A button
containing one svg marked aria-hidden="true" has none of those, so its
accessible name is the empty string and no getByRole call with a name will ever
match it.
<!-- No accessible name. getByRole('button', { name: ... }) cannot reach this. -->
<button id="btn_a8f3c" class="css-1x2y3z">
<svg width="12" height="12" aria-hidden="true"><rect width="12" height="12"/></svg>
</button>
<!-- One attribute later, the button is nameable and audible. -->
<button id="btn_b71e" class="css-1x2y3z" aria-label="Close dialog">
<svg width="12" height="12" aria-hidden="true"><rect width="12" height="12"/></svg>
</button>
The markup, before and after a one-line change
A missing accessible name is usually a defect in the product itself. Someone using a screen reader hits the same wall your locator did, and hears "button"
with nothing after it. Adding aria-label="Close dialog" is a smaller diff than
adding a test id, it fixes both problems, and it is the one repair on this page that improves
something a customer can feel.
Then getByTestId, and be clear about what it costs
A test id is a contract between the test and the product's source. Adding one means editing the application: somebody puts an attribute on a component, it goes through review, and from then on neither side can drop it quietly. It is a change somebody signs off on, which is why it sits below the locators a user could describe and above the ones only a bundler understands.
The attribute is data-testid by default. Teams already using data-qa
or data-test rename it once, under the use key, and never touch it
again.
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
testIdAttribute: 'data-qa',
},
});
playwright.config.ts · Playwright 1.62
import { test, expect } from '@playwright/test';
test('getByTestId reads the configured attribute', async ({ page }) => {
await page.setContent(`
<table>
<tbody>
<tr data-qa="order-row"><td>1042</td><td><button>Edit</button></td></tr>
</tbody>
</table>`);
await page.getByTestId('order-row').getByRole('button', { name: 'Edit' }).click();
expect(await page.getByTestId('order-row').count()).toBe(1);
});
Playwright 1.62 · TypeScript · passes
Then CSS, aimed at structure the application promises
CSS is a fine locator when it leans on something the application promises to keep:
form button[type="submit"], nav a[href="/pricing"], a data attribute
that already exists for another purpose. It stops being fine when it leans on output — a
generated class, a nested child index, the kind of chain the documentation gives as its example
of a bad practice:
#tsf > div:nth-child(2) > div.A8SBwf > div.RNNXgb > div > div.a4bIc > input.
XPath last
XPath is a first-class engine here. Playwright autodetects it, //button works
without the xpath= prefix, and a Selenium suite full of it was not written by
careless people. It goes last for two reasons: an index-based path is a claim about document
order that nothing in your build system is defending, and XPath cannot see inside a shadow root
at all.
How do you narrow a locator without leaving the ranking?
Most strict mode violations are asking you to say which of several identical things you meant, and the answer is almost always somewhere else in the DOM: the row it sits in, the dialog it belongs to, the card under a particular heading. Chaining one locator inside another says that.
Take the case every suite has. Three rows, three Edit buttons, nothing about any of them distinguishing it from the others. The only description of the one you want that survives a re-sort is the Edit button in the row for Contoso, so write that.
import { test, expect } from '@playwright/test';
const MARKUP = `
<table>
<thead><tr><th>Customer</th><th>Plan</th><th>Actions</th></tr></thead>
<tbody>
<tr><td>Northwind Traders</td><td>Team</td><td><button>Edit</button></td></tr>
<tr><td>Contoso</td><td>Enterprise</td><td><button>Edit</button></td></tr>
<tr><td>Fabrikam</td><td>Team</td><td><button>Edit</button></td></tr>
</tbody>
</table>`;
test('the Edit button in one row', async ({ page }) => {
await page.setContent(MARKUP);
// A table row has an accessible name built from its cells, so this works directly.
const byRowName = page
.getByRole('row', { name: 'Contoso' })
.getByRole('button', { name: 'Edit' });
// Same result, and the form to use when the row's name is not distinctive enough.
const byFilter = page
.getByRole('row')
.filter({ hasText: 'Contoso' })
.getByRole('button', { name: 'Edit' });
// Filter by a descendant when the text alone would be ambiguous.
const byDescendant = page
.getByRole('row')
.filter({ has: page.getByRole('cell', { name: 'Enterprise', exact: true }) })
.getByRole('button', { name: 'Edit' });
for (const candidate of [byRowName, byFilter, byDescendant]) {
expect(await candidate.count()).toBe(1);
await candidate.click();
}
});
Playwright 1.62 · TypeScript · passes
filter() takes hasText, hasNotText, has,
hasNot and visible. The two negative forms are the ones people forget
and then need badly:
.filter({ hasNot: page.getByRole('button', { name: 'Undo' }) }) picks the rows that
have not been changed yet, which is otherwise a hard sentence to write in CSS.
and() and or() sit alongside filtering. and() matches elements satisfying both
locators, useful when a role and an attribute are each ambiguous on their own.
or() matches either, for the case where a security dialog sometimes appears in
place of the button you wanted. The documentation carries a note on it: "Note that when both locators match something, the resulting locator will have
multiple matches, potentially causing a locator strictness violation." An
or() is two locators, so it can be twice as ambiguous.
Where do locators break in a real application?
Everything from here down is a DOM in a browser, including a mobile browser, and never a native iOS or Android app — Playwright drives browsers, so in a native app there is no document for a locator to describe. Inside the browser, these five are where it goes wrong.
1. An accessible name that changes with translation
Your suite is green. Someone points it at the German build and every
getByRole('button', { name: 'Submit' }) finds nothing, because the button now says
Absenden. The role survived the translation; the name is the half that did not.
The available repairs are not equal. Pinning the locale from the test config, with
use: { locale: 'en-GB' }, makes the browser report one language, and an
application that chooses its copy from that will serve one language to the suite. Moving the
affected controls to test ids works and costs a source change per control. Matching on role
inside a container you can identify some other way works where the container has a stable name
of its own.
Most teams pin the locale, because it is one line and every other locator on the page keeps
working. Localisation then gets its own short suite, run against each translated build, written
in test ids from the start. One thing to skip: getByRole also accepts a
description option matching the accessible description, and the description gets
translated along with everything else, so it carries the same problem.
2. getByText against a sentence split across elements
The screen reads Order 1042 shipped. The DOM holds three spans. Whether
getByText('Order 1042 shipped') works turns on something invisible: whether the
whitespace you see on screen is in the document or in the stylesheet.
<!-- Matches. The spaces are text nodes inside the <p>, so the <p> contains the sentence. -->
<p>Order <span>1042</span> shipped</p>
<!-- Does not match. The gaps are drawn by flexbox; the text content is "Order1042shipped". -->
<li class="status"><span>Order</span><span>1042</span><span>shipped</span></li>
Two fragments that render identically
import { test, expect } from '@playwright/test';
const ORDERS = `
<style>.status { display: flex; gap: 6px; }</style>
<ul>
<li class="status"><span>Order</span><span>1041</span><span>packed</span></li>
<li class="status"><span>Order</span><span>1042</span><span>shipped</span></li>
</ul>`;
test('split text: what fails and what works', async ({ page }) => {
await page.setContent(ORDERS);
// No single element contains "Order 1042 shipped", so there is nothing to match.
expect(await page.getByText('Order 1042 shipped').count()).toBe(0);
// Match the container by role, then filter on the part that does live in one element.
const row = page.getByRole('listitem').filter({ hasText: '1042' });
expect(await row.count()).toBe(1);
await expect(row.getByText('shipped')).toBeVisible();
});
Playwright 1.62 · TypeScript · passes
The repair generalises: stop asking one element to contain the whole sentence, and instead
locate the container and filter it by the fragment that one element does contain.
Reaching for CSS here is a step down the ranking for no gain, because the two text pseudo-classes
split the same way — :has-text() matches any element containing the text somewhere
inside, up to and including <body>, while :text() matches the
smallest element containing it. Neither invents a space that is not in the document.
3. Dynamic ids and generated class names
#mui-4821. .css-1x2y3z. .btn_a8f3c. These fail for a
reason that has nothing to do with CSS being a poor engine: the value was produced by a build,
and nobody promised to produce the same one tomorrow. The suite goes green for months, then a
dependency bump renumbers everything at once and forty specs go red with no product change to
blame.
If a suite is already full of them, locator.normalize() is worth an afternoon. It
returns a new locator for the same element built from test ids, roles and other user-facing
attributes, so you can print what a resilient version of each selector would look like and paste
the good ones in.
const better = await page.locator('#btn_b71e').normalize();
console.log(better.toString());
// getByRole('button', { name: 'Close dialog' })
Playwright 1.62 · TypeScript · the logged output, from a run against the icon-button markup above
4. Shadow DOM
Component libraries put the element you want inside a shadow root, and the documentation lists
the exceptions: "All locators in Playwright by default work with elements in
Shadow DOM. The exceptions are: Locating by XPath does not pierce shadow roots. Closed-mode
shadow roots are not supported." CSS selectors do pierce open shadow DOM. So
getByRole, getByText and a plain CSS selector all work through an open
root, and XPath does not.
That gives a suite of XPath against a web-component library a ceiling on how much of it can be made to work, and no amount of care with the expressions raises it. It is also the strongest single argument for rewriting those files during a migration. The longer version, with iframes alongside, sits in the piece on shadow roots and frames.
5. A control that is only identifiable by its row
The failure looks like this: page.getByRole('button', { name: 'Edit' }).nth(1),
written the day the customer list happened to be in that order, green ever since. Then someone
adds sorting by plan, or a customer churns, and the spec edits Fabrikam while asserting about
Contoso. Nothing throws. The test passes on the wrong row until an assertion downstream finally
disagrees, and by then the stack trace points somewhere else entirely.
The review habit that catches it is one question: could this locator match a second element after the next feature ships? If the answer is yes, the index is standing in for a description nobody wrote, and writing that description out is the whole repair.
What should your team's rule be?
Write one rule down and put it where a reviewer will see it. It needs the fallback order, the name of your test id attribute, and a sentence on who may add one to the application. Three engineers each following a reasonable convention produce a suite where no one can review anyone else's spec, because a reviewer cannot tell an unusual locator from a deliberate one.
A suite where locators are chosen ad hoc also fails intermittently, and the failures read like timing problems when they are nothing of the sort, so the team that eventually gets called about flakiness is often looking at a spec whose locator matched something else that morning. Locator strategy is one of the four causes we label when we read a suite, and it is the one you can usually read straight off a stack trace.
The version that gets followed is short. Start at getByRole with a name. Go down
the list only when the one above cannot identify the element. When you
reach the bottom, ask whether the markup can change, because the answer is often yes and the
change is usually an accessibility fix somebody wanted anyway.
Questions
What does "strict mode violation" mean in Playwright?
It means your locator described more than one element on the page, so Playwright refused to guess which one you meant and threw instead. The documentation puts it this way: "Locators are strict. This means that all operations on locators that imply some target DOM element will throw an exception if more than one element matches." The error lists every match and prints a suggested locator beside each one, which is usually the repair written out for you.
Which Playwright locator should I use?
Walk the list from the top and stop at the first one that identifies your element and only your element. getByRole with a name, then getByLabel, getByPlaceholder, getByText and getByAltText, then getByTitle, then getByTestId, then CSS, then XPath. Every step down trades a property the user can perceive for a property of the markup, and the markup is the part your team changes without telling anyone.
When is it right to use data-testid?
When nothing above it in the ranking identifies the element, and giving the element a name a user would recognise is not on the table. A test id is a change to the application's source, not a change to the test: somebody adds an attribute to a component, reviews it and ships it, and from then on the test and the product hold a contract that neither side can quietly drop. That is why it sits below the user-facing locators and above CSS.
Do Playwright locators work with shadow DOM?
Open shadow roots, yes, and the documentation lists the exceptions: "All locators in Playwright by default work with elements in Shadow DOM. The exceptions are: Locating by XPath does not pierce shadow roots. Closed-mode shadow roots are not supported." CSS selectors pierce open shadow DOM. So a suite written in XPath against a component library has a ceiling on how much of it can be made to work, and that ceiling is an argument for rewriting those files during a migration.
How many locators are in your suite, and how many have a rule behind them?
A convention is something you can apply yourself, and it does nothing for the thousand locators already written. If those specs are failing at random, the way in is measurement: the suite runs on commits where your product code did not move, and every spec that fails and then passes gets a row, ranked by how often it does it, with a cause written against it. Attach one run that went red and then green on the same commit and there is a first row to read.