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.
| Priority | Locator | Finds | Example |
|---|---|---|---|
| 1 | getByRole | Elements by their accessibility role and name. Buttons, links, headings, textboxes, checkboxes | page.getByRole('button', { name: 'Login' }) |
| 2 | getByLabel | Form fields by their <label> or aria-label | page.getByLabel('Password') |
| 3 | getByPlaceholder | Inputs by placeholder text | page.getByPlaceholder('Username') |
| 4 | getByText | Any element by its text | page.getByText('Products') |
| 5 | getByTestId | Elements with a dedicated test attribute | page.getByTestId('title') |
| 6 | locator(css) | Raw CSS or XPath. Last resort | page.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-testinstead of the defaultdata-testid. One line in the config fixes that:testIdAttribute: 'data-test'underuse. 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:
| Method | Use |
|---|---|
.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:
| Assertion | Checks |
|---|---|
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.
-
getByTextmatches 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.waitForTimeoutis 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.