Skip to main content

Locators and Assertions

Locators are how you find things on the page. Assertions are how you prove the flow worked. Get these two right and your tests stop being flaky. This is the correlation step of the UI world: the part that turns a raw recording into something that survives a rerun.

Locators​

What a Locator Is​

A locator is a description of how to find an element. Nothing happens when you create it. When you act on it (click, fill) or assert on it (toBeVisible), Playwright resolves it against the live page and waits for the element to be:

  • attached to the DOM
  • visible
  • stable (not animating)
  • enabled
  • not covered by another element

If any check fails it keeps retrying until the action timeout (5 seconds by default for assertions, 0 meaning "test timeout" for actions). This is why you almost never write a sleep.

Choose Locators in This Order​

The recorder follows this priority. So should you.

PriorityLocatorFindsExample
1getByRoleElements by their accessibility role and name. Buttons, links, headings, textboxes, checkboxespage.getByRole('button', { name: 'Login' })
2getByLabelForm fields by their <label> or aria-labelpage.getByLabel('Password')
3getByPlaceholderInputs by placeholder textpage.getByPlaceholder('Username')
4getByTextAny element by its textpage.getByText('Products')
5getByTestIdElements with a dedicated test attributepage.getByTestId('title')
6locator(css)Raw CSS or XPath. Last resortpage.locator('.inventory_item')

Why roles first: a button is still a button after the CSS class, the id and the DOM position change. Role and name only change when the product changes, and then the test should change too.

Why test ids fifth, not first: they are stable, but they say nothing about what the user sees. A test full of getByTestId passes even when the button is invisible to a real person. Use them for elements that have no good role or text (a card container, a badge with just a number).

Note: saucedemo, the app in the sample project, uses data-test instead of the default data-testid. One line in the config fixes that: testIdAttribute: 'data-test' under use. See Section 8.

Finding the Role and Name​

Roles come from the accessibility tree, not from tag names. The quickest way to see them:

  • Pick locator in the recorder or in VS Code (Section 4), or
  • print the tree in a test:
    console.log(await page.locator('body').ariaSnapshot());

For the saucedemo login page it prints:

- main:
- form "Login":
- textbox "Username"
- textbox "Password"
- button "Login"

Every line is a locator: getByRole('textbox', { name: 'Username' }), getByRole('button', { name: 'Login' }).

Chaining and Filtering​

When the same element appears many times, narrow down from a container instead of counting positions:

// The "Add to cart" button inside the Backpack card, not the first one on the page
await page
.getByTestId('inventory-item')
.filter({ hasText: 'Sauce Labs Backpack' })
.getByRole('button', { name: 'Add to cart' })
.click();

Other useful narrowing tools:

MethodUse
.filter({ hasText })Keep matches that contain text
.filter({ has: locator })Keep matches that contain another element
.filter({ visible: true })Keep only visible matches
.first(), .last(), .nth(i)Pick by position. Fine for sorted lists, fragile elsewhere
{ exact: true }On getByText and getByRole, match the whole string, case-sensitive

Strict Mode​

If a locator matches more than one element and you act on it, Playwright throws instead of guessing:

Error: strict mode violation: getByRole('button', { name: 'Add to cart' }) resolved to 6 elements

This is a feature. It stops you from clicking the wrong thing silently. Fix it by filtering or by adding name, never by adding .first() without thinking about why there are six.


Assertions​

Web-First Assertions Retry​

await expect(page.getByTestId('shopping-cart-badge')).toHaveText('1');

expect with a locator polls until the condition is true or the timeout (5 seconds) passes. The page can be a second behind and the test still passes. Always await these.

The ones you will use most:

AssertionChecks
toBeVisible() / toBeHidden()Element is (not) shown
toHaveText(text)Full text, or an array of texts for a list
toContainText(text)Substring
toHaveValue(value)Input value
toBeChecked()Checkbox or radio
toBeEnabled() / toBeDisabled()Button state
toHaveCount(n)Number of matches
toHaveClass(/regex/)CSS class present
toHaveURL(url or regex)On page, current URL
toHaveTitle(text or regex)On page, document title
toMatchAriaSnapshot(yaml)A chunk of the accessibility tree

Non-Retrying Checks​

expect(value) on a plain value checks once:

const count = await page.getByTestId('inventory-item').count();
expect(count).toBe(6);

That is fine for values you computed yourself. It is wrong when the value comes from the page and the page may still be loading. Prefer toHaveCount(6) on the locator.

Soft Assertions​

When you want to check several things and see all failures, not just the first:

await expect.soft(page.getByTestId('title')).toHaveText('Products');
await expect.soft(page.getByTestId('shopping-cart-badge')).toBeHidden();

The test continues after a soft failure and is marked failed at the end with every failure listed.


Putting It Together​

From playwright/sample/tests/cart.spec.ts:

test('removing the item clears the badge', async ({ inventoryPage }) => {
await inventoryPage.addToCart('Sauce Labs Bike Light');
await expect(inventoryPage.cartBadge).toHaveText('1');

await inventoryPage.removeFromCart('Sauce Labs Bike Light');

await expect(inventoryPage.cartBadge).toBeHidden();
});

Role-based buttons, a filtered container for the product card, a test id for the badge because it has no text of its own, and assertions that retry. No sleeps, no CSS.


Tips​

  • If you typed a CSS selector, stop and ask why. There is almost always a role, label or text that describes the element better.

  • getByText matches substrings by default. getByText('Log') matches "Login" and "Logout". Add { exact: true } when it matters.

  • Never assert on things that are not the pass condition. A test that checks fifteen labels on the way to the result breaks fifteen times more often for no extra confidence.

  • Prefer assertions over waits. await expect(x).toBeVisible() is both a check and a wait. waitForTimeout is neither.

  • Keep locators in one place. When the same element is used in more than one test, put the locator in a page object (Section 6) so a redesign is a one-line fix.