# Locators

Course: [Playwright](https://www.devtutorials.ai/learn/playwright) · Section: Core Test Writing
Source: https://www.devtutorials.ai/learn/playwright/core-test-writing/locators

> What a locator is, why locators are lazy and strict, the built-in getBy locators (role, text, label, placeholder, alt text, title, test id), CSS and XPath, narrowing with chaining, filter, nth, and, or and within, choosing resilient locators from the accessibility tree, and naming locators with describe().

## Locators

- Before a test can click a button, it must **find** the button.
- A **locator** tells Playwright how to find an element on the page.
- Good locators find elements the way a user does: by role, label or text.
- In this lesson, you will learn every kind of locator, and how to pick the best one.

**Simple lesson:** a locator is a description of how to find an element.

### A Real-World Example

- The Practice Shop lists products. Every card has an **Add to cart** button.
- Priya wants to add the **Yoga Mat**, not just any product.
- So her locator says: "the product card that contains Yoga Mat, then its Add to cart button".

- `baseURL` is set in this project's config, so `page.goto('/products')` opens `https://practice.devtutorials.ai/products`.

### What You Will Learn

1. What a locator is, and why it is lazy and strict.
2. The built-in `getBy...` locators.
3. When to use CSS and XPath, and why they break easily.
4. How to narrow a locator: chaining, `filter()`, `nth()`, `and()`, `or()` and `within()`.
5. How to choose a good locator.
6. How to give a locator a readable name.

## Locator Fundamentals

### What Is a Locator?

- `page.getByRole('button', { name: 'Log in' })` is a locator.
- It does not search the page when you write it. It is a **recipe** for finding the element later.
- `page.locator()` is the general form. It takes a CSS or XPath selector: `page.locator('#search')`.

### Locators Are Lazy and Re-Evaluated

- **Lazy** = Playwright searches only when you act or assert.
- **Re-evaluated** = it searches again every time. If the page changes, the locator finds the new element.
- So you can create a locator once and reuse it, even after the page updates.

<figure className="plw-fig">
<svg viewBox="0 0 640 200" role="img" aria-labelledby="fig-plw-lazy" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-plw-lazy">Animation: a locator is written once. Each time the test uses it, Playwright searches the page again. The page grows from 6 cards to 8, and the second count finds 8</title>
  <rect x="10" y="70" width="190" height="60" rx="10" style={{ fill: "var(--plw-blue-soft)", stroke: "var(--plw-blue)" }} />
  <text x="105" y="96" textAnchor="middle" style={{ fill: "var(--plw-blue)", fontFamily: "var(--mono)", fontSize: 11 }}>const cards =</text>
  <text x="105" y="114" textAnchor="middle" style={{ fill: "var(--plw-blue)", fontFamily: "var(--mono)", fontSize: 11 }}>getByTestId('product-card')</text>
  <rect x="300" y="20" width="320" height="160" rx="10" style={{ fill: "var(--plw-win)", stroke: "var(--plw-slate)" }} />
  <g style={{ fill: "var(--plw-green-soft)", stroke: "var(--plw-green)" }}>
    <rect x="315" y="35" width="90" height="34" rx="6" /><rect x="415" y="35" width="90" height="34" rx="6" /><rect x="515" y="35" width="90" height="34" rx="6" />
    <rect x="315" y="77" width="90" height="34" rx="6" /><rect x="415" y="77" width="90" height="34" rx="6" /><rect x="515" y="77" width="90" height="34" rx="6" />
    <g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.5;0.55;0.95;1" dur="8s" repeatCount="indefinite" /><rect x="315" y="119" width="90" height="34" rx="6" /><rect x="415" y="119" width="90" height="34" rx="6" /></g>
  </g>
  <g style={{ fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>
    <text x="250" y="160" textAnchor="middle" opacity="0" style={{ fill: "var(--plw-orange)" }}>count() → 6<animate attributeName="opacity" values="0;0;1;1;0;0" keyTimes="0;0.15;0.2;0.42;0.45;1" dur="8s" repeatCount="indefinite" /></text>
    <text x="250" y="160" textAnchor="middle" opacity="0" style={{ fill: "var(--plw-orange)" }}>count() → 8<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.65;0.7;0.95;1" dur="8s" repeatCount="indefinite" /></text>
  </g>
  <path d="M200 100 H296" style={{ stroke: "var(--plw-orange)", strokeWidth: 2, strokeDasharray: "6 4" }} opacity="0"><animate attributeName="opacity" values="0;0;1;0;0;1;0" keyTimes="0;0.15;0.2;0.3;0.65;0.7;0.8" dur="8s" repeatCount="indefinite" /></path>
  <text x="460" y="172" textAnchor="middle" opacity="0" style={{ fill: "var(--muted)", fontFamily: "var(--sans)", fontSize: 11 }}>Load more added 2 cards<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.5;0.55;0.95;1" dur="8s" repeatCount="indefinite" /></text>
</svg>
<figcaption>The same locator, used twice. Each use searches the page as it is now.</figcaption>
</figure>

### Strictness and the Strict Mode Violation Error

- Actions such as `click()` and `fill()` need **exactly one** element.
- If a locator finds two or more, Playwright stops with a **strict mode violation**.
- This protects you. Without it, the test might click the wrong button and still pass.
- Run this test and read the error. Playwright even suggests better locators after **aka**.

- Reading and counting are not strict: `count()` and `toHaveCount()` are happy with many elements.

**Simple lesson:** a locator searches when used, searches again every time, and must match one element for an action.

## Built-in Locators

- Playwright has seven `getBy...` methods. They find elements the way people see them.

| Locator | Finds an element by | Example |
|---|---|---|
| `getByRole()` | Its role and accessible name | `getByRole('button', { name: 'Log in' })` |
| `getByText()` | Its visible text | `getByText('No products found')` |
| `getByLabel()` | Its form label | `getByLabel('Email')` |
| `getByPlaceholder()` | Its placeholder | `getByPlaceholder('Search by name')` |
| `getByAltText()` | An image's alt text | `getByAltText('Blue running shoes')` |
| `getByTitle()` | Its title attribute | `getByTitle('Yoga Mat details')` |
| `getByTestId()` | Its `data-testid` | `getByTestId('cart-count')` |

### getByRole()

- **Role** = what an element is for: `button`, `link`, `heading`, `checkbox`, `textbox` and many more.
- **Accessible name** = what a screen reader says for it: a button's text, a field's label, an image's alt text.
- Options make it more exact:

| Option | Example | Matches |
|---|---|---|
| `name` | `{ name: 'Log in' }` | The accessible name contains "Log in", any case |
| `exact` | `{ name: 'Log in', exact: true }` | The name is exactly "Log in" |
| `checked` | `{ checked: true }` | Ticked checkboxes and radios |
| `expanded` | `{ expanded: false }` | Closed menus and combo boxes |
| `level` | `{ level: 1 }` | `<h1>` headings |
| `pressed` | `{ pressed: true }` | Toggle buttons that are on |
| `selected` | `{ selected: true }` | Selected options and tabs |

### getByText(), getByLabel() and getByPlaceholder()

- `getByText('Yoga Mat')` matches text that **contains** "Yoga Mat", ignoring case and extra spaces.
- `getByText('Yoga Mat', { exact: true })` needs the exact text.
- `getByLabel('Email')` finds the input that the label "Email" belongs to. Use it for form fields.
- `getByPlaceholder()` is a fallback for fields with no label.

A string in `getByText()`, `getByLabel()` or a role's `name` matches **part** of the text and ignores upper and lower case. Add `exact: true` for a whole, case-sensitive match. A regular expression gives you full control.

### getByAltText(), getByTitle() and getByTestId()

- `getByAltText()` finds images by their `alt` text.
- `getByTitle()` finds elements by their `title` attribute (the tooltip text).
- `getByTestId('cart-count')` finds `data-testid="cart-count"`. Developers add these on purpose, just for tests.

### Customizing the Test ID Attribute

- Some teams use another attribute, such as `data-qa` or `data-test`.
- Tell Playwright in the config, under `use`: `testIdAttribute: 'data-qa'`.
- This lab's config is editable. Open `playwright.config.ts` and look at the `use` block.

- You can also set it in code with `selectors.setTestIdAttribute('data-qa')`, imported from `@playwright/test`.

**Simple lesson:** prefer `getByRole`, then labels and text. Use test ids when nothing user-facing is stable.

## CSS and XPath Locators

- `page.locator()` takes a **CSS selector** (`'#search'`, `'.price'`) or an **XPath** (`'//button[@type="submit"]'`).
- Use them when no user-facing locator works, for example for a layout element with no role or text.
- They are **fragile**: they depend on how the page is built, not on what the user sees.
- A designer renames a class from `price` to `amount`, and the test breaks, though the page still works.

```ts
// Fragile: depends on page structure
await page.locator('div.row > div:nth-child(2) select').selectOption('name');

// Resilient: depends on what the user sees
await page.getByLabel('Sort by').selectOption('name');
```

**Simple lesson:** CSS and XPath work, but break when the page structure changes. Use them last.

## Narrowing Locators

### Chaining with locator.locator()

- Call a locator method on another locator to search **inside** it.
- `page.getByTestId('product-card').getByRole('button')` finds buttons inside cards.

### filter() by Text and by Child Locator

| Filter | Keeps elements that |
|---|---|
| `filter({ hasText: 'Yoga' })` | contain the text "Yoga" |
| `filter({ hasNotText: 'Out of stock' })` | do not contain that text |
| `filter({ has: page.getByRole('button', { name: 'Add to cart', disabled: true }) })` | contain a matching child |
| `filter({ hasNot: page.getByText('Out of stock') })` | do not contain a matching child |
| `filter({ visible: true })` or `.visible()` | are visible |

### first(), last() and nth()

- `first()`, `last()` and `nth(2)` pick by position. `nth()` counts from 0.
- Use them only when the order **is** the point, for example "the first search result".
- Position breaks easily when items are added or sorted.

### and(), or() and within()

- `a.and(b)` matches elements that match **both** locators.
- `a.or(b)` matches elements that match **either** one. Useful when a page shows one of two things.
- `button.within(card)` (Playwright 1.64) finds the button inside each element the container matches.

**Simple lesson:** chain to search inside, `filter()` to keep the right ones, and `nth()` only when order matters.

## Choosing Good Locators

### Locator Priority

<figure className="plw-fig">
<svg viewBox="0 0 640 220" role="img" aria-labelledby="fig-plw-priority" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-plw-priority">A ladder of locators from most resilient to most fragile: role, label, text, test id, CSS and XPath. A marker climbs to the top</title>
  <g style={{ fontFamily: "var(--sans)", fontSize: 14, fontWeight: 700 }}>
    <rect x="120" y="10" width="400" height="30" rx="8" style={{ fill: "var(--plw-green-soft)", stroke: "var(--plw-green)" }} /><text x="320" y="31" textAnchor="middle" style={{ fill: "var(--plw-green)" }}>1. getByRole</text>
    <rect x="140" y="45" width="360" height="30" rx="8" style={{ fill: "var(--plw-green-soft)", stroke: "var(--plw-green)" }} /><text x="320" y="66" textAnchor="middle" style={{ fill: "var(--plw-green)" }}>2. getByLabel</text>
    <rect x="160" y="80" width="320" height="30" rx="8" style={{ fill: "var(--plw-blue-soft)", stroke: "var(--plw-blue)" }} /><text x="320" y="101" textAnchor="middle" style={{ fill: "var(--plw-blue)" }}>3. getByText / getByPlaceholder / getByAltText</text>
    <rect x="180" y="115" width="280" height="30" rx="8" style={{ fill: "var(--plw-purple-soft)", stroke: "var(--plw-purple)" }} /><text x="320" y="136" textAnchor="middle" style={{ fill: "var(--plw-purple)" }}>4. getByTestId</text>
    <rect x="200" y="150" width="240" height="30" rx="8" style={{ fill: "var(--plw-red-soft)", stroke: "var(--plw-red)" }} /><text x="320" y="171" textAnchor="middle" style={{ fill: "var(--plw-red)" }}>5. CSS and XPath</text>
  </g>
  <circle cx="90" r="9" style={{ fill: "var(--plw-orange)" }}><animate attributeName="cy" values="165;165;130;95;60;25;25" keyTimes="0;0.1;0.3;0.5;0.7;0.85;1" dur="6s" repeatCount="indefinite" /></circle>
  <text x="560" y="31" style={{ fill: "var(--muted)", fontFamily: "var(--sans)", fontSize: 12 }}>most resilient</text>
  <text x="560" y="171" style={{ fill: "var(--muted)", fontFamily: "var(--sans)", fontSize: 12 }}>most fragile</text>
  <text x="320" y="210" textAnchor="middle" style={{ fill: "var(--ink2)", fontFamily: "var(--sans)", fontSize: 12 }}>Start at the top. Move down only when the step above cannot find the element.</text>
</svg>
<figcaption>Prefer locators that describe what users see. Fall back to test ids, then CSS.</figcaption>
</figure>

### User-Facing vs Implementation Attributes

- **User-facing** = what people see or hear: roles, labels, text. These change only when the product changes.
- **Implementation** = how the page is built: ids, classes, element order. These change in every refactor.
- A test built on user-facing locators breaks only when users would notice a change too.

### The Accessibility Tree and Role-Based Locators

- Browsers build an **accessibility tree** from the page: a simple list of roles and names that screen readers use.
- `getByRole()` searches this tree. So a good `getByRole()` locator also proves the page is accessible.
- If you cannot find a button by role, a screen reader user probably cannot either.

### Try the Locator Picker

- Click any element in the page below. The picker shows the locator Playwright would choose, and other options.
- Type your own locator in **Try a locator** to see how many elements it matches.

If a locator matches more than one element, read the **aka** suggestions in the error. They are locators Playwright already checked for you.

**Simple lesson:** pick locators by what users see. They survive redesigns and help accessibility.

## Readable Locators

### Naming Locators with describe()

- `locator.describe('Add Yoga Mat button')` gives a locator a human name.
- It does not change what the locator finds.

### How Descriptions Appear in Reports and Traces

- The name appears in error messages, the HTML report and the Trace Viewer.
- A long chain such as `getByTestId('product-card').filter({ hasText: 'Yoga Mat' }).getByRole('button')` becomes easy to read.

```ts
const addYogaMat = page
  .getByTestId('product-card')
  .filter({ hasText: 'Yoga Mat' })
  .getByRole('button', { name: 'Add to cart' })
  .describe('Add Yoga Mat button');
```

Store a locator in a `const` when you use it more than once. Because locators are lazy, the `const` stays correct as the page changes.

**Simple lesson:** `describe()` gives a long locator a short, readable name.

## Try It in Your Project

- These steps take the lesson from the lab to your own app.
- Open your app in Codegen's locator picker and note which elements have no good role or label.
- Ask your team to add a `data-testid` only where no role, label or text works.
- Rewrite one old CSS or XPath locator in your suite with `getByRole()`.

**Simple lesson:** use one idea from this lesson on your own app this week.

## TL;DR

- A locator is a recipe for finding elements. It searches each time you use it.
- Actions need exactly one match. Many matches cause a strict mode violation.
- Prefer `getByRole`, then `getByLabel`, then text, then `getByTestId`, then CSS or XPath.
- Narrow with chaining, `filter()`, `and()`, `or()` and `within()`. Use `nth()` only when order matters.
- `testIdAttribute` changes the attribute `getByTestId()` reads.

## Common mistakes

| Mistake | What happens | Fix |
|---|---|---|
| Using `.first()` to silence a strict mode error | The test clicks the wrong element when the order changes | Use `filter()` to say which element you mean |
| Copying long CSS selectors from DevTools | Tests break after small layout changes | Use `getByRole` or `getByLabel` |
| `getByText()` for a button | It may match other text with the same words | Use `getByRole('button', { name })` |
| Forgetting `exact: true` | `'Log in'` also matches "Log in with Google" | Add `exact: true` when names overlap |
| Putting a test id on everything | Tests stop checking what users see | Add test ids only where user-facing locators fail |

## Check Yourself

## Exercises

## What to Remember

- Locators are lazy, re-evaluated and strict.
- Prefer role, label and text over test ids, CSS and XPath.
- Narrow with `filter()` and chaining. Avoid `nth()` unless order is the point.
- Read the **aka** suggestions in strict mode errors.
- `describe()` makes long locators readable.

Next: **Actions and Auto-Waiting**
