.agents/skills/playwright-best-practices/core/locators.md
Use locators in this order of preference:
getByRolegetByLabel, getByPlaceholdergetByText, getByTitlegetByTestIdlocator('css=...'), locator('xpath=...')Most robust approach - matches how users and assistive technology perceive the page.
// Buttons
page.getByRole('button', {name: 'Submit', exact: true}) // exact accessible name
page.getByRole('button', {name: /submit/i}) // flexible case-insensitive match
// Links
page.getByRole('link', {name: 'Home'})
// Form elements
page.getByRole('textbox', {name: 'Email'})
page.getByRole('checkbox', {name: 'Remember me'})
page.getByRole('combobox', {name: 'Country'})
page.getByRole('radio', {name: 'Option A'})
// Headings
page.getByRole('heading', {name: 'Welcome', level: 1})
// Lists & items
page.getByRole('list').getByRole('listitem')
// Navigation & regions
page.getByRole('navigation')
page.getByRole('main')
page.getByRole('dialog')
page.getByRole('alert')
For form elements with associated labels.
// Input with <label for="email">
page.getByLabel('Email address')
// Input with aria-label
page.getByLabel('Search')
// Exact match
page.getByLabel('Email', {exact: true})
page.getByPlaceholder('Enter your email')
page.getByPlaceholder(/email/i)
// Partial match (default)
page.getByText('Welcome')
// Exact match
page.getByText('Welcome to our site', {exact: true})
// Regex
page.getByText(/welcome/i)
Configure custom test ID attribute in playwright.config.ts:
use: {
testIdAttribute: 'data-testid' // default
}
Usage:
// HTML: <button data-testid="submit-btn">Submit</button>
page.getByTestId('submit-btn')
Narrow down locators:
// Filter by text
page.getByRole('listitem').filter({hasText: 'Product'})
// Filter by NOT having text
page.getByRole('listitem').filter({hasNotText: 'Out of stock'})
// Filter by child locator
page.getByRole('listitem').filter({
has: page.getByRole('button', {name: 'Buy'}),
})
// Combine filters
page
.getByRole('listitem')
.filter({hasText: 'Product'})
.filter({has: page.getByText('$9.99')})
// Navigate down the DOM tree
page.getByRole('article').getByRole('heading')
// Get parent/ancestor
page.getByText('Child').locator('..')
page.getByText('Child').locator('xpath=ancestor::article')
page.getByRole('listitem').first()
page.getByRole('listitem').last()
page.getByRole('listitem').nth(2) // 0-indexed
Locators auto-wait for actionability by default. For explicit state waiting:
await page.getByRole('button').waitFor({state: 'visible'})
await page.getByText('Loading').waitFor({state: 'hidden'})
For comprehensive waiting strategies (element state, navigation, network, polling with
toPass()), see assertions-waiting.md.
// Wait for specific count
await expect(page.getByRole('listitem')).toHaveCount(5)
// Get all matching elements
const items = await page.getByRole('listitem').all()
for (const item of items) {
await expect(item).toBeVisible()
}
Playwright pierces shadow DOM by default:
// Automatically finds elements inside shadow roots
page.getByRole('button', {name: 'Shadow Button'})
// Explicit shadow DOM traversal (if needed)
page.locator('my-component').locator('internal:shadow=button')
// By frame name or URL
const frame = page.frameLocator('iframe[name="content"]')
await frame.getByRole('button').click()
// By index
const frame = page.frameLocator('iframe').first()
// Nested iframes
const nestedFrame = page.frameLocator('#outer').frameLocator('#inner')
await nestedFrame.getByText('Content').click()
// Highlight element in headed mode
await page.getByRole('button').highlight()
// Count matches
const count = await page.getByRole('listitem').count()
// Check if exists without waiting
const exists = (await page.getByRole('button').count()) > 0
// Use Playwright Inspector
// PWDEBUG=1 npx playwright test
| Issue | Solution |
|---|---|
| Multiple elements match | Add filters or use nth(), first(), last() |
| Element not found | Check visibility, wait for load, verify selector |
| Stale element | Locators are lazy; re-query if DOM changes |
| Dynamic IDs | Use stable attributes like role, text, test-id |
| Hidden elements | Use { force: true } only when necessary |
| Anti-Pattern | Problem | Solution |
|---|---|---|
page.locator('.btn-primary') | Brittle, implementation-dependent | page.getByRole('button', { name: 'Submit' }) |
page.locator('#dynamic-id-123') | Breaks when IDs change | Use stable attributes like role, text, or test-id |
| Testing implementation details | Breaks on refactoring | Test user-visible behavior |