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.
``html
`
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'".
``ts
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