CSS Selector vs getByRole: When to Use Each
Most flaky locator failures trace back to choosing the wrong selector kind for the element, not to a typo in the selector itself. page.locator('css=...') targets the DOM structure, while getByRole() targets the accessibility tree the way a user does — and the two fail in opposite situations. This page is a decision guide: a side-by-side matrix of the criteria that matter, a decision tree that collapses those criteria into four outcomes, then a numbered checklist you can run against any element to pick the right tool the first time.
Root cause: structural selectors and semantic selectors fail differently
A CSS selector binds your test to the DOM tree — class names, nesting, and order — all of which change every time a designer refactors markup or a build tool hashes class names. getByRole() binds instead to the accessibility role and accessible name, which are part of the component's contract with assistive technology and therefore far more stable. Picking the wrong one is the real defect: CSS on a volatile component breaks on every restyle, while a role query on a non-semantic <div> matches nothing. This decision sits inside CSS & XPath Best Practices, under Reliable Selector Strategies for Playwright.
The two engines also resolve differently under the hood. A CSS query walks the rendered DOM and returns the first structural match, so it is quick but blind to meaning — it cannot tell a real submit button from a decorative <div> styled to look like one. getByRole() consults the accessibility tree the browser computes from native semantics, roles, and ARIA attributes, so it matches the element a screen-reader user would reach for by the same label. That extra computation is why role queries feel a fraction slower on very large pages, but the resolution is far more meaningful: the accessible name rarely changes without a deliberate product decision, whereas class names churn on almost every build. Frame the choice as structure versus meaning rather than old versus new and the right tool for each element becomes obvious.
The failure is easiest to see across a build timeline. The same restyle that rehashes utility classes leaves the accessible name untouched, so the two locator styles diverge at exactly the moment your CI runs against a fresh production bundle.
Minimal reproducible example
The same button targeted two ways. The CSS version breaks the moment the utility classes change; the role version survives because the rendered text and role are stable.
import { test, expect } from '@playwright/test';
test('the two locator styles target the same submit button', async ({ page }) => {
await page.goto('/checkout');
// CSS: couples the test to class names a CSS framework may rehash on any build.
const byCss = page.locator('button.btn.btn-primary.checkout__submit');
// Role: couples the test to the accessible role + name a user actually perceives.
const byRole = page.getByRole('button', { name: 'Place order' });
// Both resolve to the same element today...
await expect(byCss).toHaveCount(1);
await expect(byRole).toHaveCount(1);
// ...but only the role query stays valid after a restyle that changes classes.
await byRole.click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
Read this example as a contract, not a demonstration. The toHaveCount(1) assertions prove both locators are valid right now, which is exactly why the brittle one slips through review — it is green on the branch it was written on. The defect only surfaces on the next build that regenerates class names, so the test that exercises the CSS locator is a time bomb rather than an obviously broken query. The role locator carries no such debt because button and the label "Place order" are decisions the product team made deliberately; nobody rehashes them to shave a few bytes off a stylesheet. When you are unsure which style a teammate reached for, this pair is the tell: if removing a class from the markup would break the test, the test is coupled to structure, and you should ask whether that coupling is intentional.
Step-by-step decision checklist
Run every element through the same decision before you type a locator. The tree below collapses the checklist into four possible outcomes — a role query, a test id, a scoped CSS selector, or an XPath axis step — and the numbered steps that follow walk each branch in detail.
- Ask whether the element is interactive or has a semantic role. Buttons, links, checkboxes, headings, and form fields all expose roles. If the element is one of these, start with
getByRole()and an accessiblename— the most resilient choice and the one the decision tree routes almost everything toward. - Confirm the accessible name is stable. If the visible label is durable product copy, the role query is safe. If the name is a volatile id or timestamp, prefer a
getByRole()filtered by a steadier attribute, or fall back to a test id rather than pinning your test to text that regenerates on every render. - Fall back to CSS only for non-semantic structure. Layout wrappers, decorative
<div>/<span>containers, and SVG internals have no role. Target these with a scoped CSS selector built on stable hooks — adata-attribute or a semantic id — never on framework utility classes that a build tool rehashes. - Prefer a dedicated test id over fragile CSS. When neither a role nor durable text exists, add
data-testidand usegetByTestId(); it survives restyles the way CSS class chains do not. Reserve raw CSS for markup you do not control and cannot annotate. - Scope before you specify. Narrow with
getByRole('navigation')or a region locator first, then chain the inner query. Scoping keeps both CSS and role queries short and immune to duplicate matches elsewhere on the page, and pairs naturally with scoping locators usingfilter()andhaswhen a plain name is not enough to single out the element. - Reserve XPath for axis traversal. Only reach for XPath when you must navigate relationships CSS cannot express, such as selecting a parent from a child, and follow Optimizing XPath for SPA Navigation so the expression does not become its own source of flakiness.
Troubleshooting variants
getByRole finds nothing on a custom component
The component is rendered from non-semantic <div>s and exposes no implicit role. Either fix the markup with the correct element or an ARIA role attribute — which also helps real users — or fall back to getByTestId(). Confirm the computed role with the accessibility panel in your browser devtools; if it reads "generic", getByRole cannot match it. The deeper case for roles is covered in Why getByRole Beats CSS Selectors in Modern Apps.
getByRole matches more than one element (strict mode violation)
Two elements share the same role and name. Add an exact name ({ name: 'Save', exact: true }), filter by an additional attribute, or scope the query to a parent region first. Resolving the ambiguity is better than switching to a positional CSS nth selector, which silently rebinds when order changes; the full playbook is in Resolving Strict Mode Violations.
CSS selector worked locally but breaks in CI
The class names changed between builds because a CSS-in-JS or utility framework generated different hashes in the production bundle CI ran against. This is the exact divergence the timeline above illustrates — migrate the locator to getByRole or getByTestId so it no longer depends on generated class names, and lean on the patterns in getByRole & Accessibility Selectors.
The role query flickers on a component that renders late
The locator is correct but the element is not in the accessibility tree yet because the framework has not finished hydrating. This is a timing problem, not a selector problem: getByRole already auto-waits, but if you are asserting immediately after navigation, anchor on a visible ancestor first. Lean on the synchronization patterns in Waiting Strategies for Dynamic React Components rather than reaching for a CSS selector you assume resolves faster — it does not, and it will still be racing the same render.
Verification
Validate the choice three ways. First, run the suite with --repeat-each=5 after a deliberate restyle (rename or rehash the classes) — role and test-id queries stay green while CSS chains break, proving the resilience claim the timeline predicts. Second, use npx playwright codegen and watch which locator it suggests; the generator prefers roles for semantic elements, a good sanity check on your decision-tree branch. Third, open the Playwright Trace Viewer and inspect the locator resolution step to confirm a single, intended match rather than a strict-mode collision. Together these turn the decision from a matter of taste into something you can measure on every run.
A fourth check is worth building into code review rather than the runner: grep the diff for locators built on class chains or nth indexes and treat each as a question, not a rejection. Sometimes a scoped CSS selector on a genuinely non-semantic wrapper is the correct answer the decision tree lands on, and forcing a role onto a <div> that has none only adds a misleading aria attribute. The goal is not to eliminate CSS — it is to make sure every CSS locator in the suite is there because no role or test id fit, not because the author reached for the first selector the browser devtools copied. Once the team internalizes the tree, the ratio of role and test-id locators to raw CSS becomes a quiet health metric for how tightly your tests are coupled to markup that is free to change underneath them.
Frequently Asked Questions
Is getByRole always better than a CSS selector?
For interactive and semantic elements, yes — it is more resilient and reads like user intent. But non-semantic layout nodes have no role, so CSS or a test id is the correct tool there. The rule is default to roles, fall back to CSS only when no role exists.
Are CSS selectors faster than getByRole?
CSS matching is marginally faster because it queries the DOM directly while getByRole consults the accessibility tree. The difference is negligible next to the network and rendering waits in a real test, so resilience should drive the choice rather than raw match speed.
When should I use getByTestId instead of either?
Use getByTestId() when an element has no stable role and no durable accessible name — for example a styling wrapper you must click. A dedicated data-testid is more resilient than a CSS class chain and clearer than a brittle structural selector.
How do I pick a locator for an icon-only button?
Give the button an accessible name with aria-label and it gains a role and a name, so getByRole('button', { name: 'Close' }) works and doubles as an accessibility fix. If you cannot change the markup, fall back to a test id; avoid targeting the inner SVG path with CSS, since icon internals are exactly the kind of decorative structure that churns between builds.