page.getByRole()ARIA role + accessible name
Understand Playwright's actionability and locators.
So far, you should have at least two test cases:
We've only recorded tests without knowing how Playwright works under the hood. Let's understand how Playwright locators and interactions work!
Playwright interactions are based on locators.
import { test, expect } from "@playwright/test";test("has title", async ({ page }) => { await page.goto("https://playwright.dev/"); await page.getByRole("link").click();});
But what are locators?
A locator is a "blueprint" of the elements you want to interact with.
The Playwright team highly encourages the usage of "user-first" locators that are as close to the end-user experience as possible. The recommended locators are:
page.getByRole()ARIA role + accessible name
page.getByText()visible text content
page.getByLabel()form field by its label
page.getByPlaceholder()input via its placeholder
page.getByAltText()image by its alt text
page.getByTitle()via its title attribute
page.getByTestId()developer-set data-testid attribute
page.locator()CSS selector — brittle, couples tests to markup
CSS selectors couple tests to internal markup. They break the moment a dev renames a class.
Try to test user-first as much as possible.
codegen prefers user-first locators for you, but PWT can't know about your
site internals and what'll be the best locator. Usually you have to tweak the
locators it generates.
Unfortunately, you'll discover quickly tha when you deal with a real-world codebase, not all DOM elements can be selected user-first. In practice, it's often a mix of different locators. Check other locators in the docs.
When getByRole failsSelect this container via test id: getbyrole-fails
Run codegen against this lesson page (npx playwright codegen <url>) and
record a click on the button below. What locator does Playwright suggest —
and why isn't it getByRole('button')?
The element looks like a button but it's a <span> with an onClick
handler. There's no implicit role="button", no keyboard handling, and no
accessible name — so getByRole('button', { name: 'Click me' }) finds
nothing. Codegen falls back to getByText('Click me').
User-first selectors often unveil troublesome frontend architectures: if a
"clickable" button can only be selected with getByText, it's probably not
a real <button>. If a form element can't be selected with getByLabel,
it's often inaccessible without proper labels.
The list goes on and on — if you struggle to find good locators it's worth talking to the dev team or fixing things yourself.
Playwright Test's locators include core functionality you must be aware of.
A locator throws an exception if it matches multiple DOM elements when used with an action or assertion.
await page.getByRole("link").click();
// Error:
// locator.click: Error: strict mode violation: getByRole('link') resolved to 31 elements:
// ...
If there are multiple elements matching one locator you need to be more specific or use helper functions such as first(), nth() or last().
await page.getByRole("link").first().click();
Every time a locator is used for an action, an up-to-date DOM element is located on the page.
// the `locator` is only evaluated when it's used
const locator = page.getByRole("button", { name: "Sign in" });
// evaluate DOM elements matching the locator
await locator.hover();
// evaluate DOM elements matching the locator
await locator.click();
Many people await locators. That's unnecessary because they're only holding
a definition until they're used with an action or assertion.
To narrow down your selection you can always filter and chain locators.
// The `button` locator reuses the `product` locator
const product = page.getByRole("listitem").filter({ hasText: "Product 2" });
const button = product.getByRole("button", { name: "Add to cart" });
// Mix `getByTestId` with semantic locators for tighter scoping
const productGrid = page.getByTestId("hero-product-grid");
const productLink = productGrid.getByRole("link", {
name: "The Collection Snowboard:",
});
This becomes very handy when sites include some data-testid attributes. Chaining locators will help to select the best elements.
Write a locator that targets Product 2's buttonSelect this container via test id: locator-chaining
The stage below has three product cards, each with an identical "Add to cart" button. Write a Playwright locator that resolves to exactly one element: Product 2's button.
Don't forget to check that the button changes to "Product added".
getByRole('button') matches all three buttons. Even
getByRole('button', { name: 'Add to cart' }) matches all three — the
accessible name is identical. You have to scope the chain by the one thing
that differs between cards: the heading text.
const container = page.getByTestId("locator-chaining");
const listContainer = container
.getByRole("listitem")
.filter({ hasText: "Product 2" });
await listContainer.getByRole("button", { name: "Add to cart" }).click();
filter({ hasText: 'Product 2' }) narrows the listitem locator to a single
card; the inner getByRole('button') then resolves uniquely inside that
scope.
Playwright provides action methods for all common user interactions.
locator.fill()locator.check()locator.selectOption()locator.click()locator.dblclick()locator.hover()locator.type()locator.press()codegen is an invaluable tool here, too!The most important concept when it comes to PWT is that actions auto-wait. Note that you have to await a user action like click. A click isn't only a click.
Actions in Playwright tests are asynchronous operations — why?
For example, await locator.click() waits until the element is actionable:
disabled attribute)// Concept 1:
// Click will wait for the element to be actionable
// Click will also auto-wait for a triggered navigation to complete
await page.getByText("Login").click();
// Concept 2:
// Fill will auto-wait for element to be actionable
await page.getByLabel("User Name").fill("John Doe");
All the taken actionability steps with the "Actionability Log" included in the
debugger (npx playwright test --debug) or Playwright's UI mode (npx playwright test --ui).
These auto-waiting concepts allow you to drop many manual waitFor statements
because you don't have to check if an element exists or is visible. Actions
will wait / retry until an element is "ready for action" or throw a timeout
error in your test.
Click each button — they show up in different statesSelect this container via test id: actionability-chain
Click Step 1, then Step 2, then Step 3. Step 2 only attaches to the DOM after a delay; Step 3 fades in and wiggles for a couple of seconds before it settles. Will it just work?
Three sequential clicks. Playwright's actionability checks cover the delayed render (Step 2) and the in-flight animation (Step 3) for free.
await page.getByRole("button", { name: "Step 1" }).click();
await page.getByRole("button", { name: "Step 2" }).click();
await page.getByRole("button", { name: "Step 3" }).click();
Without auto-wait, you'd need a waitFor or toBeVisible between every
click — and you'd still race the animation on Step 3.
// 👎
// Checking if an element is visible before interacting with it
await expect(page.getByText("Login")).toBeVisible();
await page.getByText("Login").click();
// 👍
// Just interact with it and let Playwright figure out the rest
await page.getByText("Login").click();
Add the 2nd home product to the cart
Products can always change. Try to avoid relying on "magic strings". Create another "add to cart from home" but for the second product.
/.Hint: Reach for position-based helpers like nth(), first(), or last() on top of a semantic locator.
Log in, then log back out
The login flow includes many delays, thanks to auto-waiting actions you can quickly write a test for it.
/.