Finding Elements the Right Way
Master Playwright's locator strategies for reliable element selection
Finding Elements the Right Way
In the previous tutorial, we got Playwright installed and running our first test. Now let's master the most important skill in testing: finding the right elements on the page.
Finding elements sounds simple until your tests start failing because a CSS class changed or an ID got renamed. Playwright's locator system is designed to avoid these headaches.
The Problem with CSS Selectors
You've probably written selectors like this before:
// Fragile - breaks when classes change
await page.click('.btn-primary.submit-form');
// Fragile - breaks when structure changes
await page.click('#app > div:nth-child(2) > button');
// Fragile - tied to implementation details
await page.click('[data-v-4a2b3c]');
These selectors are implementation details. When designers rename a class or developers refactor the DOM, your tests break — even though the app still works fine. Not cool.
The Playwright Way: User-Facing Locators
"So how should I find elements then?"
Playwright encourages you to find elements the way a user would find them:
- "Click the Submit button" →
getByRole('button', { name: 'Submit' }) - "Fill in the Email field" →
getByLabel('Email') - "Check the text Welcome back" →
getByText('Welcome back')
These locators survive refactors because they're based on what users see, not how developers built it.
The Locator Methods
getByRole — Your Go-To Locator
"Which locator should I use first?"
This one. It's the most powerful and recommended locator. It finds elements by their ARIA role.
// Buttons
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', { name: /submit/i }).click(); // regex, case-insensitive
// Links
await page.getByRole('link', { name: 'Sign up' }).click();
// Headings
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Form elements
await page.getByRole('textbox', { name: 'Username' }).fill('john');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('combobox', { name: 'Country' }).selectOption('USA');
// Navigation, lists, etc.
await page.getByRole('navigation').getByRole('link', { name: 'Home' }).click();
await page.getByRole('listitem').filter({ hasText: 'Item 1' }).click();
Why it's great:
- Works across different HTML implementations (
<button>,<input type="button">,<div role="button">) - Tests your accessibility at the same time
- Resilient to CSS/structure changes
Common roles: button, link, textbox, checkbox, radio, combobox, heading, listitem, navigation, main, dialog
getByLabel — For Form Fields
The cleanest way to interact with form inputs:
// These all work
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Email address').fill('test@example.com');
await page.getByLabel(/email/i).fill('test@example.com');
// For fields with complex labels
await page.getByLabel('Password', { exact: true }).fill('secret123');
This works with:
<label for="email">pointing to an input<label>wrapping an inputaria-labelattributesaria-labelledbyreferences
getByPlaceholder — When There's No Label
Some forms don't have proper labels (not great for accessibility, but it happens):
await page.getByPlaceholder('Search...').fill('playwright');
await page.getByPlaceholder('Enter your email').fill('test@example.com');
getByText — Finding by Visible Text
For finding elements by their text content:
// Exact match
await page.getByText('Welcome back').click();
// Case-insensitive
await page.getByText('welcome back', { exact: false }).click();
// Regex
await page.getByText(/welcome/i).click();
// Substring match (default behavior)
await page.getByText('Welcome').click(); // matches "Welcome back" too
Heads up: getByText matches substrings by default. Use { exact: true } if you need exact matches.
getByTestId — Your Escape Hatch
"What if none of those work?"
When nothing else works, add a data-testid attribute:
<button data-testid="submit-order">Complete Purchase</button>
await page.getByTestId('submit-order').click();
When to use it:
- Dynamic content where text changes
- Multiple similar elements
- Third-party components you can't control
- When role-based locators are ambiguous
Pro tip: Configure custom test IDs in your config:
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: 'data-cy', // if you're migrating from Cypress
},
});
getByAltText — For Images
await page.getByAltText('Company logo').click();
await page.getByAltText(/profile/i).click();
getByTitle — For Tooltips
await page.getByTitle('Close dialog').click();
CSS and XPath (When You Really Need Them)
"Can I still use regular CSS selectors?"
Sometimes you need the raw power of CSS or XPath. But use these sparingly — they're more brittle:
// CSS selector
await page.locator('.product-card').first().click();
await page.locator('#main-content').click();
// XPath
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
// Combining with text
await page.locator('article', { hasText: 'Featured' }).click();
Use these sparingly. They're more brittle than the semantic locators.
Chaining Locators
"What if there are multiple matching elements?"
Narrow down your search by chaining:
// Find a button inside a specific section
await page
.getByRole('region', { name: 'Checkout' })
.getByRole('button', { name: 'Pay now' })
.click();
// Find a link in the navigation
await page
.getByRole('navigation')
.getByRole('link', { name: 'Products' })
.click();
// Find an item in a specific list
await page
.locator('.sidebar')
.getByRole('listitem')
.filter({ hasText: 'Settings' })
.click();
Filtering Locators
When you have multiple matches, filter them down:
// Filter by text
await page.getByRole('listitem').filter({ hasText: 'Product A' }).click();
// Filter by NOT having text
await page.getByRole('listitem').filter({ hasNotText: 'Out of stock' }).first().click();
// Filter by child element
await page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'Add to cart' }) })
.click();
// Combine filters
await page
.getByRole('row')
.filter({ hasText: 'John' })
.filter({ has: page.getByRole('cell', { name: 'Active' }) })
.getByRole('button', { name: 'Edit' })
.click();
Getting Specific Elements
When you have multiple matches:
// First, last, nth
await page.getByRole('button').first().click();
await page.getByRole('button').last().click();
await page.getByRole('button').nth(2).click(); // 0-indexed
// Count elements
const count = await page.getByRole('listitem').count();
console.log(`Found ${count} items`);
// Get all elements
const allButtons = await page.getByRole('button').all();
for (const button of allButtons) {
console.log(await button.textContent());
}
Locator Strictness
By default, locators must match exactly one element. If they match multiple, you get an error:
// This throws if there are multiple buttons with "Submit"
await page.getByRole('button', { name: 'Submit' }).click();
// Error: strict mode violation: getByRole('button', { name: 'Submit' })
// resolved to 2 elements
This is a feature, not a bug. It catches ambiguous selectors early. How cool is that?
Fix it by being more specific or using .first().
The Locator Priority Guide
Use this order of preference:
getByRole— Best. Tests accessibility too.getByLabel— Great for form fields.getByPlaceholder— When labels are missing.getByText— For finding by content.getByTestId— Stable, but not user-facing.- CSS/XPath — Last resort.
Debugging Locators
Can't find an element? Use the Playwright Inspector:
npx playwright test --debug
Or add this to pause and inspect:
await page.pause(); // Opens inspector
In the inspector, you can:
- Hover over elements to see their roles/labels
- Try different locators interactively
- Copy working locators to your test
Also try UI Mode (npx playwright test --ui) — it shows you exactly what each locator matched.
Common Mistakes
1. Using implementation-specific selectors
// Bad
await page.locator('.MuiButton-root.Mui-primary').click();
// Good
await page.getByRole('button', { name: 'Submit' }).click();
2. Not using exact match when needed
// Might match "Email preferences" too
await page.getByText('Email').click();
// Better
await page.getByText('Email', { exact: true }).click();
3. Over-specifying selectors
// Too specific - brittle
await page.locator('div.container > section > div.form-group > button.primary').click();
// Just right
await page.getByRole('button', { name: 'Submit' }).click();
4. Forgetting about case sensitivity
// Might not match "SUBMIT" or "Submit"
await page.getByText('submit').click();
// Case-insensitive regex
await page.getByText(/submit/i).click();
What's Next?
Now you know how to find elements like a pro:
getByRoleshould be your first choicegetByLabelis perfect for form fieldsgetByTestIdis your escape hatch for tricky cases- Chain and filter locators to narrow down matches
- Use Playwright Inspector when stuck
You can find elements. Now let's interact with them — clicking, typing, and more. Let's go!