# Choosing a Locator Strategy

> **When to use**: When deciding which Playwright locator method to use for an element

## Quick Answer

Use `getByRole()` for everything that has a semantic HTML role (buttons, links, headings, form fields, dialogs). Fall back to `getByLabel()` for form fields, `getByText()` for plain content, and `getByTestId()` only as a last resort for custom components with no accessible role.

## Decision Flowchart

```
Start: You need to locate an element
  |
  v
Does the element have a semantic role?
(button, link, heading, textbox, checkbox, combobox, dialog, img, row, cell, navigation...)
  |
  +-- YES --> Use getByRole('role', { name: 'accessible name' })
  |             |
  |             +-- Need to narrow scope? Chain from a parent role locator:
  |                   getByRole('navigation').getByRole('link', { name: '...' })
  |
  +-- NO
       |
       v
     Is it a form field with a visible <label>?
       |
       +-- YES --> Use getByLabel('label text')
       |
       +-- NO
            |
            v
          Is it static text content (paragraph, span, div with text)?
            |
            +-- YES --> Use getByText('text content')
            |             Prefer { exact: true } when text is short or common
            |
            +-- NO
                 |
                 v
               Does it have a placeholder?
                 |
                 +-- YES --> Use getByPlaceholder('placeholder text')
                 |             (Less preferred -- placeholders disappear on input)
                 |
                 +-- NO
                      |
                      v
                    Does it have a title attribute or alt text?
                      |
                      +-- YES --> Use getByTitle('...') or getByAltText('...')
                      |
                      +-- NO
                           |
                           v
                         Add data-testid="..." to the markup
                         Use getByTestId('identifier')
                         |
                         +--> NEVER fall back to CSS selectors or XPath.
                              Fix the markup instead.
```

## Decision Matrix

| Element Type | Recommended Locator | Fallback | Example |
|---|---|---|---|
| Button | `getByRole('button', { name })` | `getByText()` if role missing | `getByRole('button', { name: 'Submit' })` |
| Link | `getByRole('link', { name })` | `getByText()` for anchor text | `getByRole('link', { name: 'Sign up' })` |
| Text input | `getByLabel('...')` | `getByRole('textbox', { name })` | `getByLabel('Email address')` |
| Checkbox | `getByRole('checkbox', { name })` | `getByLabel()` | `getByRole('checkbox', { name: 'Accept terms' })` |
| Radio button | `getByRole('radio', { name })` | `getByLabel()` | `getByRole('radio', { name: 'Express shipping' })` |
| Dropdown / Select | `getByRole('combobox', { name })` | `getByLabel()` | `getByLabel('Country')` |
| Heading | `getByRole('heading', { name, level })` | `getByText()` | `getByRole('heading', { name: 'Dashboard', level: 1 })` |
| Nav link | chain: `getByRole('navigation').getByRole('link', { name })` | scope with `locator('nav')` | see detailed example below |
| Table cell | chain: `getByRole('row').filter().getByRole('cell')` | `locator('td')` scoped | see detailed example below |
| Image | `getByRole('img', { name })` | `getByAltText()` | `getByRole('img', { name: 'Company logo' })` |
| Modal / Dialog | `getByRole('dialog')` then chain within | `locator('[role="dialog"]')` | `getByRole('dialog').getByRole('button', { name: 'Confirm' })` |
| Dynamic list item | `.filter({ hasText })` or `.filter({ has })` | `nth()` as last resort | `getByRole('listitem').filter({ hasText: 'Milk' })` |
| Custom component | `getByTestId('...')` | Add `data-testid` to markup | `getByTestId('color-picker')` |

## Detailed Analysis

### Tier 1: `getByRole()` -- Use by Default

The strongest locator. It mirrors how assistive technology and real users perceive the page.

**Pros**
- Resilient to markup refactors (class names, tag changes)
- Enforces accessible markup -- if the locator breaks, your accessibility broke too
- Works across frameworks (React, Vue, Angular, plain HTML)
- Supports filtering by `name`, `level`, `checked`, `pressed`, `expanded`, `selected`, and (Playwright 1.60+) `description`

**Cons**
- Requires the element to have a valid ARIA role (implicit or explicit)
- Can match multiple elements when names are duplicated -- scope with chaining, `exact`, or `description`

**When it fails**: Custom components with `<div>` soup and no ARIA roles. Fix the component first; add `getByTestId()` only if you cannot change the markup.

**Disambiguating duplicates (Playwright 1.60+)**: when two controls share a name, prefer scoping/chaining first; if they differ only by accessible description (`aria-describedby`), use the `description` option rather than dropping to CSS — `getByRole('button', { name: 'Delete', description: 'Removes this project' })`. See [core/locators.md](locators.md#disambiguating-by-accessible-description-description-playwright-160).

---

### Tier 2: `getByLabel()` -- Form Fields

Queries by the associated `<label>` text. This is often the most readable locator for form fields.

**Pros**
- Extremely readable: `getByLabel('Password')` tells you exactly what field
- Works with `<label for="...">`, wrapping `<label>`, and `aria-labelledby`

**Cons**
- Only works for form elements with labels
- Breaks if someone changes label text (but that is usually intentional)

**Use over `getByRole('textbox')`** when the label text is clear and unique. Use `getByRole()` when you need to differentiate between multiple fields with similar labels by role type.

---

### Tier 3: `getByText()` -- Static Content

Finds elements by their visible text content.

**Pros**
- Intuitive for non-interactive text (paragraphs, spans, badges, status messages)
- Supports exact and substring matching

**Cons**
- Fragile if text is dynamic, translated, or duplicated
- Can match parent elements unintentionally -- use `{ exact: true }` or scope the query

**Rule of thumb**: Use `getByText()` for assertions and content verification, not for interactive elements. Interactive elements should use `getByRole()`.

---

### Tier 4: `getByPlaceholder()` -- Inputs Without Labels

Locates by placeholder attribute value.

**Pros**
- Works when labels are missing (search bars, minimal UIs)

**Cons**
- Placeholders disappear when the user types -- poor UX foundation
- Signals missing accessibility (no label)

**Treat as a yellow flag**: If you use this, consider filing a ticket to add a proper label.

---

### Tier 5: `getByTestId()` -- Last Resort

Locates by `data-testid` attribute.

**Pros**
- Fully decoupled from user-facing text and structure
- Stable under UI redesigns

**Cons**
- Invisible to users and assistive technology
- Pollutes production markup (unless stripped in build)
- Tells you nothing about what the element looks like or does

**Use only when**: The component has no semantic role, no label, no text, and you cannot change the markup. Common cases: canvas elements, third-party widgets, complex custom components.

---

### Never Use: Raw CSS Selectors or XPath

```
// DO NOT do this
page.locator('.btn-primary');           // class names change
page.locator('#submit-btn');            // IDs are brittle
page.locator('div > span:nth-child(2)'); // structure changes break this
page.locator('xpath=//div[@class="foo"]'); // unreadable, fragile
```

If you are reaching for a CSS selector, stop. Walk back up the flowchart and find a semantic locator. If none exists, add `data-testid`.

## Real-World Examples

### 1. Buttons

```typescript
// TypeScript
// Standard button
await page.getByRole('button', { name: 'Submit' }).click();

// Icon-only button (uses aria-label)
await page.getByRole('button', { name: 'Close' }).click();

// Button inside a specific section
await page.getByRole('region', { name: 'Billing' })
  .getByRole('button', { name: 'Update' }).click();
```

```javascript
// JavaScript
// Standard button
await page.getByRole('button', { name: 'Submit' }).click();

// Icon-only button (uses aria-label)
await page.getByRole('button', { name: 'Close' }).click();

// Button inside a specific section
await page.getByRole('region', { name: 'Billing' })
  .getByRole('button', { name: 'Update' }).click();
```

---

### 2. Links

```typescript
// TypeScript
// Standard link
await page.getByRole('link', { name: 'Sign up' }).click();

// Link inside navigation
await page.getByRole('navigation')
  .getByRole('link', { name: 'Pricing' }).click();

// Link with exact match (avoid partial hits)
await page.getByRole('link', { name: 'Log in', exact: true }).click();
```

```javascript
// JavaScript
await page.getByRole('link', { name: 'Sign up' }).click();

await page.getByRole('navigation')
  .getByRole('link', { name: 'Pricing' }).click();

await page.getByRole('link', { name: 'Log in', exact: true }).click();
```

---

### 3. Text Inputs

```typescript
// TypeScript
// Input with a visible label -- preferred
await page.getByLabel('Email address').fill('user@example.com');

// When multiple textboxes exist and you need role specificity
await page.getByRole('textbox', { name: 'Email address' }).fill('user@example.com');

// Textarea
await page.getByLabel('Message').fill('Hello, world');

// Search input (role = searchbox)
await page.getByRole('searchbox', { name: 'Search' }).fill('playwright');
```

```javascript
// JavaScript
await page.getByLabel('Email address').fill('user@example.com');

await page.getByRole('textbox', { name: 'Email address' }).fill('user@example.com');

await page.getByLabel('Message').fill('Hello, world');

await page.getByRole('searchbox', { name: 'Search' }).fill('playwright');
```

---

### 4. Checkboxes and Radios

```typescript
// TypeScript
// Checkbox
await page.getByRole('checkbox', { name: 'Accept terms' }).check();

// Verify checked state
await expect(page.getByRole('checkbox', { name: 'Accept terms' })).toBeChecked();

// Radio button
await page.getByRole('radio', { name: 'Express shipping' }).check();

// Radio within a group (fieldset with legend)
await page.getByRole('group', { name: 'Shipping method' })
  .getByRole('radio', { name: 'Express' }).check();
```

```javascript
// JavaScript
await page.getByRole('checkbox', { name: 'Accept terms' }).check();

await expect(page.getByRole('checkbox', { name: 'Accept terms' })).toBeChecked();

await page.getByRole('radio', { name: 'Express shipping' }).check();

await page.getByRole('group', { name: 'Shipping method' })
  .getByRole('radio', { name: 'Express' }).check();
```

---

### 5. Dropdowns and Selects

```typescript
// TypeScript
// Native <select> element
await page.getByLabel('Country').selectOption('Canada');

// Custom combobox (ARIA combobox role)
await page.getByRole('combobox', { name: 'Country' }).click();
await page.getByRole('option', { name: 'Canada' }).click();

// Listbox pattern
await page.getByRole('combobox', { name: 'Font size' }).click();
await page.getByRole('listbox').getByRole('option', { name: '16px' }).click();
```

```javascript
// JavaScript
await page.getByLabel('Country').selectOption('Canada');

await page.getByRole('combobox', { name: 'Country' }).click();
await page.getByRole('option', { name: 'Canada' }).click();

await page.getByRole('combobox', { name: 'Font size' }).click();
await page.getByRole('listbox').getByRole('option', { name: '16px' }).click();
```

---

### 6. Headings

```typescript
// TypeScript
// Specific heading level
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();

// Any heading with that name (when level does not matter)
await expect(page.getByRole('heading', { name: 'Recent activity' })).toBeVisible();

// Heading within a section
await page.getByRole('region', { name: 'Sidebar' })
  .getByRole('heading', { name: 'Categories' });
```

```javascript
// JavaScript
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();

await expect(page.getByRole('heading', { name: 'Recent activity' })).toBeVisible();

await page.getByRole('region', { name: 'Sidebar' })
  .getByRole('heading', { name: 'Categories' });
```

---

### 7. Navigation Items

```typescript
// TypeScript
// Link inside the main nav
await page.getByRole('navigation')
  .getByRole('link', { name: 'Pricing' }).click();

// When there are multiple navs, narrow by aria-label
await page.getByRole('navigation', { name: 'Main menu' })
  .getByRole('link', { name: 'Pricing' }).click();

// Breadcrumb navigation
await page.getByRole('navigation', { name: 'Breadcrumb' })
  .getByRole('link', { name: 'Products' }).click();
```

```javascript
// JavaScript
await page.getByRole('navigation')
  .getByRole('link', { name: 'Pricing' }).click();

await page.getByRole('navigation', { name: 'Main menu' })
  .getByRole('link', { name: 'Pricing' }).click();

await page.getByRole('navigation', { name: 'Breadcrumb' })
  .getByRole('link', { name: 'Products' }).click();
```

---

### 8. Table Cells

```typescript
// TypeScript
// Find a cell in a specific row
await page.getByRole('row', { name: /Jane Smith/ })
  .getByRole('cell', { name: '$120.00' });

// Click an action button in a specific row
await page.getByRole('row', { name: /Jane Smith/ })
  .getByRole('button', { name: 'Edit' }).click();

// Filter rows using .filter() for complex matching
const row = page.getByRole('row').filter({ hasText: 'Pending' });
await row.getByRole('button', { name: 'Approve' }).click();

// Verify table header exists
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible();
```

```javascript
// JavaScript
await page.getByRole('row', { name: /Jane Smith/ })
  .getByRole('cell', { name: '$120.00' });

await page.getByRole('row', { name: /Jane Smith/ })
  .getByRole('button', { name: 'Edit' }).click();

const row = page.getByRole('row').filter({ hasText: 'Pending' });
await row.getByRole('button', { name: 'Approve' }).click();

await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible();
```

---

### 9. Images

```typescript
// TypeScript
// Image with alt text
await expect(page.getByRole('img', { name: 'Company logo' })).toBeVisible();

// Alternative: getByAltText (same result, less preferred)
await expect(page.getByAltText('Company logo')).toBeVisible();

// Avatar image inside a card
await page.locator('article').filter({ hasText: 'Jane Smith' })
  .getByRole('img', { name: "Jane Smith's avatar" });
```

```javascript
// JavaScript
await expect(page.getByRole('img', { name: 'Company logo' })).toBeVisible();

await expect(page.getByAltText('Company logo')).toBeVisible();

await page.locator('article').filter({ hasText: 'Jane Smith' })
  .getByRole('img', { name: "Jane Smith's avatar" });
```

---

### 10. Custom Components (No ARIA Role)

```typescript
// TypeScript
// Third-party color picker with no semantic role
await page.getByTestId('color-picker').click();

// Custom drag-and-drop zone
await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer });

// Canvas-based chart
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
```

```javascript
// JavaScript
await page.getByTestId('color-picker').click();

await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer });

const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
```

**Before reaching for `getByTestId`, ask yourself**: Can I add `role` and `aria-label` to this component instead? If yes, do that and use `getByRole()`.

---

### 11. Dynamic Lists

```typescript
// TypeScript
// Filter a list item by text
const item = page.getByRole('listitem').filter({ hasText: 'Milk' });
await item.getByRole('button', { name: 'Remove' }).click();

// Filter by a child locator
const card = page.locator('.product-card').filter({
  has: page.getByText('Out of stock'),
});
await expect(card).toHaveCount(3);

// Count items in a list
await expect(page.getByRole('listitem')).toHaveCount(5);

// Iterate over list items for complex assertions
for (const item of await page.getByRole('listitem').all()) {
  await expect(item).toContainText('$');
}
```

```javascript
// JavaScript
const item = page.getByRole('listitem').filter({ hasText: 'Milk' });
await item.getByRole('button', { name: 'Remove' }).click();

const card = page.locator('.product-card').filter({
  has: page.getByText('Out of stock'),
});
await expect(card).toHaveCount(3);

await expect(page.getByRole('listitem')).toHaveCount(5);

for (const item of await page.getByRole('listitem').all()) {
  await expect(item).toContainText('$');
}
```

---

### 12. Modals and Dialogs

```typescript
// TypeScript
// Wait for dialog to appear, then interact within it
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Delete' }).click();

// Fill a form inside a dialog
const modal = page.getByRole('dialog', { name: 'Edit profile' });
await modal.getByLabel('Display name').fill('Jane');
await modal.getByRole('button', { name: 'Save' }).click();

// Verify dialog closed
await expect(dialog).toBeHidden();
```

```javascript
// JavaScript
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Delete' }).click();

const modal = page.getByRole('dialog', { name: 'Edit profile' });
await modal.getByLabel('Display name').fill('Jane');
await modal.getByRole('button', { name: 'Save' }).click();

await expect(dialog).toBeHidden();
```

## Anti-Patterns to Avoid

| Anti-Pattern | Why It Fails | Use Instead |
|---|---|---|
| `page.locator('.btn-primary')` | Class names change during refactors and redesigns | `getByRole('button', { name: '...' })` |
| `page.locator('#email-input')` | IDs are implementation details, not user-visible | `getByLabel('Email')` |
| `page.locator('div > form > input:first-child')` | Any structural change breaks the selector | `getByLabel('...')` or `getByRole('textbox', { name: '...' })` |
| `page.locator('[data-testid="submit"]')` | Raw CSS for test IDs -- use the built-in method | `getByTestId('submit')` |
| `page.getByText('Submit')` for a button | Matches any element with that text, not just the button | `getByRole('button', { name: 'Submit' })` |
| `page.locator('button').nth(2)` | Index-based -- breaks when order changes | `getByRole('button', { name: '...' })` |

## Scoping Strategy: When Multiple Elements Match

When a locator matches more than one element, narrow scope rather than using `nth()`:

```typescript
// BAD: fragile index
page.getByRole('button', { name: 'Edit' }).nth(0);

// GOOD: scope to a parent section
page.getByRole('region', { name: 'Billing' })
  .getByRole('button', { name: 'Edit' });

// GOOD: scope to a table row
page.getByRole('row', { name: /Order #1234/ })
  .getByRole('button', { name: 'Edit' });

// GOOD: scope with filter
page.locator('article').filter({ hasText: 'Draft' })
  .getByRole('button', { name: 'Edit' });
```

## Related

- [Playwright Locators documentation](https://playwright.dev/docs/locators)
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- [ARIA Roles reference (MDN)](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles)
