Core Concepts August 15, 2026 13 min read

Playwright Locators Guide 2026: getByRole, getByText & More

Locators are the foundation of every Playwright test. Pick the wrong strategy and you get a fragile suite that breaks on every deploy. This guide covers every built-in locator — with 15+ real code examples — so you always choose the right one.

Every Playwright test begins the same way: find an element, then interact with it. The locator is how you find that element. It sounds simple, but the locator strategy you choose determines whether your test breaks next sprint or survives for years.

Playwright offers a rich set of built-in locators — from semantic methods like getByRole and getByLabel to raw CSS and XPath selectors. Each has a specific use case. This guide walks through all of them in the order you should reach for them, with real code examples you can copy into your project today.

If you are brand new to Playwright, start with our Playwright automation for beginners guide first, then come back here to master locators.


Why Locators Matter in Playwright

A locator is not just a selector string. In Playwright, a Locator is a first-class object that represents a way to find elements on a page at any moment. Unlike Selenium's findElement, which returns a stale reference, Playwright locators are lazy — they re-query the DOM every time you use them.

This design gives you three critical advantages:

  • Auto-waiting: Every action on a locator (click, fill, check) automatically waits for the element to become actionable — visible, stable, enabled, and not obscured. No more waitForSelector before every click.
  • No stale element errors: Because locators re-query on each action, elements that re-render (React, Angular, Vue state changes) never throw stale reference exceptions.
  • Retry-ability: Web-first assertions like expect(locator).toBeVisible() auto-retry until the condition passes or the timeout expires.
auto-waiting in action
// Playwright waits for the button to be actionable before clicking
await page.getByRole('button', { name: 'Submit' }).click();

// No explicit wait needed — the assertion retries automatically
await expect(page.getByText('Success!')).toBeVisible();

The bottom line: your choice of locator strategy determines how resilient and maintainable your tests are. A brittle CSS chain like div.wrapper > form > div:nth-child(3) > input breaks the moment a developer wraps the form in a new container. A semantic locator like getByLabel('Email') survives because it targets meaning, not structure.


The Locator Hierarchy: Which to Use First

Playwright's documentation recommends a clear priority order. Memorize this hierarchy and always start at the top:

  1. getByRole — The gold standard. Matches how users and screen readers see the page.
  2. getByLabel — Perfect for form inputs associated with a <label>.
  3. getByText — Finds elements by their visible text content.
  4. getByTestId — Reliable fallback using data-testid attributes.
  5. getByPlaceholder / getByAltText — Niche use cases for placeholders and images.
  6. CSS / XPath — Last resort for legacy code or complex DOM traversal.

Rule of thumb: If you can find the element with getByRole, you should. Only move down the hierarchy when the element genuinely lacks a semantic role, label, or visible text. See our Playwright best practices guide for more on this philosophy.


getByRole — The Gold Standard

getByRole locates elements by their ARIA role and accessible name. It mirrors how assistive technology (screen readers, voice control) identifies elements, making it the most resilient locator strategy available.

Common roles and examples

getByRole examples
// Button
await page.getByRole('button', { name: 'Add to Cart' }).click();

// Link
await page.getByRole('link', { name: 'Documentation' }).click();

// Heading (specific level)
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');

// Textbox (input[type=text] or textarea)
await page.getByRole('textbox', { name: 'Search' }).fill('Playwright');

// Checkbox
await page.getByRole('checkbox', { name: 'Accept terms' }).check();

// Combobox (select dropdown)
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');

// Navigation landmark
const nav = page.getByRole('navigation');

// Row in a table
await expect(page.getByRole('row')).toHaveCount(5);

The name option explained

The name option matches the element's accessible name, which is computed from (in order of priority): aria-label, aria-labelledby, the element's text content, or the associated <label>. You can pass a string (exact match) or a regular expression:

name with regex
// Exact match
page.getByRole('button', { name: 'Submit Order' });

// Regex — case-insensitive partial match
page.getByRole('button', { name: /submit/i });

// Exact: false — substring match
page.getByRole('heading', { name: 'Welcome', exact: false });

Accessibility bonus: If you cannot find an element with getByRole, it likely has an accessibility problem. Missing roles, missing labels, and non-semantic HTML all prevent getByRole from working — and they also prevent screen readers from working. Your locator strategy doubles as an accessibility audit.


getByLabel — Perfect for Form Fields

getByLabel finds form controls by their associated label text. It works with <label for="..."> associations, wrapping <label> elements, and aria-label attributes.

getByLabel examples
// Standard label association
// <label for="email">Email address</label>
// <input id="email" type="email">
await page.getByLabel('Email address').fill('user@example.com');

// Wrapping label
// <label>Password <input type="password"></label>
await page.getByLabel('Password').fill('s3cur3Pa$$');

// aria-label attribute
// <input aria-label="Search products" type="search">
await page.getByLabel('Search products').fill('laptop');

// Checkbox with label
await page.getByLabel('Remember me').check();

getByLabel is the ideal locator for forms because users identify inputs by their labels, not by their CSS classes or IDs. If a developer renames a class from .email-input to .form-email, your test still works because the label text didn't change.


getByText — Find by Visible Text

getByText locates elements containing the specified text. By default, it performs a case-sensitive substring match. You can control matching behavior with the exact option or use a regular expression.

getByText matching modes
// Default: case-sensitive substring match
page.getByText('Welcome back');
// Matches "Welcome back, John!" and "Welcome back"

// Exact match — full text must equal
page.getByText('Welcome back', { exact: true });
// Only matches "Welcome back" (not "Welcome back, John!")

// Regular expression — case-insensitive
page.getByText(/welcome back/i);

// Regex with pattern
page.getByText(/\d+ items? in cart/);
// Matches "1 item in cart" or "5 items in cart"

Watch out: getByText matches any element containing the text, not just buttons or headings. If multiple elements match, Playwright throws a strict-mode error. Use .first() or narrow the scope with chaining if needed.


getByTestId — The Reliable Fallback

When an element has no meaningful ARIA role, no label, and no stable visible text, getByTestId is your safety net. It locates elements by the data-testid attribute (configurable).

getByTestId usage
// HTML: <div data-testid="product-card">...</div>
const card = page.getByTestId('product-card');

// HTML: <canvas data-testid="chart-canvas"></canvas>
await expect(page.getByTestId('chart-canvas')).toBeVisible();

Configuring a custom test ID attribute

Some teams use data-cy, data-qa, or data-test instead of data-testid. You can configure this globally in playwright.config.ts:

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // Now getByTestId looks for data-qa="..."
    testIdAttribute: 'data-qa',
  },
});

When to use getByTestId: Custom canvas components, drag-and-drop zones, dynamically generated containers, or third-party widgets where you have no control over semantic markup. If you are adding data-testid to a <button> or <input>, stop — those already have ARIA roles. Use getByRole instead.


getByPlaceholder and getByAltText

These two locators serve niche but important use cases.

getByPlaceholder

Useful when a form input has placeholder text but no associated label (common in minimalist UI designs):

getByPlaceholder
// <input placeholder="Enter your email..." type="email">
await page.getByPlaceholder('Enter your email...').fill('test@example.com');

// Regex match
await page.getByPlaceholder(/email/i).fill('test@example.com');

getByAltText

Locates elements with an alt attribute — typically images and <area> elements in image maps:

getByAltText
// <img alt="Company logo" src="/logo.png">
await expect(page.getByAltText('Company logo')).toBeVisible();

// Click an image link
await page.getByAltText('Product thumbnail').click();

CSS and XPath Selectors — When to Use Them

Playwright supports both CSS and XPath selectors through the generic page.locator() method. While they offer maximum flexibility, they are also the most fragile option because they couple your test to DOM structure.

CSS selectors

CSS selectors
// By class
page.locator('.product-card');

// By ID
page.locator('#main-content');

// Attribute selector
page.locator('input[type="email"]');

// nth-child for tables
page.locator('table tbody tr:nth-child(3) td:nth-child(2)');

// Combine with :visible pseudo-class
page.locator('button.submit:visible');

XPath selectors

XPath selectors
// Prefix with xpath= to use XPath
page.locator('xpath=//button[contains(text(), "Save")]');

// Navigate up the DOM tree (parent axis)
page.locator('xpath=//span[text()="Error"]/parent::div');

// Find by attribute
page.locator('xpath=//input[@name="username"]');

When CSS/XPath make sense:

  • Legacy applications with no semantic HTML and no ability to add data-testid
  • Complex DOM traversal that semantic locators cannot express (parent/sibling axis in XPath)
  • nth-child table cell selection where no other identifier exists
  • Third-party iframes or widgets you cannot modify

Chaining and Filtering Locators

Real-world UIs have repeated patterns — product cards, table rows, list items. A single getByRole('button') might match 20 buttons on the page. Chaining and filtering let you narrow down to the exact element you need.

Chaining with .locator()

Call .locator() on a locator to scope the search within that element's subtree:

locator chaining
// Find the "Delete" button inside a specific product card
const card = page.getByTestId('product-card-42');
await card.getByRole('button', { name: 'Delete' }).click();

// Navigate within a table
const table = page.getByRole('table');
const rows = table.getByRole('row');
await expect(rows).toHaveCount(10);

Filtering with .filter()

The .filter() method adds conditions to narrow results without creating fragile DOM chains:

filter examples
// Find list item containing "Playwright" text
const item = page.getByRole('listitem')
  .filter({ hasText: 'Playwright' });

// Filter by child element
const row = page.getByRole('row')
  .filter({ has: page.getByText('Active') });

// Chain multiple filters
const activeAdmin = page.getByRole('row')
  .filter({ hasText: 'Admin' })
  .filter({ has: page.getByText('Active') });

// Negative filter — exclude rows with "Disabled" text
const enabledRows = page.getByRole('row')
  .filter({ hasNotText: 'Disabled' });

Positional methods: .nth(), .first(), .last()

positional locators
// First item in a list
await page.getByRole('listitem').first().click();

// Last row in a table
await page.getByRole('row').last().click();

// Third item (zero-indexed)
await page.getByRole('listitem').nth(2).click();

// Combine filter + nth
const secondActive = page.getByRole('row')
  .filter({ hasText: 'Active' })
  .nth(1);
await secondActive.getByRole('button', { name: 'Edit' }).click();

Best practice: Prefer .filter({ hasText: ... }) over .nth() whenever possible. Filters are semantic and survive reordering. Positional indices break when a new item is inserted above your target.


Locator Assertions

Playwright's web-first assertions pair perfectly with locators. They auto-retry until the condition is met or the timeout expires — no manual polling required.

common locator assertions
import { expect } from '@playwright/test';

// Visibility
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByTestId('modal')).toBeHidden();

// Text content
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
await expect(page.getByTestId('price')).toContainText('$49.99');

// Count
await expect(page.getByRole('listitem')).toHaveCount(5);

// Enabled / disabled
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();

// Checked state
await expect(page.getByLabel('Accept terms')).toBeChecked();

// Attribute value
await expect(page.getByRole('link', { name: 'Docs' })).toHaveAttribute('href', '/docs');

// CSS class
await expect(page.getByTestId('card')).toHaveClass(/active/);

// Input value
await expect(page.getByLabel('Email')).toHaveValue('test@example.com');

Custom timeout: All assertions accept an optional timeout: await expect(locator).toBeVisible({ timeout: 10000 }). This is useful for slow-loading elements like dashboards or lazy-loaded images.


Combining Locators: locator.and() and locator.or()

Playwright v1.50+ added and() and or() for combining locators without chaining or filtering — useful when you need to match elements satisfying multiple conditions simultaneously.

locator.and() — both conditions must match

locator.and() — intersection
// Find a button that is BOTH visible AND has the name "Submit"
const button = page.getByRole('button')
  .and(page.getByText('Submit'));

// Useful when getByRole alone returns multiple buttons
const primaryBtn = page.getByRole('button')
  .and(page.locator('[data-variant="primary"]'));

locator.or() — either condition matches

locator.or() — union
// Handle two possible button labels (A/B test or locale variation)
const confirmBtn = page.getByRole('button', { name: 'Confirm' })
  .or(page.getByRole('button', { name: 'Place Order' }));

await confirmBtn.click(); // clicks whichever is present

// Handle a modal OR an inline message (only one appears at a time)
const errorMsg = page.getByRole('dialog')
  .or(page.getByTestId('inline-error'));

Playwright Inspector: Not sure which locator to use? Run npx playwright open --inspect https://yourapp.com to launch the interactive inspector. Click any element and Playwright suggests the best locator for it, ranked by the official hierarchy. This is the fastest way to identify correct locators without writing trial-and-error code.

Anti-Patterns to Avoid

These locator mistakes cause the majority of flaky test failures. Eliminate them from your codebase:

Fragile CSS chains
page.locator('div.app > main > section:nth-child(2) > form > div.row > input')

Breaks when any wrapper element changes. Every selector segment is a point of failure.

Semantic locator
page.getByLabel('Email address')

Survives any structural refactor. Only breaks if the label itself changes.

Text that changes
page.getByText('3 items in your cart')

The number changes every test run. Use a regex pattern instead.

Regex pattern
page.getByText(/\d+ items? in your cart/)

Matches regardless of item count while still validating the format.

Non-unique matches
page.getByRole('button').click()

Strict mode throws because multiple buttons match. Never rely on Playwright picking the "right" one.

Unique by name
page.getByRole('button', { name: 'Save Draft' }).click()

The name option uniquely identifies the target button.

More anti-patterns to watch for:

  • Auto-generated IDs — IDs like #input-7f3a2b change every build. Never use them as locators.
  • XPath with absolute paths/html/body/div[1]/div[3]/form/input breaks on any DOM change. Use relative XPath at minimum.
  • Mixing locator strategies — Picking CSS here, getByRole there, XPath elsewhere makes the suite inconsistent and hard to maintain. Standardize on semantic locators.
  • data-testid on everything — Overusing test IDs pollutes production HTML and misses accessibility issues. Reserve for elements without semantic roles.

AI-Powered Locator Generation with Claude

Choosing the right locator for every element in a complex application is time-consuming. This is where Claude AI with the Playwright MCP Server transforms your workflow.

Instead of manually inspecting the DOM and writing locators, you describe what you want to test in plain English. Claude analyzes the live page through the MCP Server, inspects the DOM structure, evaluates ARIA roles and labels, and generates the optimal locator following the hierarchy in this guide.

claude ai prompt
// You tell Claude:
"Write a test that fills in the login form and submits it"

// Claude inspects the live DOM and generates:
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('securePass123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

Claude automatically chooses getByLabel for form fields (because labels exist), getByRole for the button (because it has a semantic role), and web-first assertions for verification. No manual DOM inspection required.

This AI-powered approach also enables self-healing locators — when a UI change breaks a locator, Claude can analyze the updated DOM and suggest the new optimal locator automatically.


Frequently Asked Questions

What is the best Playwright locator strategy in 2026?

Follow the hierarchy: getByRole first (it mirrors how users and assistive technology see the page), then getByLabel for form fields, getByText for visible text, getByTestId as a reliable fallback, and CSS/XPath only for legacy code or complex DOM traversal. This order maximizes test resilience and accessibility coverage.

What is the difference between getByRole and getByTestId?

getByRole locates elements by their ARIA role and accessible name (button, textbox, heading), reflecting how real users perceive the UI. getByTestId locates by a data-testid attribute added specifically for testing. Prefer getByRole because it validates accessibility and survives redesigns. Use getByTestId only for elements without meaningful ARIA roles.

How do I chain locators in Playwright?

Call .locator() or .getByRole() on an existing locator to scope the search: page.getByTestId('card').getByRole('button', { name: 'Delete' }). Use .filter({ hasText: '...' }) to narrow results, and .nth(), .first(), .last() to pick a specific match from multiple results.

Can Playwright use XPath selectors?

Yes. Use page.locator('xpath=//div[@class="container"]'). However, XPath is tightly coupled to DOM structure and breaks easily when markup changes. Reserve it for legacy applications where you cannot add test IDs and the DOM lacks semantic HTML.

How does Playwright auto-waiting work with locators?

Every Playwright action (click, fill, check) automatically waits for the locator's target element to become actionable — visible, stable, enabled, and not obscured. Web-first assertions (toBeVisible, toHaveText) auto-retry until the condition passes or the timeout expires. The default timeout is 30 seconds, configurable per action or globally in playwright.config.ts.

What is the difference between locator.and() and locator.filter()?

locator.and(another) returns elements matching both locators — it is a set intersection. locator.filter({ hasText, has }) narrows a locator by text content or child elements. Use and() when you have two independent locator strategies to combine. Use filter() when you want to narrow by content or structure within an already-scoped locator.

How do I use the Playwright Inspector to find locators?

Run npx playwright open --inspect https://yourapp.com to open your app with the Playwright Inspector attached. Click any element in the browser and the Inspector shows the recommended locator, ranked by the official hierarchy. You can copy it directly into your test. For existing tests, add await page.pause() at any point and the Inspector activates in the browser during test execution.

Can I use locator.or() for A/B testing scenarios?

Yes — this is the primary use case for locator.or(). When your application shows different UI variants to different users (A/B tests, feature flags, locale variations), or() lets a single test handle both variants: page.getByRole('button', { name: 'Sign Up' }).or(page.getByRole('button', { name: 'Get Started' })). The locator matches whichever button is present and clicks it without conditional logic in your test code.


Quick Reference: Locator Decision Guide

Use this cheat sheet when you're unsure which locator to reach for:

Locator Selection Checklist

  • Element has a semantic role (button, link, heading) → getByRole
  • Form input with a label → getByLabel
  • Unique, stable visible text → getByText
  • No role, no label, no text → getByTestId
  • Input with placeholder only → getByPlaceholder
  • Image element → getByAltText
  • Legacy DOM, no control over markup → CSS / XPath
  • Multiple matches → .filter() or .nth()

Mastering locators is one of the highest-leverage skills in Playwright automation. Every test you write starts with finding an element — get the locator right and the rest of the test practically writes itself. For a deeper dive into structuring your entire test framework, check out our Playwright best practices guide and how to use Playwright tutorial.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy Instructor Published course author
Playwright + AI Expert Specialized in AI-powered QA
Production Experience Enterprise-grade frameworks
Connect on LinkedIn