Start testing things (for real)

Understand Playwright's actionability and locators.

So far, you should have three test cases:

  • add a product to cart
  • search for a product and add it to cart
  • log in

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 available on the page fixture.

import { test, expect } from "@playwright/test";

// The provided `page` object is called a test fixture
// and there are many more available...
// You can even add your own.
//                          👇
test("has title", async ({ page }) => {
  await page.goto("https://playwright.dev/");

  // Expect the page title "to contain" a substring.
  await expect(page).toHaveTitle(/Playwright/);
});
Note
Don't worry about Playwright fixtures. We'll talk about them later.

Locators

The Playwright team highly encourages the usage of "user-first" locators to be as close to the end-user experience as possible. The recommended locators are:

  • page.getByRole() (user-first)
  • page.getByText() (user-first)
  • page.getByLabel() (user-first)
  • page.getByPlaceholder() (user-first)
  • page.getByAltText() (user-first)
  • page.getByTitle() (user-first)
  • page.getByTestId() ("qa-first")
Note

codegen creates 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 later.

If these user-first locators don't fit your needs, CSS selector based locators work (page.locator('.cta')), too.

When dealing 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 fails

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

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.

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

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 locator 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 of a class locator and a user-first locator
const detailContainer = page.locator(".detail-content");
const productHeading = detailContainer.getByRole("heading", { level: 2 });

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 button

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.

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

page
  .getByRole("listitem")
  .filter({ hasText: "Product 2" })
  .getByRole("button", { name: "Add to cart" });

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)

Additionally, when an action was performed it'll wait until a possible navigation is completed.

// 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

Debug 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).

Actionability log

Note

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 states

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. In a real Playwright test you'd write three plain click() calls — no waitFor, no toBeVisible — and Playwright would handle every wait between them.

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

Exercise

Create a new test case that logs the user in.

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