Playwright Testing

Playwright Selector Strategy Guide: From CSS to Role-Based Selectors and Page Objects

Selectors are the foundation of every end-to-end test. Choose poorly and your test suite becomes a maintenance burden that breaks on every UI change. Choose well and your tests stay green through redesigns, refactors, and feature additions. This guide covers the full spectrum of selector strategies in Playwright, from brittle CSS paths to resilient role-based locators.

3x

Selector-related failures account for the majority of flaky test investigations, with teams spending up to 3x more time fixing locators than writing new test logic.

Web Testing Industry Survey, 2025

1. The Selector Hierarchy: From Brittle to Resilient

Not all selectors are created equal. At the bottom of the reliability spectrum sit selectors that depend on DOM structure: things like div > ul > li:nth-child(3) > a. These break the moment a developer wraps an element in a new container, reorders a list, or introduces a layout component. They encode implementation details that have nothing to do with what the user sees or does.

One step up, CSS class selectors like .btn-primary offer slightly more stability, but they still couple tests to styling decisions. Renaming a class during a design system migration breaks every test that referenced it. Class names describe how something looks, not what it does, which makes them a poor foundation for tests that should verify behavior.

The most resilient selectors target what the user actually interacts with: visible text, accessible roles, and labels. Playwright's getByRole, getByLabel, and getByText locators are designed around this principle. A selector like getByRole('button', { name: 'Submit order' }) will work regardless of whether the button is implemented as a <button>, a styled <div> with an ARIA role, or a component from a third-party library. It targets the user's mental model, not the developer's implementation.

Between these extremes, data-testid attributes occupy a practical middle ground. They are stable because they exist solely for testing and are unlikely to change during normal development. But they are invisible to users and accessibility tools, which means they do not verify that the element is actually accessible or visible. A data-testid on a hidden element will still match, which can mask real usability bugs.

2. data-testid vs. Role-Based Selectors

The debate between data-testidand role-based selectors often generates strong opinions, but the practical answer depends on your application's accessibility maturity. If your app already has proper ARIA labels, semantic HTML, and accessible names on interactive elements, role-based selectors are the clear winner. They test what the user experiences and they double as accessibility audits. Every time a role-based selector fails, it either means the test is wrong or the element is no longer accessible, both of which are worth investigating.

In applications with poor accessibility markup, role-based selectors can be frustrating because they surface accessibility gaps rather than test failures. A button without an accessible name cannot be targeted by getByRole, which forces the team to either fix the accessibility issue or fall back todata-testid. The pragmatic approach is to use role-based selectors as the default and reserve data-testid for elements where adding proper accessibility markup is not feasible in the current sprint.

One pattern that works well is a tiered selector strategy. Prefer getByRole for buttons, links, and form elements. Use getByLabel for form inputs. Use getByText for static content and headings. Use data-testid only for complex composite components where no single element has a meaningful accessible name, such as a chart, a map widget, or a canvas-based visualization. Document this hierarchy in your testing conventions so that code reviewers can enforce it consistently.

Generate resilient selectors automatically

Assrt discovers your UI and generates Playwright tests with role-based selectors. Open-source, no signup required.

Get Started

3. Page Object Model: Benefits and Anti-Patterns

The Page Object Model (POM) is the most widely recommended pattern for organizing Playwright tests, and for good reason. By encapsulating page interactions in dedicated classes, POM reduces duplication, centralizes selector definitions, and makes tests read like user stories rather than DOM manipulation scripts. When a UI element changes, you update the selector in one place instead of across dozens of test files.

However, POM has well-documented anti-patterns that teams frequently fall into. The most common is the "god object" page class that contains every interaction for an entire page. A CheckoutPage class with 40 methods covering the cart, shipping form, payment, and confirmation sections becomes just as hard to maintain as scattered selectors. The fix is to decompose page objects by user-facing sections or components, not by URL. A ShippingForm component object and a PaymentForm component object are more maintainable than a monolithic CheckoutPage.

Another anti-pattern is putting assertions inside page objects. Page objects should expose actions and state queries, not verification logic. When a page object method both performs an action and asserts the outcome, tests lose clarity about what is being verified. Keep assertions in the test file where they communicate intent clearly.

A third anti-pattern is inheritance hierarchies in page objects. Teams create BasePage classes with shared navigation methods, then extend them for every page. This creates tight coupling between unrelated pages and makes refactoring painful. Composition (mixing in reusable component objects) is almost always a better approach than inheritance for page objects.

4. Splitting Selectors by User Intent

The most durable selector strategy organizes locators around what the user is trying to accomplish, not around the page structure. Consider a search feature: the user wants to type a query, submit it, and see results. The selectors should target those three interactions regardless of how the search UI is implemented. If the search bar moves from the header to a sidebar, or if the submit button becomes an auto-search on keypress, intent-based selectors survive these changes because they track the user's goal, not the current layout.

In Playwright, this translates to locator chains that mirror user actions. Instead of page.locator('#search-input'), use page.getByRole('searchbox', { name: 'Search products' }). Instead of page.locator('.results-container .item:first-child'), use page.getByRole('listitem').first(). These selectors communicate what the test expects the user to see, which makes test failures more diagnostic. When a role-based selector fails, the error message tells you that the expected element with a specific role and name was not found, which is immediately actionable.

Tools like Assrt apply this principle automatically. When Assrt discovers test scenarios by crawling a running application, it identifies interactive elements by their accessible roles and visible labels, generating Playwright tests that use role-based selectors by default. The generated test files are standard Playwright code that you can inspect, modify, and run in any CI pipeline. This can be a useful starting point for teams transitioning from CSS selectors to intent-based locators, since the generated code demonstrates the pattern across real pages.

5. Selector Maintenance at Scale

As test suites grow beyond a few hundred tests, selector maintenance becomes a significant cost center. Teams report spending 30% or more of their testing time updating selectors after UI changes, especially when those selectors were written against implementation details rather than user intent. The compounding effect is real: each new test that uses a brittle selector adds future maintenance work that grows linearly with the number of UI changes.

Several practices reduce this burden. First, enforce a selector linting rule in your CI pipeline. Playwright's ESLint plugin can warn when tests use raw CSS selectors instead of built-in locator methods. Second, track selector stability as a metric: when a test fails, log whether the failure was a selector issue or a genuine application bug. Over time, this data reveals which pages and components generate the most selector churn, letting you prioritize accessibility improvements where they will have the most impact on test stability.

Third, consider self-healing selectors as a complement to manual maintenance. Several tools in the testing ecosystem now offer automatic selector recovery, where the test framework tries alternative selectors when the primary one fails. Assrt, for example, includes self-healing selectors that use multiple attributes (role, text, proximity to other elements) to relocate elements after UI changes. This does not eliminate the need for good selector hygiene, but it reduces the frequency of false failures during active development when the UI is changing rapidly.

Finally, invest in selector documentation. Maintain a living document or wiki page that maps your selector strategy to specific component types. When a new developer joins the team or a contractor writes tests, this reference prevents them from introducing brittle selectors that undermine the stability you have built. The best test suites are the ones where every selector choice is intentional, documented, and aligned with how users actually interact with the product.

Stop fixing selectors manually

Assrt generates Playwright tests with resilient, role-based selectors and self-healing locators that adapt to UI changes.

$npm install @assrt/sdk