July 20, 2026
Playwright Selector Cheatsheet
A practical Playwright selector cheatsheet with locator priority, CSS and XPath examples, getByRole guidance, and stability tips for teams maintaining reliable UI tests.
Playwright selectors are one of the first places a test suite becomes either maintainable or fragile. The API surface looks simple, but selector choice determines whether your tests encode user intent or implementation detail. A stable locator survives refactors, layout changes, and minor UI redesigns. A brittle one turns every DOM tweak into a maintenance ticket.
This cheatsheet focuses on how to choose selectors in Playwright, not just how to type them. The most useful rule is also the simplest: prefer locators that reflect what a user can perceive, then fall back to structural selectors only when necessary.
If a selector reads like a CSS implementation detail, ask whether the test is asserting behavior or chasing the DOM.
For reference, Playwright documents its locator model in the official docs on Playwright. The framework gives you several ways to find elements, but not all of them are equally resilient.
Selector priority, from most stable to most brittle
Use this ordering as a default decision guide:
getByRole()with accessible namegetByLabel()for form fieldsgetByPlaceholder()only when placeholder text is intentional and stablegetByText()for visible text, usually for content assertions or linksdata-testidor another dedicated test attribute- CSS selectors
- XPath, only when no better option exists
This is not a universal law. It is a practical hierarchy for most teams. The tradeoff is simple: the higher you go, the more your tests reflect the product from a user perspective. The lower you go, the more they depend on DOM structure, classes, and rendering details.
Why Playwright locators are different from raw selectors
Playwright encourages locators instead of one-off element queries because locators are strict, composable, and re-evaluated during actionability checks. That matters for dynamic UIs, where an element can appear, disappear, or move between the time it is located and the time an action runs.
A locator in Playwright is not just a selector string. It is a query that can be chained, narrowed, filtered, and waited on through the framework. That means the same test can be both readable and robust if you choose the right entry point.
Example: a readable locator chain
typescript
await page
.getByRole('dialog', { name: 'Create project' })
.getByRole('button', { name: 'Save' })
.click();
This says what the test is doing in human terms. It also avoids brittle assumptions about class names or nested divs.
getByRole() is usually the best starting point
getByRole() works well because it aligns with the accessibility tree rather than the DOM tree. If your app exposes correct roles and names, you usually get a selector that is both stable and semantically meaningful.
Typical examples:
typescript
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
When getByRole() is a good fit
- Buttons, links, inputs, checkboxes, radios, dialogs, tabs, menus
- Any control with a stable accessible name
- Apps with decent accessibility coverage
- Suites where product and test teams want selectors tied to user-visible behavior
Common failure modes
- A button has an empty accessible name because the icon-only control lacks
aria-label - Multiple elements share the same accessible name and role
- The name changes with localization, feature flags, or content from external systems
If localization is part of your product, be careful. A selector that uses visible text in English may fail when the test runs against another locale. In that case, data-testid can be a more stable contract for tests that are not validating copy.
getByLabel() for forms is often cleaner than CSS
For form automation, getByLabel() is frequently the best choice because it matches the way users identify fields. It also tends to survive structural refactors better than a CSS path from wrapper divs.
typescript
await page.getByLabel('First name').fill('Ada');
await page.getByLabel('Country').selectOption('GB');
This is usually preferable to selecting input[name="firstName"] if the label is visible and stable. The name attribute can be fine too, but it is still an implementation detail in many systems. If the label exists, use it first.
getByText() is useful, but easy to overuse
Text selectors read naturally, but they are broader than many teams expect. A string can appear in multiple places, hidden nodes can interfere, and layout changes can move text without changing behavior.
typescript
await page.getByText('Continue').click();
This can work, but it is often too vague for production suites. Prefer the more specific form when possible:
typescript
await page.getByRole('button', { name: 'Continue' }).click();
Use getByText() more often for assertions than for actions, especially when validating rendered copy or status messages.
data-testid is the right compromise for many teams
A dedicated test attribute is often the best fallback when the accessible name is unstable or when you want to select an element without coupling the test to content or layout.
```html
<button data-testid="save-project">Save</button>
typescript
```typescript
await page.getByTestId('save-project').click();
Why teams use it
- It is explicit
- It avoids accidental coupling to CSS classes
- It can remain stable during styling changes
- It is easy to grep across the codebase
Why teams should not overuse it
- It can become a substitute for accessible markup
- It may hide the fact that the UI lacks proper roles and names
- It can encourage tests that bypass user-facing semantics
A good practice is to use data-testid for complex widgets, repeated rows, dynamic containers, or elements that are otherwise hard to distinguish. Do not use it everywhere by default if a role-based locator is already clear.
CSS selectors: useful, fast, and sometimes too coupled
CSS selectors are familiar and concise. They are often the right choice for structural narrowing or for elements that do not expose a strong accessibility contract.
typescript
await page.locator('form#signup button[type="submit"]').click();
await page.locator('.toast.toast-success').expect().toBeVisible();
Good uses for CSS selectors
- Selecting by stable IDs
- Narrowing within a known region
- Targeting non-interactive elements for assertions
- Working with legacy markup that lacks roles or labels
Weak points
- Class names can be generated, hashed, or changed by a design system
- Deep descendant chains are brittle
- CSS often reflects styling, not behavior
Avoid selectors like these unless there is no better option:
page.locator('div.container > div.row > div:nth-child(3) > button')
That kind of selector is fragile because it encodes layout structure rather than intent. If the third column becomes the second, the test fails without the user-facing behavior changing.
XPath: valid, but usually a last resort
XPath still has legitimate uses, especially when you need ancestor traversal or text-relative selection that CSS cannot express cleanly. But it is easy to write XPath that is difficult to read and maintain.
typescript
await page.locator('//label[text()="Email"]/following::input[1]').fill('user@example.com');
This can be acceptable in a narrow case, but if your codebase has a lot of XPath, the suite may be carrying unnecessary complexity. XPath is more verbose, less familiar to many frontend engineers, and easier to make brittle with DOM changes.
Prefer XPath only when
- You need to walk up or across the DOM in a way CSS cannot handle
- You are working with legacy markup you cannot change
- A particular relationship is genuinely more stable than available attributes
Practical locator patterns you will reuse
Button by role and name
typescript
await page.getByRole('button', { name: 'Delete account' }).click();
Link by role and name
typescript
await page.getByRole('link', { name: 'Billing settings' }).click();
Form field by label
typescript
await page.getByLabel('Password').fill('correct horse battery staple');
Section-scoped selection
typescript
const billing = page.getByRole('region', { name: 'Billing' });
await billing.getByRole('button', { name: 'Update card' }).click();
Table row targeting
typescript
const row = page.getByRole('row', { name: /Acme Corp/ });
await row.getByRole('button', { name: 'Edit' }).click();
Filtered locator
typescript
await page.getByRole('listitem').filter({ hasText: 'Inactive' }).click();
These patterns keep the selector close to the user model. They also reduce the temptation to traverse deeply into the DOM.
How to think about selector stability
A stable selector has three properties:
- It survives visual redesigns
- It survives DOM refactoring that does not change user behavior
- It is unambiguous enough that the test interacts with the intended element
The tradeoff is that no selector is perfectly stable in all conditions. Even a good locator can become flaky if the application violates accessibility conventions, renders duplicate names, or changes text as part of localization or A/B testing.
Selector quality is not only a test issue. It is often a signal about application quality, especially accessibility and UI contract clarity.
What to choose in common scenarios
Login form
Use labels and roles.
typescript
await page.getByLabel('Email').fill('team@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
Modal dialog
Scope to the dialog, then select within it.
typescript
const dialog = page.getByRole('dialog', { name: 'Invite teammate' });
await dialog.getByLabel('Email').fill('dev@example.com');
await dialog.getByRole('button', { name: 'Send invite' }).click();
Dynamic list items
Use text plus a scoped container, or a test ID for each row if the UI is highly dynamic.
typescript
const item = page.getByTestId('project-row').filter({ hasText: 'Alpha' });
await item.getByRole('button', { name: 'Archive' }).click();
Icon-only button
Add an accessible name and select by role.
typescript
await page.getByRole('button', { name: 'Open menu' }).click();
If the button has no accessible name, that is a product bug and a testability problem.
Anti-patterns that make suites harder to maintain
1. Using CSS classes from styling frameworks
page.locator('.MuiButton-root.MuiButton-containedPrimary')
This is tied to a design system implementation. A style refactor may break it without changing behavior.
2. Deeply nested selectors
page.locator('main > div > section > div > button')
This usually means the test knows too much about page layout.
3. Index-based selection without context
page.locator('table tbody tr').nth(2)
If rows reorder, the test can click the wrong record.
4. Overusing text= for actions
Text is often too broad unless narrowed to a specific role or container.
5. Building selectors from fragile runtime strings
If a test derives a selector from changing copy or generated IDs, it is often better to find a stable attribute or test ID.
Selector debugging workflow
When a selector fails, avoid immediately rewriting the test. First determine why it failed.
- Inspect whether the element still exists
- Check whether the accessible name changed
- Verify whether the locator matches multiple elements
- Confirm that the element is visible and enabled at the time of action
- Decide whether the issue is selector quality or app behavior
Playwright’s locator assertions can help narrow the cause.
typescript
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
If the selector exists but the action still fails, the problem may be timing, overlays, navigation, or animation rather than locator syntax.
Selector strategy for teams, not just individuals
The best selector strategy is the one your whole team can apply consistently. That means agreeing on conventions:
- Prefer role and accessible name for interactive elements
- Use
data-testidfor durable test hooks when semantics are not enough - Avoid long CSS chains
- Treat XPath as exceptional
- Make accessibility a testability requirement, not an afterthought
Consistency matters because test code is shared infrastructure. If every engineer invents a different pattern, the suite becomes harder to review, debug, and refactor.
A useful team rule is this: if a selector is not obvious during code review, it probably needs to be simplified or replaced with a better hook.
Quick reference, selector choice by situation
| Situation | Preferred selector | Notes |
|---|---|---|
| Button, link, dialog, tab | getByRole() |
Best default when accessible names are stable |
| Form field | getByLabel() |
Usually clearer than CSS |
| Content assertion | getByText() |
Better for verifying visible text than for actions |
| Hidden implementation hook | data-testid |
Good fallback for dynamic or ambiguous UI |
| Legacy markup | CSS selector | Keep it shallow and intentional |
| DOM relationship that CSS cannot express | XPath | Use sparingly |
A compact Playwright selector cheatsheet
// Role-based
page.getByRole('button', { name: 'Save' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('dialog', { name: 'Settings' })
// Labels and text page.getByLabel(‘Password’) page.getByText(‘Welcome back’)
// Test IDs page.getByTestId(‘user-menu’)
// CSS page.locator(‘form#login’) page.locator(‘[aria-live=”polite”]’)
// XPath page.locator(‘//h2[text()=”Billing”]/following::button[1]’)
Final selection guidance
The most reliable Playwright selectors are the ones that line up with the product’s observable behavior. That usually means roles, labels, and stable test attributes, not CSS structure. If you need a rule of thumb, start with getByRole(), move to getByLabel(), and use data-testid when the UI lacks a durable semantic hook.
The practical goal is not to avoid every CSS or XPath selector. It is to minimize how much of your test suite depends on the shape of the DOM. That keeps failures meaningful and maintenance costs lower.
If your team is deciding between code-based automation and more managed approaches, selector syntax is only one part of the picture. Long-term reliability also depends on how much infrastructure, locator maintenance, and flaky-test triage your team wants to own. For teams that want less code-level upkeep, an agentic platform such as Endtest, an agentic AI [Test automation](https://en.wikipedia.org/wiki/Test_automation) platform, vs Playwright can be worth evaluating, especially if you are also interested in AI test creation that produces editable, human-readable steps rather than a growing pile of framework code.
That said, regardless of tool choice, the core lesson stays the same: the closer your selectors are to user intent, the less time you will spend repairing tests after routine UI change.