A Guide to Playwright Locators: From `getByRole` to CSS and XPath

July 18, 2025

In the world of end-to-end testing, flakiness is the silent killer of productivity and confidence. A test suite that passes one moment and fails the next due to a minor UI change is worse than no test suite at all. At the heart of this instability often lies a poor element selection strategy. This is precisely the problem Playwright addresses with its modern, robust approach to element interaction through Playwright locators. Unlike traditional methods that simply find an element at a single point in time, Playwright locators are dynamic, auto-waiting pointers to elements on a page. They represent a fundamental shift in how we write automated tests, moving away from brittle implementation details and towards resilient, user-centric interactions. This comprehensive guide will explore the entire spectrum of Playwright locators, from the highly recommended user-facing selectors like getByRole to the necessary fallbacks of CSS and XPath, empowering you to build a test automation suite that is not just functional, but truly reliable.

The Core Philosophy: What Makes Playwright Locators Different?

Before diving into the specific types of Playwright locators, it's crucial to understand the philosophy that sets them apart. If you're coming from other frameworks like Selenium or Cypress (pre-v10), you might be used to functions like findElement or cy.get(), which immediately query the DOM and either return an element or throw an error. Playwright fundamentally changes this paradigm.

A Playwright locator is not the element itself; it's an object that represents a query for an element. This distinction is the source of its power. The two cornerstone features that arise from this design are auto-waiting and strictness.

Auto-Waiting: The End of Manual Waits

One of the most common sources of test flakiness is timing. Modern web applications are dynamic; elements are rendered, hydrated, and enabled asynchronously. A traditional test script might try to click a button before it's actually interactive, leading to a failure. Developers often solve this with explicit waits (sleep, waitForElementVisible), which litter the codebase and either slow down tests unnecessarily or are not long enough to prevent race conditions.

Playwright's auto-waiting mechanism solves this elegantly. When you perform an action on a locator, like locator.click(), Playwright automatically performs a series of checks before executing the action. It waits for the element to:

  • Be attached to the DOM.
  • Be visible.
  • Be stable (i.e., not animating).
  • Be enabled and able to receive events.

This is done automatically for every action, with a configurable timeout. This means your test code becomes cleaner and more representative of user intent. You simply state, "click the submit button," and Playwright handles the necessary waiting to ensure the button is ready. The impact on CI/CD pipeline stability is significant, as research from DORA highlights that reliable automated testing is a key capability for elite-performing teams.

Strictness: Preventing Ambiguous Tests

Strictness is another critical feature. By default, if a locator resolves to more than one element when you try to perform an action, Playwright will throw an error. This is a deliberate design choice to prevent ambiguous and potentially erroneous tests. For instance, if your locator is .btn and there are three such buttons on the page, which one should be clicked? A test that randomly clicks one of them is not a reliable test.

// Throws an error if more than one button exists on the page
await page.getByRole('button', { name: 'Sign Up' }).click();

This strict-by-default behavior forces you to write more specific and unambiguous Playwright locators. While you can opt-out by using methods like first(), last(), or nth(), the default encourages best practices. This philosophy aligns with the principle that a test should be a precise specification of behavior. According to a Stack Overflow analysis on software engineering, clarity and precision are hallmarks of high-quality code, a principle that extends directly to test automation code. By enforcing specificity, Playwright helps ensure your tests are both clear and correct.

The Gold Standard: User-Facing Locators

Playwright's official documentation strongly recommends a specific hierarchy for choosing locators, prioritizing those that are most resilient to change. At the top of this hierarchy are user-facing locators. These selectors find elements the way a user would, based on what they see and interact with on the screen, such as text, labels, and accessibility roles. This approach decouples tests from the underlying DOM structure, making them far more robust against code refactoring.

Basing tests on implementation details like CSS classes or complex XPath is fragile. A developer might change a div to a section or refactor CSS class names (.btn-primary to .button-main), which would break the test without altering the user's experience. User-facing locators are immune to such changes. As usability experts at the Nielsen Norman Group emphasize, designing for accessibility and user experience often leads to more robust and maintainable systems, a principle that holds true for test automation.

page.getByRole()

This is the most powerful and recommended locator. It finds elements based on their ARIA role, which defines the element's purpose. This aligns your tests directly with accessibility best practices. Screen readers and other assistive technologies use these roles to understand the page, and so can your tests.

// Find a button by its accessible name
await page.getByRole('button', { name: 'Login' }).click();

// Find a heading of a specific level
const mainHeading = page.getByRole('heading', { level: 1 });
await expect(mainHeading).toHaveText('Welcome to our App');

// Find a checkbox
await page.getByRole('checkbox', { name: 'I agree to the terms' }).check();

Using getByRole makes your tests more readable and resilient. The accessible name can be derived from the element's content, an aria-label, or an associated <label> tag. For a complete list of roles, you can refer to the W3C ARIA specifications.

page.getByText()

This locator finds an element based on its visible text content. It's incredibly useful for locating non-interactive elements like paragraphs or for targeting links and buttons when their role is not a primary concern.

// Find an element containing the text "Product Details"
const productDetails = page.getByText('Product Details');

// Use a regular expression for partial or case-insensitive matching
await page.getByText(/view cart/i).click();

// Be careful with exact matching, as it can be brittle
await page.getByText('Welcome, user!', { exact: true }); // Fails for "Welcome, user! "

While powerful, be mindful of exact: true. Minor changes in whitespace or punctuation can break an exact match. Using regular expressions or substring matches is often more resilient.

page.getByLabel()

This is the ideal locator for form inputs. It finds an <input> element associated with a <label>. This perfectly mimics how a user identifies a form field.

<label for="user-email">Email Address</label>
<input id="user-email" type="email" />
// The locator finds the input associated with the label text
await page.getByLabel('Email Address').fill('[email protected]');

This is far superior to a CSS selector like #user-email, as it doesn't rely on a specific id attribute that might change.

Other User-Facing Locators

Playwright provides a few other highly useful, self-descriptive locators:

  • page.getByPlaceholder(): Finds an input by its placeholder text.
    await page.getByPlaceholder('Enter your name...').fill('John Doe');
  • page.getByAltText(): Finds an image by its alt text. This is great for asserting that the correct images are displayed and for interacting with image-based links.
    await page.getByAltText('Company Logo').click();
  • page.getByTitle(): Finds an element by its title attribute (the text that appears on hover).
    await expect(page.getByTitle('Close this dialog')).toBeVisible();

By consistently prioritizing this suite of user-facing Playwright locators, you build a foundation for a test suite that is descriptive, maintainable, and closely aligned with both user behavior and accessibility standards. This investment pays dividends in reduced maintenance and increased confidence in your test results, a key finding in many Forrester reports on modern application development.

The Fallbacks: CSS, XPath, and Test IDs

While user-facing locators should be your first choice, there are situations where they are not practical or possible. For these cases, Playwright provides powerful, albeit more brittle, alternatives: data-testid attributes, CSS selectors, and XPath expressions.

The Pragmatic Escape Hatch: getByTestId()

Sometimes, an element is difficult to select using user-facing attributes. It might not have a clear role, text, or label. For instance, a container <div> used for layout or a close button represented only by an 'X' icon font. In these scenarios, the best practice is to ask developers to add a dedicated test attribute to the element.

The convention is data-testid.

<div data-testid="shopping-cart-container">
  <!-- ... cart items ... -->
</div>

Playwright has a first-class locator for this: page.getByTestId().

const cartContainer = page.getByTestId('shopping-cart-container');
await expect(cartContainer).toBeVisible();

This approach is highly recommended as a fallback because it creates a contract between the test suite and the application code. It's a selector that is explicitly for testing, so developers are less likely to change it accidentally. This concept is championed by testing experts like Kent C. Dodds, who argues it's one of the best ways to create resilient UI tests. You can configure the attribute Playwright looks for if your team uses a different convention (e.g., data-qa).

CSS Locators: A Familiar Tool

CSS selectors are a familiar tool for any web developer and can be used in Playwright when other options are exhausted. They are good for selecting elements based on IDs, classes, or specific attribute values.

Playwright's generic page.locator() method can take a CSS selector string.

// Select by ID (can be brittle if IDs are dynamic)
await page.locator('#session-id').click();

// Select by class (can be brittle if classes are for styling)
await page.locator('.btn-submit').click();

// A better use of CSS: selecting by a non-standard data attribute
await page.locator('[data-cy="submit-button"]').click();

The main danger with CSS locators is tying tests to styling. A class like .red-text or .pull-right is purely presentational and likely to change. If you must use CSS, prefer structurally significant classes or, even better, data-* attributes. The MDN Web Docs on CSS Selectors provide a comprehensive reference for their syntax.

XPath Locators: The Last Resort

XPath (XML Path Language) is the most powerful and most brittle locator strategy. It allows you to traverse the entire DOM tree with complex expressions, selecting elements based on their position, parents, siblings, or ancestors. This power comes at a high cost of readability and maintainability.

Use XPath only when there is absolutely no other way to uniquely identify an element. A common, albeit fragile, use case is selecting an element based on its position relative to another.

// Find the parent div of a paragraph containing specific text
const parentDiv = page.locator('xpath=//p[contains(text(), "Unique Text")]/..');

// Find a button within the third list item (very brittle!)
const thirdButton = page.locator('xpath=(//li/button)[3]');

An XPath like //div/div[2]/span/a[3] is a recipe for a flaky test. A minor change to the page's structure will break it instantly. If you find yourself writing complex XPath, it's a strong signal that the application needs better testability hooks, like a data-testid. For syntax help, the MDN documentation on XPath is an invaluable resource. Think of XPath as a surgical tool for emergencies, not a hammer for everyday use.

Advanced Techniques: Chaining, Filtering, and Combining Locators

Once you've mastered the basic Playwright locators, you can unlock even more power and precision by combining them. Playwright's API is designed to be composable, allowing you to chain and filter locators to pinpoint the exact element you need with confidence and clarity.

Chaining Locators for Scoped Searches

Chaining is the most common and intuitive advanced technique. You can call a locator method on another locator object to narrow down the search scope. This is extremely useful for finding an element within a specific container, such as a card, form, or table row.

Imagine a page with multiple product cards, each with its own "Add to Cart" button. Selecting by role alone would be ambiguous.

// Ambiguous: which "Add to Cart" button?
await page.getByRole('button', { name: 'Add to Cart' }).click();

Chaining provides a clear and robust solution:

// 1. Locate the specific product card by its heading
const productCard = page.getByRole('article', { name: 'Super Widget' });

// 2. Within that card, locate the "Add to Cart" button
await productCard.getByRole('button', { name: 'Add to Cart' }).click();

This code is not only functional but also highly readable. It clearly states the user's action: "In the 'Super Widget' article, click the 'Add to Cart' button." This pattern is fundamental to writing maintainable tests for component-based applications, a topic often discussed in discussions on the official Playwright GitHub repository.

Filtering Locators with .filter()

The .filter() method allows you to refine a set of locators that match a certain criteria. This is particularly useful when dealing with lists or tables where you need to select an item based on some of its content. The two primary filter options are hasText and has.

  • filter({ hasText: '...' }): Narrows down the locator to only include elements that contain a specific piece of text.
// Find the list item that contains the text "Status: Shipped"
const shippedItem = page.getByRole('listitem').filter({ hasText: 'Status: Shipped' });

// Now interact with an element within that specific list item
await shippedItem.getByRole('button', { name: 'View Details' }).click();
  • filter({ has: locator }): This is even more powerful. It narrows down the locator to only include elements that contain another locator inside them.
// Find the table row that contains a cell with the text "[email protected]"
const userRow = page.getByRole('row').filter({ 
  has: page.getByRole('cell', { name: '[email protected]' })
});

// Now, on that specific row, click the "Edit" button
await userRow.getByRole('button', { name: 'Edit' }).click();

This has filter is a game-changer for interacting with complex data grids and tables, a common challenge in enterprise application testing.

Combining Locators with .or() and .and()

For truly complex scenarios, you can combine two locators with logical operators.

  • .or(): Creates a locator that matches elements satisfying either of the two locators. This is useful when an element might appear in one of two ways, perhaps during an A/B test or a UI migration.
// Find a button that is either named "Submit" or "Save"
const submitButton = page.getByRole('button', { name: 'Submit' })
                         .or(page.getByRole('button', { name: 'Save' }));

await submitButton.click();
  • .and(): Creates a locator that matches elements satisfying both locators. This can be used to add more constraints to a selector.
// Find an element that is BOTH a link AND has the class "primary-action"
const primaryLink = page.getByRole('link').and(page.locator('.primary-action'));

These advanced combinations, as detailed in the official Playwright documentation on advanced locators, provide the tools to handle almost any selection challenge you might encounter, all while maintaining the core principles of auto-waiting and strictness.

Playwright Locators: A Summary of Best Practices

Mastering Playwright locators is less about memorizing syntax and more about adopting a strategic mindset. To build a truly resilient and low-maintenance test suite, consistently applying a set of best practices is key. These principles will guide you toward writing tests that are readable, robust, and valuable.

1. Follow the Locator Priority Hierarchy

Always try to select elements in this order. This hierarchy is optimized for resilience, prioritizing user-facing attributes over implementation details.

  1. getByRole: The best choice. Aligns with accessibility and user intent.
  2. getByText: Excellent for finding elements by their content.
  3. getByLabel: The perfect tool for form inputs.
  4. getByPlaceholder, getByAltText, getByTitle: For specific, user-visible attributes.
  5. getByTestId: The best fallback. Creates a stable testing contract.
  6. CSS Selector: Use for structural or attribute-based selection when necessary. Avoid styling classes.
  7. XPath: The last resort for complex DOM traversal.

2. Collaborate with Developers for Testability

Don't struggle in isolation with a hard-to-test UI. The most effective approach is to work with your development team to make the application more testable. Advocate for adding data-testid attributes to elements that are otherwise difficult to locate. This "shift-left" approach to quality, where testing considerations are integrated early in the development process, is proven to reduce costs and improve outcomes, a finding supported by McKinsey research on developer velocity.

3. Leverage Playwright's Tooling

Playwright provides excellent tools to help you find the best locators. You don't have to guess.

  • Codegen: Run npx playwright codegen <your-url> to launch a browser where you can interact with your site. Playwright will record your actions and automatically generate the code, often suggesting the best user-facing locator for each interaction.
    $ npx playwright codegen https://myapp.com
  • Playwright Inspector: When a test is running in debug mode (--debug), the Playwright Inspector opens. You can use its "Pick Locator" feature to hover over elements on the page and see the recommended locator in real-time. This is an invaluable tool for debugging and writing new tests. As noted in reports on Playwright's features, these developer experience tools are a major reason for its growing adoption.

4. Write Tests from the User's Perspective

Before you write a single line of code, ask yourself: "How would a user find and interact with this element?" Would they look for a button with the text "Submit"? Would they find an input field by its label "Email Address"? This mental model will naturally guide you toward user-facing locators. A test that reads like a user story (Find the 'Login' button and click it) is infinitely more maintainable than one that reads like a DOM traversal instruction (Find the third div inside the header and click its child anchor tag). This user-centric approach ensures your tests are verifying actual user journeys, not just the integrity of the DOM structure.

The journey from flaky, implementation-dependent tests to a robust, maintainable automation suite begins with a single, crucial step: mastering your selection strategy. Playwright locators provide a powerful, modern framework for doing just that. By embracing the philosophy of auto-waiting and strictness, and by religiously prioritizing user-facing locators like getByRole, you fundamentally shift the nature of your tests. They cease to be fragile checks of the DOM structure and become resilient specifications of user behavior. While CSS and XPath remain necessary tools in your arsenal, they should be treated as the fallbacks they are, reserved for when a stable, user-centric alternative isn't available. By adopting the hierarchy and best practices outlined in this guide, you will not only write better Playwright testsβ€”you will build more confidence in your product, accelerate your development lifecycle, and contribute to a higher standard of quality.

What today's top teams are saying about Momentic:

"Momentic makes it 3x faster for our team to write and maintain end to end tests."

- Alex, CTO, GPTZero

"Works for us in prod, super great UX, and incredible velocity and delivery."

- Aditya, CTO, Best Parents

"…it was done running in 14 min, without me needing to do a thing during that time."

- Mike, Eng Manager, Runway

Increase velocity with reliable AI testing.

Run stable, dev-owned tests on every push. No QA bottlenecks.

Ship it

FAQs

Momentic tests are much more reliable than Playwright or Cypress tests because they are not affected by changes in the DOM.

Our customers often build their first tests within five minutes. It's very easy to build tests using the low-code editor. You can also record your actions and turn them into a fully working automated test.

Not even a little bit. As long as you can clearly describe what you want to test, Momentic can get it done.

Yes. You can use Momentic's CLI to run tests anywhere. We support any CI provider that can run Node.js.

Mobile and desktop support is on our roadmap, but we don't have a specific release date yet.

We currently support Chromium and Chrome browsers for tests. Safari and Firefox support is on our roadmap, but we don't have a specific release date yet.

Β© 2025 Momentic, Inc.
All rights reserved.