Dev Tools
Back to articles·7 min

Selector in depth: CSS vs XPath, stable selector patterns, why Playwright's getByRole wins

The painful part of E2E automation isn't writing steps — it's selectors that break every other day. Here are the patterns that worked across years of writing, debugging, and migrating Selenium / Cypress / Playwright suites, plus why Playwright pushes role-based locators.

CSS vs XPath: choose by use case

CSS wins when:

  • The element has clean class / id / attribute hooks
  • You want speed (30–50% faster than XPath in every browser engine)
  • Teammates already read CSS

XPath wins when:

  • You need to match by text content: //button[text()="Submit"] (CSS only got similar power with :has(), Safari 16+)
  • You need to walk to a parent: //input/../label (CSS literally cannot)
  • You have nested conditions: "div that has class A but no descendant with class B"

My rule of thumb: default to CSS, save XPath for the two parent-or-text cases.

Three principles for selectors that survive 6 months

1. Always prefer data-testid: decoupled from product visuals, immune to UI redesigns. Cypress, Playwright, Selenium all recognize them.

<button data-testid="submit-payment">Pay now</button>

Selector: [data-testid="submit-payment"] — survives even a swap to an icon button.

2. Never nth-child or nth-of-type: change the list order and the selector dies; even backend re-sorting breaks it.

3. Prefer semantic classes over styling classes: .btn-primary-rounded-lg-hover (Tailwind-style) changes constantly; .submit-button (semantic) is stable. Or just always go data-testid.

4. Don't trust IDs blindly: frameworks generate id="react-aria-:r1:"-style IDs that change on hydration. Use them when stable, fallback otherwise.

Why Playwright's getByRole is a game-changer

Traditional selectors say: "find the DOM at this position" — structure changes, selector breaks.

Playwright's getByRole says: "find an accessibility-tree node with role=button and an accessible name containing 'Submit'".

page.getByRole('button', { name: 'Submit' })

Benefits:

  • Independent of visual reordering: move the button left to right — the test still passes
  • Aligned with a11y: a broken test means a broken accessibility tree, so you catch UX bugs free
  • Cross-framework: roles are consistent across React / Vue / Angular

Nice side effect: it forces you to use <button> instead of <div onClick>, upgrading a11y across the codebase.

Timing in dynamic content

Worst antipattern: waitForTimeout(2000) — flaky on slow networks, wasteful on fast ones.

Correct:

  • Playwright: await expect(page.getByText('Success')).toBeVisible() — built-in auto-wait, polls for 5s by default
  • Selenium: WebDriverWait(driver, 10).until(EC.visibility_of_element_located(...))
  • Robot Framework: Wait Until Element Is Visible selector timeout=10s

Advanced — wait for the actual network response:

await page.waitForResponse(resp =>
  resp.url().includes('/api/payment') && resp.status() === 200
);
await expect(page.getByText('Payment success')).toBeVisible();

More precise than waiting for an element — sometimes the element renders from cache before the real API call completes.

Cross-browser gotchas

  • :has() selector: Safari 16+, Chrome 105+, Firefox 121+ only. Works in your dev env, breaks for an old Firefox user
  • text= Playwright pseudo-locator: Playwright-specific syntax — paste into a browser DevTools console and it won't work
  • /text()="..." in XPath: Safari implements some XPath functions incompletely — verify with the selector tester tool before shipping
  • Shadow DOM: regular CSS selectors cannot pierce shadow boundaries; Playwright's locators do automatically, but the same string in DevTools won't

Before writing a test, paste your HTML and selector into the selector tester and verify it matches exactly what you expect. Toggle CSS / XPath to see which gives a cleaner expression.

Paired tool
CSS / XPath selector tester
Open the tool