THE SHORT VERSION
Start with the control a user would recognize. A role and accessible name often express that well. Use CSS when a particular attribute or relationship is a deliberate part of the test. Either way, know what can change the match.
The difference is what you depend on.
A useful comparison starts with the same element. For the Save changes button above, one locator asks what the control is called; the other asks what its HTML contains.
| What matters | getByRole | CSS selector |
|---|---|---|
| Finds the target by | Role and options such as accessible name. | The tags, attributes, or relationships you write. |
| A good fit when | The test should use a meaningfully named control. | A maintained markup detail identifies what the test needs. |
| Review when | The name, role, or scope changes. | An attribute, tag, or selected relationship changes. |
| Does not prove | The whole page is accessible. | The selected control has the right user-facing meaning. |
Scroll the comparison horizontally on a small screen.
Playwright recommends prioritizing user-facing attributes and explicit testing contracts. That is a starting point for choosing deliberately.
Change one thing. See what still matches.
The locators stay fixed. Change the sample button’s wrapper, label, or attribute and compare the expected matches. A failure can be a useful signal, not just a test to repair.
Two locators. The same button.
The role locator finds a button named Save changes. The CSS locator finds a button with data-action="save-profile". Both identify the single button in this sample.
getByRole1 matchCSS selector1 matchExpected matches for the sample HTML below. The two locators stay unchanged.
See the HTML for this change
<button type="button" data-action="save-profile">
Save changes
</button>What this tells youA match tells you what was found. Your test still needs to check what the action does.
A layout change leaves both intact.
Neither locator depends on the button’s parent or its position. Adding this wrapper changes the layout structure without changing either match.
getByRole1 matchCSS selector1 matchExpected matches for the sample HTML below. The two locators stay unchanged.
See the HTML for this change
<div class="profile-actions">
<button type="button" data-action="save-profile">
Save changes
</button>
</div>What this tells youCSS does not have to mean a long chain of parents and positions. This attribute selector has no wrapper dependency.
A new name changes the role match.
The exact accessible name is now Update profile. The original role locator no longer matches; the data-action attribute still does.
getByRole0 matchesCSS selector1 matchExpected matches for the sample HTML below. The two locators stay unchanged.
See the HTML for this change
<button type="button" data-action="save-profile">
Update profile
</button>What this tells youWas the rename intended? A failed name match can catch a real UI change. Check the requirement before editing the test.
A markup change breaks the CSS match.
The button’s role and accessible name stay the same. The CSS locator still asks for save-profile, but that attribute value is gone.
getByRole1 matchCSS selector0 matchesExpected matches for the sample HTML below. The two locators stay unchanged.
See the HTML for this change
<button type="button" data-action="update-profile">
Save changes
</button>What this tells youTreat a selector attribute as something the application and test maintain together. Short selectors still have dependencies.
THE LOCATORS WE ARE COMPARING
page.getByRole('button', {
name: 'Save changes',
exact: true,
})page.locator(
'button[data-action="save-profile"]'
)Read the name Playwright reads.
The name option in getByRole means the accessible name. It is not the HTML name attribute, and it may differ from the visible text.
<button type="button" aria-label="Save profile">
Save
</button>This button shows “Save,” but its accessible name is “Save profile.” The locator should use the latter.
const save = page.getByRole('button', {
name: 'Save profile',
exact: true,
});
await expect(save).toHaveCount(1);
await expect(save).toHaveAccessibleName('Save profile');Use the code examples inside a Playwright test with page ready and expect imported from @playwright/test.
An unnamed icon button may need an accessible label. Switching to CSS can make the test find it while leaving that problem in the product.
Read MDN’s accessible-name explanation and Playwright’s getByRole options.
Use CSS for a reason you can explain.
A short CSS selector can be appropriate for a maintained attribute or a relationship your test intentionally checks. The important question is which part of the markup your team expects to preserve.
button[data-action="save-profile"]Relies on the button tag and one attribute value. A new layout wrapper does not affect it.
.panel > div:nth-child(2) > button.btn-a7fAdds a parent chain, a position, and a class that may be generated. Each one needs a reason to be there.
If the application has no meaningful name or stable attribute, agree on a test ID rather than making a long DOM path your default. See the three-way locator comparison for that decision.
What about hidden elements?
Role locators exclude elements hidden from the accessibility tree by default. A plain CSS selector can still match hidden markup; its click() must then pass the applicable actionability checks. Check which element you selected before adding a visibility filter.
See includeHidden.
One target. Then the right result.
Two sections can both contain “Save changes.” exact: true does not distinguish identical names. Start in the Profile section, then find its button.
See the two-section example
<section aria-label="Profile">
<button type="button" data-action="save-profile">
Save changes
</button>
<p role="status"></p>
</section>
<section aria-label="Preferences">
<button type="button">Save changes</button>
</section>const profile = page.getByRole('region', {
name: 'Profile',
exact: true,
});
const save = profile.getByRole('button', {
name: 'Save changes',
exact: true,
});
await expect(save).toHaveCount(1);
await save.click();
await expect(profile.getByRole('status'))
.toHaveText('Profile saved');See the same check with a CSS locator
const profile = page.getByRole('region', {
name: 'Profile',
exact: true,
});
const save = profile.locator(
'button[data-action="save-profile"]'
);
await expect(save).toHaveCount(1);
await save.click();
await expect(profile.getByRole('status'))
.toHaveText('Profile saved');These snippets assume the Profile save handler updates its status to “Profile saved.” The HTML above shows the structure; your application supplies that behavior. Use your actual success state in the final assertion.
Both approaches create Playwright locators. A normal click checks readiness, including visibility and whether the element is enabled. It does not prove that the profile was saved. Keep the result assertion.
See Playwright’s actionability checks and retrying text assertions.
Before you commit the locator.
- The locator identifies the intended control in the state this test uses.
- I know which name, role, attribute, or relationship it relies on.
- Repeated controls are narrowed to a meaningful part of the page.
- A changed accessible name or missing semantic control is reviewed before using a fallback.
- The test checks the expected result after the action.
If a locator matches but the action still fails, work through the locator failure guide before replacing it. The page state or application behavior may need attention.
FROM THE EXAMPLE TO YOUR OWN UI
Make the choice on your actual page.
Inspect an element and review its selectors with CSS Selector & XPath Finder. If the problem depends on earlier actions, record the browser steps so a teammate can see how you got there.
Free extension for desktop Chrome. Try it before signing up.The extension’s CSS-based Playwright snippet is a starting point. Check the target, choose the locator that fits your test, and add assertions for the result.
A few common questions.
Should I prefer getByRole in Playwright?
Start with a user-facing locator when it expresses the interaction. getByRole is a good fit for a control with a meaningful role and accessible name. Use CSS deliberately when a markup attribute or relationship is what you need to identify; a missing accessible name deserves investigation, not just a different selector.
Does getByRole use visible text or the name attribute?
Its name option matches the accessible name. For a button that can come from its text or ARIA naming attributes. It does not mean the HTML name attribute. Inspect the computed accessible name when the visible label and the locator disagree.
Do CSS locators auto-wait too?
Yes. Both page.locator() and getByRole() create Playwright locators. Their actions use Playwright’s actionability checks. Changing the locator method does not remove an overlay, enable a disabled button, or prove that a save succeeded.
What if there are two buttons with the same name?
Scope to the intended region, dialog, or other meaningful container before finding the button. exact: true makes the name match exact; it does not make duplicate names unique. Avoid choosing first() simply to silence a strictness error.
When should I use getByTestId instead?
Use an agreed test ID when the test needs a stable identity separate from user-facing wording. Keep accessibility and label assertions where they matter. The three-way locator guide covers this choice in more detail.
Does a passing role locator prove accessibility?
No. It can expose some role or naming problems, but it does not check the complete experience. Keep dedicated accessibility testing for keyboard use, focus, contrast, and other requirements.
Sources & further reading
The API guidance follows the documentation below. The profile examples are authored examples to adapt to your application.