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 usertext=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.