Test like a user

Understand Playwright's actionability and locators.

So far, you should have at least two test cases:

  • add a product to cart from home
  • add a product to cart from the catalog

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?

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:

Reach for these first
page.getByRole()

ARIA role + accessible name

<button>Save changes</button>
page.getByText()

visible text content

<p>Welcome back!</p>
page.getByLabel()

form field by its label

<label>Email<input type="email"/></label>
Also user-first
page.getByPlaceholder()

input via its placeholder

<input placeholder="Search…"/>
page.getByAltText()

image by its alt text

<img alt="Workshop logo"/>
page.getByTitle()

via its title attribute

<a title="Open in new tab">…</a>
When user-first isn’t possible
page.getByTestId()

developer-set data-testid attribute

<div data-testid="cart-count">3</div>
Not recommended
page.locator()

CSS selector — brittle, couples tests to markup

<button class="cta">Save</button>

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.

Note

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.

Inline exercise

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')?

Click me
Show why

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.

Important locator characteristics

Playwright Test's locators include core functionality you must be aware of.

Locators are strict

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();

Locators are lazy

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();
Warning

Many people await locators. That's unnecessary because they're only holding a definition until they're used with an action or assertion.

Locators can be chained

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.

Inline exercise

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".

Products

  • Product 1

  • Product 2

  • Product 3

Show solution

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.

Actionability

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()
  • ...
Note
To find the best action, 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:

  • the matching element is attached to the DOM
  • the matching element is visible
  • the matching element is stable (not animating)
  • the matching element is able to receive events (not obscured by other elements)
  • the matching element is enabled (no 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");
Note

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.

Inline exercise

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?

Show the equivalent test

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();

Hands on

Practice locators and actions

Exercise 1 of 2

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.

  1. Navigate to /.
  2. Click the 2nd product.
  3. Add it to the cart.

Hint: Reach for position-based helpers like nth(), first(), or last() on top of a semantic locator.

Exercise 2 of 2

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.

  1. Navigate to /.
  2. Click on the top-right "login" button.
  3. Fill out the form and submit it (any combination will work).
  4. Click on the welcome message (we'll add proper assertions in the next lessons).
  5. Log the user out again.