Every Playwright test boils down to find an element, do something with it, assert something about it. The "find an element" half is what this chapter is about — and Playwright's locator API is genuinely the most user-friendly of any browser-automation framework. Where Cypress relies on CSS selectors as the default and treats accessibility-based locators as opt-in, Playwright inverts the priority: role-, label-, and text-based locators are the recommended way, with CSS and XPath as fallbacks. This lesson is about building the muscle memory to reach for the right locator first time, every time.
What a Locator actually is
Before the API: a Playwright Locator is not the element itself. It's a lazy, queryable handle that re-runs its query every time you await an action or assertion against it. Think of it as a recipe for finding an element, not a snapshot.
// This line does NOT touch the DOM
const submitButton = page.getByRole("button", { name: "Submit" });
// This line queries the DOM and clicks
await submitButton.click();
// This line queries the DOM AGAIN — fresh query, no stale element
await expect(submitButton).toBeVisible();Because locators re-query, they're immune to "stale element reference" errors that plague Selenium. You can store a Locator in a variable, navigate, render new content, and the same Locator still works against the new DOM.
The recommended locator priority
Playwright's docs are unusually opinionated about locator order. From most resilient to least:
getByRole— by accessibility role and accessible name (best practice).getByLabel— form fields by their visible label.getByPlaceholder— inputs by their placeholder text.getByText— elements by visible text.getByAltText— images and elements by alt text.getByTitle— elements by theirtitleattribute.getByTestId— bydata-testidattribute.- CSS selectors — fallback for structural matches.
- XPath — fallback of last resort.
The further up the list, the more your locator describes how a user finds the element, not how the developer happened to mark up the DOM. That alignment with user intent is what makes the locator survive CSS refactors, framework migrations, and design-system overhauls.
getByRole — the workhorse
A web page is a tree of accessibility roles whether the developer thinks about it or not: every <button> has role button, every <a href> has role link, every <input type="text"> has role textbox. Screen readers navigate by role. So can your tests:
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Products" }).click();
await page.getByRole("heading", { level: 1 }).waitFor();
await page.getByRole("textbox", { name: "Email" }).fill("alice@test.com");
await page.getByRole("checkbox", { name: "Remember me" }).check();
await page.getByRole("listitem").filter({ hasText: "Laptop" }).click();The name option matches the element's accessible name — usually its visible text or its aria-label. The match is case-insensitive and substring by default; pass { name: /^Submit$/, exact: true } for exact matching with regex.
Why this is the best locator: if getByRole('button', { name: 'Submit' }) can't find a button, the button might not be properly accessible — which is itself a real bug worth catching. Tests that demand accessibility produce more accessible products.
getByLabel — forms first
For form fields, the most natural locator is the label the user reads:
await page.getByLabel("Email address").fill("alice@test.com");
await page.getByLabel("Password").fill("secret123");
await page.getByLabel("Subscribe to newsletter").check();This works for any input properly associated with a <label> (via for= or wrapping). It also works for aria-label and aria-labelledby patterns. Forms that pass getByLabel tests are forms that screen-reader users can fill in — another double-duty win.
getByText — visible content
When the element exists for the user as text rather than a control — banners, error messages, totals, "12 items" counters — getByText is the natural fit:
await expect(page.getByText("Order confirmed")).toBeVisible();
await page.getByText("Add to cart").first().click();
await expect(page.getByText(/total: \$[\d.]+/i)).toBeVisible();The default is substring + case-insensitive. Pass { exact: true } to match the full string, or pass a regex when the text is dynamic (timestamps, counts, prices).
getByTestId — when the DOM doesn't help
Sometimes the element has no role, no label, and no stable text — a custom widget, a styled <div>, an analytics-only element. That's what data-testid is for:
await page.getByTestId("product-card").first().click();
await expect(page.getByTestId("checkout-total")).toContainText("$129.99");Playwright reads data-testid by default; if your team's convention is data-cy (common in Cypress projects you're migrating), switch in the config:
// playwright.config.ts
export default defineConfig({
use: { testIdAttribute: "data-cy" },
});Now page.getByTestId('submit') matches [data-cy='submit']. Same change in one place — every test inherits.
Locator priority, visualised
Locator strategies — ranked by resilience and user-friendliness
The numbers are illustrative — the point is the order. Every team eventually drifts up the list as they internalise that user-facing locators outlive design refactors.
Filtering — narrowing without resorting to nth()
Real apps have lists, tables, and repeating components. .filter() narrows a multi-match locator down to the one you want:
// All listitems containing "Laptop"
const laptopItem = page.getByRole("listitem").filter({ hasText: "Laptop" });
// The row where the cell contains Alice's name
const aliceRow = page
.getByRole("row")
.filter({ has: page.getByRole("cell", { name: "Alice" }) });
// Exclude a class
const enabledButtons = page.getByRole("button").filter({ hasNotText: "Coming soon" });
await aliceRow.getByRole("button", { name: "Edit" }).click();hasText matches by visible text inside the locator. has filters by the presence of a child locator — perfect for "the row that contains this thing." hasNotText and hasNot are the negations.
Chaining — scoping down a tree
Locators chain. parent.getByRole(...) searches inside parent, not the whole page:
const productCard = page.getByTestId("product-card").first();
await productCard.getByRole("button", { name: "Add to cart" }).click();
await expect(productCard.getByRole("heading")).toContainText("Laptop");This is the equivalent of Cypress's cy.within pattern — every locator off productCard is scoped to that card. No .find() needed; chaining is built in.
Strict mode — Playwright's safety net
By default, locators that match multiple elements throw an error when you act on them:
// If 6 buttons say "Add to cart", this throws:
// strict mode violation: locator resolved to 6 elements
await page.getByRole("button", { name: "Add to cart" }).click();This is on purpose. Cypress silently picks the first match; Playwright forces you to be explicit. Use .first(), .last(), .nth(2) (zero-indexed), or filter:
await page.getByRole("button", { name: "Add to cart" }).first().click();
await page
.getByTestId("product-card")
.filter({ hasText: "Wireless Headphones" })
.getByRole("button", { name: "Add to cart" })
.click();Strict mode is one of the most underrated reliability features in Playwright. The first time you accidentally write a locator that matches three elements, the test screams instead of clicking the wrong thing — and you find a real bug.
A real product-listing example
A typed test that exercises every locator pattern:
import { test, expect } from "@playwright/test";
test.describe("Product listing", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/products");
});
test("page header and search input are present", async ({ page }) => {
const header = page.getByTestId("page-header");
await expect(header.getByRole("heading", { level: 1 })).toContainText("Products");
await expect(header.getByPlaceholder("Search products")).toBeVisible();
});
test("clicks Add to cart on the first product card", async ({ page }) => {
const firstCard = page.getByTestId("product-card").first();
await firstCard.getByRole("button", { name: "Add to cart" }).click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
});
test("adds the Wireless Headphones product to the cart", async ({ page }) => {
const headphonesCard = page
.getByTestId("product-card")
.filter({ hasText: "Wireless Headphones" });
await headphonesCard.getByRole("button", { name: "Add to cart" }).click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
});
test("paginates through products", async ({ page }) => {
await page
.getByTestId("pagination")
.getByRole("button", { name: "Next" })
.click();
await expect(page).toHaveURL(/page=2/);
});
});Read each test from the outside in. The first scopes locators to the page header. The second uses .first() to disambiguate. The third uses .filter({ hasText }) to find a specific card by product name. The fourth scopes pagination clicks to the pagination region. None of them use a CSS class or an XPath.
Coming from Cypress?
The mappings:
cy.get('[data-testid="submit"]')→page.getByTestId('submit')cy.contains('Add to cart')→page.getByText('Add to cart')cy.get('button').contains('Submit')→page.getByRole('button', { name: 'Submit' })cy.get('form').find('input[name=email]')→page.locator('form').getByLabel('Email')cy.contains('tr', 'alice@example.com').within(...)→page.getByRole('row').filter({ hasText: 'alice@example.com' })
The biggest mindset shift: in Cypress you mostly thought in CSS; in Playwright you think in roles and labels first. CSS is the fallback, not the default.
⚠️ Common mistakes
- Reaching for CSS classes when a role would work.
page.locator('.btn-primary.submit-large')is one design-system migration away from breaking.page.getByRole('button', { name: 'Submit' })survives every CSS refactor your team will ever do. Get into the habit of asking "how would a user find this?" first, every time. - Forgetting Playwright is strict by default. A locator that matches multiple elements throws — that's a feature, not a bug. Don't paper over it with
.first()reflexively; ask whether your locator is actually unambiguous. Often the right fix is.filter({ hasText: ... })to narrow to the correct match, not just some match. - Chaining
.locator('css')when you could chain.getByRole(...). Once you have a parent locator (page.getByTestId('product-card').first()), you can chain.getByRole,.getByText,.getByLabeloff it — they all stay scoped. Defaulting to.locator('css')for child queries gives back the resilience win you got from the parent.
🎯 Practice task
Wire up the locator toolkit on Sauce Demo. 25-30 minutes.
-
With
baseURL: "https://www.saucedemo.com"set, log in inside abeforeEach(use the credentialsstandard_user/secret_sauce). Land on/inventory.html. -
In
tests/locators.spec.ts, write five tests that each demonstrate one locator technique:import { test, expect } from "@playwright/test"; test.describe("Locator toolkit", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); await expect(page).toHaveURL(/inventory/); }); test("getByTestId — six product cards", async ({ page }) => { await expect(page.locator("[data-test='inventory-item']")).toHaveCount(6); }); test("getByRole + first — add the first product to the cart", async ({ page }) => { await page .locator(".inventory_item") .first() .getByRole("button", { name: "Add to cart" }) .click(); await expect(page.locator(".shopping_cart_badge")).toHaveText("1"); }); test("filter + chain — add the Backpack regardless of position", async ({ page }) => { const backpack = page .locator(".inventory_item") .filter({ hasText: "Sauce Labs Backpack" }); await backpack.getByRole("button", { name: "Add to cart" }).click(); await expect(page.locator(".shopping_cart_badge")).toHaveText("1"); }); test("getByText — the page heading", async ({ page }) => { await expect(page.getByText("Products", { exact: true })).toBeVisible(); }); test("strict mode — disambiguate by filtering", async ({ page }) => { // Unfiltered: 6 buttons match. .first() picks the first; filter is more semantic. await page .locator(".inventory_item") .filter({ hasText: "Fleece Jacket" }) .getByRole("button", { name: "Add to cart" }) .click(); await expect(page.locator(".shopping_cart_badge")).toHaveText("1"); }); }); -
Run the spec headlessly:
npm test -- tests/locators.spec.ts. All five should pass across all three browsers. -
Force a strict-mode error. In one test, change the click to
await page.getByRole("button", { name: "Add to cart" }).click()(no.first(), no filter). Run again. Read the error:strict mode violation: locator resolved to 6 elements. This is Playwright telling you exactly what's wrong. Fix it by adding.first()or.filter(). -
Stretch: add a sixth test that adds three different items by name (Backpack, Fleece Jacket, T-Shirt) using
.filter({ hasText })andgetByRole, then asserts the cart badge shows "3". This is the pattern you'll repeat dozens of times in a real e-commerce suite — and it's the test that will not break when the design team renames.inventory_itemto.product-tilenext quarter.
Locators are the foundation. The next lesson covers CSS and XPath — what they're still good for, the Playwright-specific extensions like >> and text= engines, and when reaching for .locator('css') is the right call.