Skip to main content
Back to Blog
Guide
2026-04-01

Playwright Locators Best Practices: Roles, Strictness, and Stability

Choose stable Playwright locators with roles, labels, scoped filters, strictness, and web-first assertions, then diagnose ambiguity without brittle shortcuts.

Playwright locator narrowing a page accessibility tree to one stable button

Playwright locator best practices are to select elements by user-facing semantics, make singular targets unique, and let strictness expose ambiguity. Prefer getByRole() with an accessible name, use getByLabel() for fields, scope repeated content with chaining and filter(), and use getByTestId() for an explicit product contract when semantics are insufficient. Pair locators with awaited web-first assertions. Avoid structural CSS/XPath, casual first() or nth(), forced actions, and arbitrary sleeps. The complete Playwright E2E guide supplies the broader runner and fixture foundation.

A good locator describes which user-visible control matters and why it is unique. It does not encode today's component wrapper hierarchy. This guide turns that principle into a repeatable selection order, shows two complete TypeScript examples, and diagnoses strictness and timeout failures without weakening the test.

The Locator Selection Order That Holds Up

Playwright's locator guide calls locators the central piece of auto-waiting and retryability. Its recommended built-ins cover role, text, label, placeholder, alt text, title, and test ID. The official best-practices guide recommends prioritizing user-facing attributes and explicit contracts rather than DOM implementation details.

Use this order as a decision process, not an absolute ranking detached from the UI:

UI contractPreferred locatorExampleMain failure signal
Interactive element with a meaningful role and namegetByRole(role, { name })Submit button, navigation link, dialogSemantics or accessible name changed
Labeled form controlgetByLabel(label)Email, password, consent checkboxLabel-control association is broken
Meaningful non-interactive copygetByText(text)Confirmation, empty-state messageVisible wording changed or is duplicated
Image with alternative textgetByAltText(text)Product image, logo linkAlternative text changed or is missing
Stable explicit automation contractgetByTestId(id)Canvas control, icon-only composite, translated UIProduct/test contract changed intentionally
Implementation detail with no better contractlocator(css)Rare browser-specific or structural stateDOM refactor breaks the selector

Role locators are usually the best first attempt because they match how users and assistive technology perceive the interface. Pass the accessible name in most cases: getByRole('button', { name: 'Save profile' }) says much more than getByRole('button'). Native HTML usually supplies implicit roles, so a <button> should not need a redundant role="button" merely to satisfy a test.

Role locators are not a substitute for an accessibility audit. Playwright explicitly makes that limitation in its docs. They do, however, expose missing names, invalid role assumptions, and inaccessible custom controls early. If a button can be found only by a nested SVG class, first ask whether the control itself needs an accessible name.

Accessible Name Matters More Than Visible Text Alone

The name matched by getByRole() is the computed accessible name, which can come from text content, a label, aria-label, or other accessibility relationships. That is why this may work even when the string is not rendered inside the button:

<button aria-label="Close cart"><svg aria-hidden="true"><!-- icon --></svg></button>
await page.getByRole('button', { name: 'Close cart' }).click();

Do not combine a guessed role with a visible string and assume the browser agrees. Inspect the accessibility tree with codegen, the Inspector, or a snapshot. The official docs recommend codegen because it prioritizes role, text, and test-ID locators and refines a candidate when multiple elements match.

If copy is translated, decide which contract should remain stable. A locale-specific E2E test can intentionally assert the translated accessible name. A behavior test spanning many locales may use a stable test ID while separately testing translation output. Hiding all copy changes behind test IDs makes localization regressions invisible; forcing every cross-locale action through English names makes the suite unusable. Split the concerns.

Strictness Is a Diagnostic, Not an Obstacle

Playwright locators are strict for operations that require one element. The strictness documentation says a click throws when the locator resolves to multiple elements, while multiple-element operations such as count() are valid. This is desirable: a test should not silently click whichever matching button happens to appear first.

When strictness fails, ask these questions in order:

  1. Did the product accidentally render a duplicate control?
  2. Is the accessible name too broad or missing context?
  3. Can the locator be scoped to a dialog, row, card, region, or form?
  4. Is a stable test ID the honest contract for this control?
  5. Is selecting by position genuinely the behavior under test?

Only the final case normally justifies nth(). Playwright warns that first(), last(), and nth() can point at a different element after the page changes. Treat them as explicit list-position assertions, not universal strictness suppressors.

Example 1: Scope a Repeated Action to Its Semantic Card

This complete spec runs with only @playwright/test. Two plan cards contain the same button name. The test identifies the card containing the Pro heading, then finds the button within that card. A DOM wrapper can be added or reordered without changing the user-facing contract.

// tests/pricing-locator.spec.ts
import { test, expect } from '@playwright/test';

test('chooses the Pro plan from repeated cards', async ({ page }) => {
  await page.setContent(`
    <main>
      <h1>Plans</h1>
      <article>
        <h2>Starter</h2>
        <p>For personal projects</p>
        <button data-plan="Starter">Choose plan</button>
      </article>
      <article>
        <h2>Pro</h2>
        <p>For quality teams</p>
        <button data-plan="Pro">Choose plan</button>
      </article>
      <p role="status">No plan selected</p>
    </main>
    <script>
      for (const button of document.querySelectorAll('button')) {
        button.addEventListener('click', () => {
          document.querySelector('[role=status]').textContent =
            button.dataset.plan + ' selected';
        });
      }
    </script>
  `);

  const proCard = page.getByRole('article').filter({
    has: page.getByRole('heading', { name: 'Pro' }),
  });

  await expect(proCard).toHaveCount(1);
  await proCard.getByRole('button', { name: 'Choose plan' }).click();
  await expect(page.getByRole('status')).toHaveText('Pro selected');
});

The parent locator and the has locator are evaluated relationally. This models “the article containing a heading named Pro,” then “the Choose plan button inside it.” It is stronger than a global text search followed by nth(1), and more resilient than a selector such as .plans > div:nth-child(2) button.

The toHaveCount(1) assertion is optional for the click because strictness already requires one button. It is useful here as a diagnostic boundary: if the product duplicates the Pro card, the failure points at card identity before the action. Do not add uniqueness assertions to every obvious locator; add them where repeated structures have historically drifted.

Example 2: Resolve a Strict Mode Violation in a Table

The next runnable example starts by proving that a broad Delete locator matches two controls. It then scopes to the invoice row a user intends to act on. No wait, position, or CSS class is needed.

// tests/invoice-locator.spec.ts
import { test, expect } from '@playwright/test';

test('deletes the intended invoice', async ({ page }) => {
  await page.setContent(`
    <table>
      <caption>Invoices</caption>
      <thead><tr><th>Number</th><th>Customer</th><th>Action</th></tr></thead>
      <tbody>
        <tr><td>INV-1041</td><td>Northwind</td><td><button>Delete</button></td></tr>
        <tr><td>INV-1042</td><td>Contoso</td><td><button>Delete</button></td></tr>
      </tbody>
    </table>
    <p role="status">No invoice deleted</p>
    <script>
      for (const button of document.querySelectorAll('button')) {
        button.addEventListener('click', () => {
          const row = button.closest('tr');
          document.querySelector('[role=status]').textContent =
            row.cells[0].textContent + ' deleted';
          row.remove();
        });
      }
    </script>
  `);

  const broadDelete = page.getByRole('button', { name: 'Delete' });
  await expect(broadDelete).toHaveCount(2);

  const invoice = page.getByRole('row').filter({ hasText: 'INV-1042' });
  await invoice.getByRole('button', { name: 'Delete' }).click();

  await expect(page.getByRole('status')).toHaveText('INV-1042 deleted');
  await expect(page.getByRole('row').filter({ hasText: 'INV-1042' })).toHaveCount(0);
});

If customer names or invoice numbers can overlap, replace broad hasText with a child locator that expresses the stable cell contract. For example, give the invoice-number cell a test ID or use a link with the invoice number as its accessible name. Filtering is not permission to use an imprecise substring forever.

Chaining, has, hasText, and Visible Filtering

Chaining keeps search context local:

const dialog = page.getByRole('dialog', { name: 'Edit profile' });
await dialog.getByLabel('Display name').fill('Asha');
await dialog.getByRole('button', { name: 'Save' }).click();

Use filter({ has }) when a descendant has a semantic identity, and filter({ hasText }) for a stable text fragment within the candidate. Prefer has when it can express a role/name relationship because it narrows both meaning and scope.

Current Playwright also supports filter({ visible: true }), added before 1.61. The locator docs caution that a more reliable unique locator is usually better. Visible filtering is reasonable when the product deliberately keeps duplicate active/inactive templates in the DOM and visibility is the actual distinction. It is not a blanket fix for ambiguous component markup.

Locator operators such as and() and or() solve real compound or alternative states, but or() can itself become strict if both alternatives appear. If either a primary control or an interruption dialog may be present, observe the union, then branch based on the current UI and continue with a unique locator. Do not click the union blindly.

Pair Locators with Web-First Assertions

Stable selection is only half of reliable automation. The assertions guide explains that async web matchers re-fetch and retry until the condition passes or times out. Use:

await expect(page.getByRole('status')).toHaveText('Saved');

instead of:

expect(await page.getByRole('status').textContent()).toBe('Saved');

The second form performs an immediate value assertion. It can fail during a legitimate asynchronous update even though the locator is excellent. Similarly, expect(await locator.isVisible()).toBe(true) checks once; await expect(locator).toBeVisible() retries.

Actions have their own actionability checks. For click(), Playwright requires the locator to resolve to one element and checks visibility, stability, event reception, and enabled state. A locator timeout may therefore mean the element exists but is covered, moving, or disabled. Read the action log before changing the selector.

Avoid force: true unless the test intentionally bypasses a user-facing constraint. Force can turn “the overlay blocks checkout” into a passing click that no user could perform. Avoid waitForTimeout for the same reason: sleeping neither identifies the correct element nor proves it became actionable.

Use Test IDs as a Designed Contract

Playwright's locator guide calls test IDs the most resilient testing method, while noting that they are not user-facing. Use them deliberately for interfaces whose stable identity is not available through semantics: a complex canvas toolbar, a translated composite widget, a virtualized cell, or two controls with intentionally identical names but different business identities.

const total = page.getByTestId('checkout-total');
await expect(total).toHaveText('$42.00');

Name IDs by business meaning, not visual location: checkout-total is stronger than right-column-label-3. Treat removal or renaming as a contract change reviewed alongside tests. You can configure a different attribute through Playwright's test-ID configuration if the product already uses an established convention.

Do not add test IDs to compensate for broken HTML labels or unnamed buttons. Fix the semantic contract first when users and assistive technology need it. Then use a test ID only when it expresses information semantics cannot.

Review Every New Locator as a Contract

Before merging a specification, read each action locator without looking at the DOM. A reviewer should be able to identify the control and its meaningful scope from the test code. Apply this short gate:

  • The locator names a role, label, text, alternative text, or intentional test contract rather than a component class.
  • A singular action resolves uniquely for a product reason, not because first() happens to select today's element.
  • Repeated rows or cards are narrowed by a stable child identity before their action is selected.
  • The locator targets the correct page or frame and is created after the flow that makes that surface available.
  • The follow-up assertion is awaited and checks a user-visible result, not merely that the click promise resolved.
  • force, positional selection, visible filtering, and test IDs each have a reason visible in the scenario or nearby code.
  • Test data supplies deterministic business keys, so a good locator is not undermined by duplicate uncontrolled records.

Generated locators are candidates, not exemptions from this review. Codegen can see the current DOM and improve ambiguity, but it does not know which wording is contractually stable, whether a test ID represents business identity, or whether an experiment will render a second control in CI. Run the candidate against realistic data and at least the browser projects the suite supports.

When a locator must use CSS, keep the exception narrow and document the missing semantic contract in the application backlog. A short selector tied to a deliberate state attribute is preferable to a full ancestry path. Revisit exceptions when the component becomes accessible or gains a stable automation contract; otherwise temporary implementation detail becomes permanent suite architecture.

Diagnose Locator Failures Systematically

Strict mode violation

Count the matches and inspect each candidate in the Inspector or trace. Scope to a meaningful parent, add the accessible name, filter by a stable child, or introduce an explicit test ID. Do not append .first() until you can explain why first position is the requirement.

Timeout waiting for a locator

Confirm the page URL and enclosing frame first. Then inspect role, name, and current DOM. A redirect, unopened dialog, wrong iframe, or failed setup often makes the correct locator absent. If the element exists, review actionability details such as visibility, stability, enabled state, and event interception.

getByRole cannot find a visibly labeled control

The guessed role or computed accessible name differs from the visual impression. Inspect the accessibility snapshot. Check native element type, label association, aria-label, aria-labelledby, and hidden content. Fix application semantics rather than changing a button to getByText just to make the test green.

The locator works locally but finds hidden duplicates in CI

Responsive markup, experiments, or server-rendered templates may differ. Run the same project/viewport and inspect the trace. Scope by active dialog or region. Use filter({ visible: true }) only if visibility is the intended product distinction, not because the source of duplication is unknown.

A locator selects the wrong item after data changes

Positional selection is the usual cause. Replace nth() with a stable row/card identity and a nested action. Control test data so business keys are deterministic. If position itself is the behavior, assert the complete ordered list before acting on an index.

A good locator still flakes on the assertion

Check whether the assertion is web-first and awaited. Replace immediate DOM-value checks with locator matchers. If the state comes from a network request, assert the resulting UI or a specific response instead of sleeping. Keep selector diagnosis separate from asynchronous-state diagnosis.

The Playwright debug-mode guide covers Inspector workflows, while the existing Playwright locator best-practices article provides additional migration patterns.

Version Scope and Limitations

These locator principles are current in Playwright 1.61, but they are not new 1.61 APIs. Role locators, strictness, chaining, filtering, auto-waiting, and web-first assertions are established Playwright behavior. The current release notes show that accessible-description matching for getByRole() arrived in 1.60, while visible filtering arrived earlier. Do not label either as a 1.61 feature.

Locators cannot make an unstable business identity stable. Duplicate names may be valid, virtualized content may not exist until scrolled into view, and canvas pixels do not expose ordinary DOM roles. Closed shadow roots are not pierced by normal locators, and cross-origin content still requires correct frame targeting. Use product contracts, controlled data, frame locators, visual assertions, or component-level tests as the UI requires.

Semantic locators also do not prove accessibility conformance. Add an accessibility testing layer for rules and manual evaluation. A passing getByRole('button') only proves Playwright found that role/name combination for this scenario.

Use the Playwright BrowserContext guide when locator failures actually come from the wrong user session or state. Use the Web Storage 1.61 guide when a feature flag or onboarding marker controls whether the element exists. For agent-ready conventions, browse the QA skills directory and install the Playwright CLI skill; the CLI can help inspect current accessibility state, but generated candidates still need human review against the product contract.

Frequently Asked Questions

Is getByRole always the best Playwright locator?

It is usually the best first choice for interactive, semantic UI when paired with an accessible name. Use getByLabel for labeled fields and a stable test ID when user-facing semantics cannot uniquely express the product contract. There is no benefit in forcing a role locator onto non-semantic implementation detail.

Why does Playwright throw a strict mode violation?

A singular operation such as click() resolved to more than one element. The failure protects the test from acting on an arbitrary match. Narrow by name, parent scope, child filter, or explicit contract instead of immediately using first().

Are first, last, and nth bad in every test?

No. They are valid when list position is itself the requirement, such as asserting the first search result after verifying the order. They are risky when used only to silence ambiguity because inserted or reordered elements can redirect the action silently.

Should I use CSS or XPath locators in Playwright?

Use them only when the target has no suitable user-facing or explicit contract. Long CSS and XPath paths couple tests to DOM structure and break during harmless refactors. If the same structural selector appears repeatedly, improve the app's semantics or add a stable test ID.

Does getByRole perform an accessibility audit?

No. It uses accessibility roles and names to locate elements and can reveal obvious semantic problems, but Playwright explicitly says role locators do not replace accessibility audits and conformance tests.

How do I locate one button among repeated cards or rows?

Locate the card or row by its stable heading, key, or child element, then call getByRole('button', { name }) inside that parent. This expresses the relationship and survives reordering better than nth().

Why should I avoid waitForTimeout with locators?

Playwright locators already re-resolve elements, actions auto-wait for actionability, and web-first assertions retry. A fixed sleep adds latency without proving the target is unique or ready. Wait for the specific user-visible state or network boundary instead.

When is getByTestId preferable to a visible name?

Use it when the stable identity is a business or automation contract not reliably represented by role, label, or text, including heavily localized or non-DOM controls. Keep separate assertions for user-facing copy when that copy matters.