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:

  1. getByRole() with accessible name
  2. getByLabel() for form fields
  3. getByPlaceholder() only when placeholder text is intentional and stable
  4. getByText() for visible text, usually for content assertions or links
  5. data-testid or another dedicated test attribute
  6. CSS selectors
  7. 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();

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();

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.

  1. Inspect whether the element still exists
  2. Check whether the accessible name changed
  3. Verify whether the locator matches multiple elements
  4. Confirm that the element is visible and enabled at the time of action
  5. 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-testid for 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.