
Playwright Locators and Selectors: Finding Elements the Right Way
Finding elements reliably is the foundation of test automation. Get it wrong, and you'll spend more time fixing flaky tests than writing new features. Playwright takes a fundamentally different approach to element selection compared to older frameworks, prioritizing user-visible attributes and accessibility semantics over implementation details.
This guide covers everything you need to know about Playwright locators - from the recommended built-in methods to advanced filtering and chaining techniques.
Table Of Contents-
Locators vs Selectors
Understanding the difference between locators and selectors is crucial for writing maintainable tests.
Selectors are strings that describe how to find an element - like CSS selectors (button.primary) or XPath (//button[@class="primary"]). They're static patterns evaluated at a single point in time.
Locators are Playwright objects that encapsulate a selector along with auto-waiting and retry logic. When you call page.locator('.button'), you get a locator object that will automatically wait for the element and retry if the DOM changes.
// This is a locator - a smart wrapper with auto-waiting
const submitButton = page.locator('button[type="submit"]')
// When you perform an action, the locator:
// 1. Waits for the element to exist
// 2. Waits for it to be visible and stable
// 3. Retries if the element becomes stale
await submitButton.click()Locators are lazy - they don't search for elements until you perform an action or make an assertion. This means you can define locators at the top of your test or in page objects without worrying about timing.
Built-in Locator Methods
Playwright provides specialized methods for common element types. These are preferred over raw CSS selectors because they're more resilient and express intent clearly.
| Method | Purpose | Example |
|---|---|---|
getByRole() | Find by ARIA role | getByRole('button', { name: 'Submit' }) |
getByText() | Find by text content | getByText('Welcome back') |
getByLabel() | Find input by label | getByLabel('Email address') |
getByPlaceholder() | Find input by placeholder | getByPlaceholder('Enter your email') |
getByAltText() | Find image by alt text | getByAltText('Company logo') |
getByTitle() | Find by title attribute | getByTitle('Close dialog') |
getByTestId() | Find by test ID attribute | getByTestId('submit-button') |
These methods query the accessibility tree, not the raw DOM. This means they work correctly with screen readers and reflect what users actually perceive on the page.
Role-Based Locators
getByRole() is Playwright's recommended approach for finding interactive elements. It queries the accessibility tree using ARIA roles, making tests resilient to markup changes while ensuring your app is accessible.
Common Roles
// Buttons
await page.getByRole('button', { name: 'Submit' }).click()
await page.getByRole('button', { name: /confirm/i }).click() // regex
// Links
await page.getByRole('link', { name: 'Home' }).click()
// Form inputs
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
await page.getByRole('checkbox', { name: 'Remember me' }).check()
await page.getByRole('combobox', { name: 'Country' }).selectOption('USA')
// Navigation
await page.getByRole('navigation').locator('a').first().click()
// Headings
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard')
// Lists
const menuItems = page.getByRole('listitem')
await expect(menuItems).toHaveCount(5)Role Options
The getByRole() method accepts options to narrow down matches:
// Match by accessible name
page.getByRole('button', { name: 'Submit' })
// Case-insensitive matching with regex
page.getByRole('button', { name: /submit/i })
// Match exact text
page.getByRole('button', { name: 'Submit', exact: true })
// Match pressed state (for toggle buttons)
page.getByRole('button', { pressed: true })
// Match expanded state (for accordions/menus)
page.getByRole('button', { expanded: false })
// Match selected state
page.getByRole('option', { selected: true })
// Match checked state
page.getByRole('checkbox', { checked: true })
// Match heading level
page.getByRole('heading', { level: 2 })Implicit vs Explicit Roles
Many HTML elements have implicit roles:
| HTML Element | Implicit Role |
|---|---|
<button> | button |
<a href="..."> | link |
<input type="text"> | textbox |
<input type="checkbox"> | checkbox |
<select> | combobox |
<nav> | navigation |
<main> | main |
<article> | article |
<h1> - <h6> | heading |
Elements can also have explicit roles via the role attribute:
<div role="button" tabindex="0">Custom Button</div>Text and Label Locators
getByText()
Finds elements by their visible text content:
// Exact text match
await page.getByText('Welcome to our platform').click()
// Partial match (default behavior)
await page.getByText('Welcome').click()
// Exact match only
await page.getByText('Welcome', { exact: true }).click()
// Case-insensitive with regex
await page.getByText(/welcome/i).click()⚠️
getByText() matches any element containing the text, which can lead to multiple matches. For interactive elements like buttons, prefer getByRole() with the name option.
getByLabel()
Finds form inputs by their associated label - the same way screen readers identify form fields:
// Works with <label for="...">
await page.getByLabel('Email address').fill('user@example.com')
// Works with aria-label
await page.getByLabel('Search').fill('query')
// Works with aria-labelledby
await page.getByLabel('Phone number').fill('555-1234')
// Regex matching
await page.getByLabel(/email/i).fill('user@example.com')getByPlaceholder()
Finds inputs by placeholder text:
await page.getByPlaceholder('Enter your email').fill('test@test.com')
await page.getByPlaceholder(/search/i).fill('query')Note: Placeholder text shouldn't be the only way to identify a field - labels are better for accessibility. But for testing, placeholders can be useful when labels aren't available.
Test ID Locators
Test IDs provide a stable way to locate elements when semantic locators aren't practical:
<button data-testid="submit-form">Submit</button>await page.getByTestId('submit-form').click()Configuring Test ID Attribute
By default, Playwright looks for data-testid. You can change this in your config:
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: 'data-test-id', // or 'data-cy', 'data-qa', etc.
},
})When to Use Test IDs
Test IDs are appropriate when:
- The element has no accessible name or role
- Multiple elements have the same visible text
- The UI is highly dynamic and semantic locators would be unstable
- You need to target implementation details (like specific list items in a sortable list)
// Good use case - no semantic way to distinguish these rows
await page
.getByTestId('user-row-123')
.getByRole('button', { name: 'Delete' })
.click()
// Avoid - role-based is better here
await page.getByTestId('submit-button').click() // Use getByRole('button', {name: 'Submit'}) insteadCSS and XPath Selectors
When built-in locators don't suffice, you can use CSS selectors or XPath with page.locator():
CSS Selectors
// Basic selectors
page.locator('button')
page.locator('.submit-btn')
page.locator('#main-form')
page.locator('[data-status="active"]')
// Combinators
page.locator('form button') // descendant
page.locator('form > button') // direct child
page.locator('h1 + p') // adjacent sibling
page.locator('h1 ~ p') // general sibling
// Attribute selectors
page.locator('[type="submit"]')
page.locator('[href^="https"]') // starts with
page.locator('[href$=".pdf"]') // ends with
page.locator('[class*="primary"]') // contains
// Pseudo-classes
page.locator('li:first-child')
page.locator('li:nth-child(2)')
page.locator('input:not([disabled])')
page.locator('button:visible') // Playwright extension
page.locator('input:focus')XPath Selectors
Prefix XPath expressions with xpath=:
// Basic XPath
page.locator('xpath=//button')
page.locator('xpath=//button[@type="submit"]')
// Text-based
page.locator('xpath=//button[text()="Submit"]')
page.locator('xpath=//button[contains(text(), "Submit")]')
// Traversal
page.locator('xpath=//div[@class="form"]//button')
page.locator('xpath=//button/parent::div')
page.locator('xpath=//button/following-sibling::span')⚠️
XPath and complex CSS selectors are harder to read and maintain. Use them as a last resort when semantic locators aren't possible.
Filtering Locators
Playwright provides methods to narrow down locator matches:
filter()
Filter by text content or nested locators:
// Filter by text
const activeUsers = page.locator('.user-card').filter({ hasText: 'Active' })
// Filter by NOT having text
const inactiveUsers = page
.locator('.user-card')
.filter({ hasNotText: 'Active' })
// Filter by containing another locator
const cardsWithButton = page.locator('.card').filter({
has: page.getByRole('button', { name: 'Edit' }),
})
// Filter by NOT containing a locator
const cardsWithoutBadge = page.locator('.card').filter({
hasNot: page.locator('.premium-badge'),
})
// Combine filters
const premiumActiveUsers = page
.locator('.user-card')
.filter({ hasText: 'Premium' })
.filter({ hasText: 'Active' })nth()
Select by index (0-based):
const rows = page.locator('table tr')
await rows.nth(0).click() // First row
await rows.nth(2).click() // Third row
await rows.nth(-1).click() // Last row
await rows.nth(-2).click() // Second to lastfirst() and last()
await page.locator('.item').first().click()
await page.locator('.item').last().click()Chaining Locators
Chain locators to scope searches within parent elements:
// Find button within a specific section
const modal = page.locator('.modal')
const submitButton = modal.getByRole('button', { name: 'Submit' })
// Navigate through structure
const userEmail = page
.locator('.user-card')
.filter({ hasText: 'John Doe' })
.locator('.email')
// Complex chaining
const activeTaskCheckbox = page
.locator('.task-list')
.getByRole('listitem')
.filter({ hasText: 'Review PR' })
.getByRole('checkbox')Using locator() within locator()
You can call locator() on an existing locator to search within it:
const header = page.locator('header')
const navLinks = header.locator('nav a')
// Equivalent to:
const navLinks = page.locator('header nav a')Working with Lists
Common patterns for handling lists and repeated elements:
Counting Elements
const items = page.locator('.list-item')
await expect(items).toHaveCount(5)
const count = await items.count()
console.log(`Found ${count} items`)Iterating Over Elements
// Get all matching elements
const items = page.locator('.product-card')
const count = await items.count()
for (let i = 0; i < count; i++) {
const name = await items.nth(i).locator('.name').textContent()
console.log(name)
}
// Using all() to get array of locators
const allItems = await items.all()
for (const item of allItems) {
await expect(item).toBeVisible()
}Asserting on Lists
// Check all items have a specific property
const prices = page.locator('.price')
await expect(prices).toHaveText(['$10', '$20', '$30'])
// Check any item matches
await expect(page.locator('.product')).toContainText(['Laptop']) // at least one matchesBest Practices
Priority Order for Locators
Use locators in this priority order:
- getByRole() - Accessible and stable
- getByLabel() - For form inputs
- getByPlaceholder() - When labels aren't available
- getByText() - For static content
- getByTestId() - When semantic locators don't work
- CSS/XPath - Last resort
Make Locators Resilient
// Fragile - tied to implementation
page.locator('div.MuiButton-root.MuiButton-containedPrimary')
// Resilient - based on user perception
page.getByRole('button', { name: 'Save Changes' })Use Descriptive Test IDs
// Unclear
data-testid="btn1"
// Clear
data-testid="submit-registration-form"Avoid Index-Based Selection When Possible
// Fragile - breaks if order changes
await page.locator('button').nth(2).click()
// Better - identifies the specific element
await page.getByRole('button', { name: 'Delete' }).click()Keep Locators in Page Objects
// pages/checkout.page.ts
export class CheckoutPage {
readonly page: Page
readonly continueButton: Locator
readonly cartItems: Locator
readonly promoCodeInput: Locator
constructor(page: Page) {
this.page = page
this.continueButton = page.getByRole('button', {
name: 'Continue to Payment',
})
this.cartItems = page.getByTestId('cart-item')
this.promoCodeInput = page.getByLabel('Promo code')
}
async applyPromoCode(code: string) {
await this.promoCodeInput.fill(code)
await this.page.getByRole('button', { name: 'Apply' }).click()
}
}Mastering locators is essential for building maintainable test suites. By prioritizing accessibility-based methods like getByRole() and getByLabel(), you create tests that are not only resilient to UI changes but also verify that your application is usable by everyone.
Quiz on Playwright Locators
Your Score: 0/10
Question: What is the key difference between a locator and a selector in Playwright?
Continue Reading
Frequently Asked Questions (FAQs) / People Also Ask (PAA)
Why does Playwright recommend getByRole over CSS selectors?
How do I handle elements that appear after AJAX calls?
Can I use XPath selectors in Playwright?
How do I find elements by partial text match?
What's the difference between filter() and locator() chaining?
How do I select the Nth element from a list?
Should I add test IDs to all elements?
How do I debug which element a locator matches?