Q13 of 42 · Playwright

Compare locator strategies: getByRole vs getByTestId vs CSS selectors.

PlaywrightMidplaywrightlocatorsbest-practicesmid

Short answer

Short answer: `getByRole` matches by accessible name — most stable, double-duties as a11y testing. `getByTestId` is the explicit testing-attribute fallback when semantic markup isn't enough. CSS selectors couple tests to implementation and should be a last resort. Use them in that order of preference.

Detail

Playwright's recommended hierarchy is opinionated and worth understanding deeply.

Tier 1 — Semantic / accessible locators: getByRole(role, { name }), getByLabel(label), getByPlaceholder(text), getByText(text), getByTitle(title), getByAltText(alt). These match elements the way a user or screen reader would identify them.

Why prefer them: they survive class renames, CSS overhauls, and refactors that change DOM structure but preserve user-facing semantics. They also make a11y problems visible — if you can't getByRole('button', { name: 'Submit' }), neither can a screen-reader user.

Tier 2 — Test ID: getByTestId('submit-order') matches data-testid="submit-order" (configurable). Use when:

  • The element has no good accessible name (icon-only buttons without aria-label).
  • Generic markup like divs with no semantic role.
  • Disambiguating multiple elements with the same role/text.

Tier 3 — CSS selectors: page.locator('[data-test=submit]'), page.locator('.btn-primary > span'). Anything based on DOM structure, classes, or attributes. Brittle; couples tests to implementation. Use sparingly.

Tier 4 — XPath: page.locator('xpath=//button[text()="Submit"]'). Almost always avoidable with the higher tiers. The exception is querying based on text + ancestor relationships not expressible in CSS.

Other useful locator features:

  • .filter({ hasText: ... }) — narrow a set: page.getByRole('listitem').filter({ hasText: 'Apples' }).
  • .nth(N) — index into a set, when ordering is meaningful.
  • .getByRole(...).and(...) / .or(...) — combine locators.

The senior framing: the locator is a contract. getByRole expresses the user's contract; getByTestId expresses an explicit testing contract; page.locator('.btn-primary') expresses no contract at all.

// EXAMPLE

// ✅ Tier 1 — semantic
await page.getByRole('button', { name: 'Submit order' }).click();
await page.getByLabel('Email').fill('alice@x.com');
await page.getByText('Order confirmed').isVisible();

// ✅ Tier 2 — explicit test ID
await page.getByTestId('payment-iframe').click();

// ✅ Disambiguating with filter
await page.getByRole('listitem').filter({ hasText: 'Apples' }).click();

// ⚠️  Tier 3 — CSS, last resort
await page.locator('[data-test=summary] > .total').textContent();

// ❌ Tier 4 — XPath, almost never the answer
await page.locator('xpath=//div[@class="container"]/span[1]').click();

// WHAT INTERVIEWERS LOOK FOR

Naming the recommended hierarchy with reasoning (a11y forcing function, refactor resilience), and knowing `filter` / `nth` for disambiguation.

// COMMON PITFALL

Defaulting to `getByTestId` everywhere and skipping `getByRole`. Test IDs are fine, but they're a fallback; the semantic locators catch a11y bugs for free.