Home / Blog / Playwright locators

Playwright locators: which one to use, and what to do when it does not exist

Quick answer

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.

LocatorWhat it matchesReach for it whenWhat breaks it
getByRoleThe element's ARIA role plus its accessible nameAlmost always, and always for something a user clicks, types into or reads as a headingA name that changes with translation or a copy edit; a div with no role and no name
getByLabelA form control through its labelInputs, selects and textareas that have an associated labelA label with no for and no wrapping; a design that dropped labels for placeholders
getByPlaceholderThe placeholder attributeAn input with a placeholder and nothing betterThe same placeholder on two inputs, which is what "Search" usually is
getByTextText inside a single elementNon-interactive content: a status, a paragraph, a cellSubstring matching catching a second element; a sentence split across sibling elements
getByAltTextThe alt attributeImages and image mapsA decorative image with alt=""; alt text rewritten by whoever owns search rankings
getByTitleThe title attributeThe rare element whose tooltip is the only thing naming itReplacing the native tooltip with a component, which removes the attribute
getByTestIdThe configured test id attribute, data-testid by defaultNothing above identifies the element and you can change the sourceA build that strips data attributes; a refactor that carries the id to the wrong node
CSSAny CSS selector, plus Playwright's own pseudo-classesA structural hook the application promises to keep, such as a form's submit buttonGenerated class names, nth-child chains, and any DOM shape a redesign moves
XPathAn XPath expressionA legacy suite you are converting a file at a timeIndex-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.