Series Navigation
← Part 2: Your First Playwright Test
→ Part 4: Assertions — Expect, Soft Assertions and Custom Matchers
What Is a Locator?
A locator is Playwright's way of describing which element you want to interact with. Unlike raw selectors, Playwright locators are lazy — they don't actually find the element until you act on them. This means they re-query the DOM on every interaction, automatically handling dynamic content.
// This doesn't touch the DOM yet
const button = page.getByRole('button', { name: 'Submit' });
// This finds the element and clicks it
await button.click();
// If the page re-renders, this re-finds the element — no stale references
await button.click();
This is fundamentally different from Selenium's findElement, which returns a reference that can go stale.
The Locator Priority Hierarchy
Playwright's own guidance recommends this priority order. Higher = more resilient:
1. getByRole() ← best, tests like a user thinks
2. getByLabel() ← for form inputs
3. getByPlaceholder() ← for inputs without labels
4. getByText() ← for non-interactive text content
5. getByAltText() ← for images
6. getByTitle() ← for elements with title attribute
7. getByTestId() ← when semantic locators aren't possible
8. CSS / XPath ← last resort, most brittle
getByRole — The Gold Standard
getByRole finds elements by their ARIA role — the semantic meaning of the element in the accessibility tree. This is how screen readers and assistive technology see your app, and it's the most resilient locator available.
// Buttons
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('button', { name: /submit/i }).click(); // regex, case-insensitive
// Links
await page.getByRole('link', { name: 'Home' }).click();
// Headings
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome');
// Text inputs (role is 'textbox')
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
// Checkboxes
await page.getByRole('checkbox', { name: 'Remember me' }).check();
// Radio buttons
await page.getByRole('radio', { name: 'Monthly' }).click();
// Comboboxes (select dropdowns)
await page.getByRole('combobox', { name: 'Country' }).selectOption('India');
// Table cells
await expect(page.getByRole('cell', { name: '$49.99' })).toBeVisible();
// Navigation
await page.getByRole('navigation').getByRole('link', { name: 'Blog' }).click();
The name option matches the accessible name — this is usually the visible label text, aria-label, or aria-labelledby content.
Common ARIA roles you'll use daily:
| Role | Matches |
|---|---|
button |
<button>, [role=button] |
link |
<a href> |
textbox |
<input type=text>, <textarea> |
checkbox |
<input type=checkbox> |
radio |
<input type=radio> |
combobox |
<select>, custom dropdowns |
heading |
<h1> through <h6> |
img |
<img> |
list |
<ul>, <ol> |
listitem |
<li> |
table |
<table> |
row |
<tr> |
cell |
<td> |
dialog |
<dialog>, modal overlays |
navigation |
<nav> |
main |
<main> |
alert |
error/success banners |
getByLabel — For Form Fields
getByLabel finds an input by its associated <label> text. This is the right locator for any labeled form field.
// Matches <label>Email address</label> paired with an <input>
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByLabel('Date of birth').fill('1990-01-15');
await page.getByLabel('Newsletter').check(); // for labeled checkboxes
This works whether the label uses for/id, wraps the input, or uses aria-labelledby.
getByPlaceholder — For Inputs Without Labels
When an input has no label but has placeholder text:
await page.getByPlaceholder('Search products...').fill('headphones');
await page.getByPlaceholder('Enter your email').fill('user@example.com');
getByText — For Text Content
Find elements by their visible text:
// Exact match
await page.getByText('Sign out').click();
// Partial match (default)
await page.getByText('Welcome').click();
// Exact match explicitly
await page.getByText('Submit', { exact: true }).click();
// Regex
await page.getByText(/order confirmed/i).click();
Use getByText for non-interactive content. For buttons and links, prefer getByRole.
getByTestId — Your Safety Net
When no semantic locator works, add a data-testid attribute to your HTML and use getByTestId:
<div class="product-card" data-testid="product-card-42">...</div>
await page.getByTestId('product-card-42').click();
By default Playwright looks for data-testid. You can change this in config:
// playwright.config.ts
use: {
testIdAttribute: 'data-qa', // use data-qa instead
}
getByTestId is more resilient than CSS selectors because it doesn't break when class names or structure change. But it requires your team to add and maintain the attributes.
Chaining Locators — Narrowing Scope
Chain locators to find elements within other elements:
// Find the "Add to Cart" button inside the first product card
await page.getByTestId('product-card').first().getByRole('button', { name: 'Add to Cart' }).click();
// Find a link inside the navigation (not anywhere on the page)
await page.getByRole('navigation').getByRole('link', { name: 'Account' }).click();
// Find a cell in a specific row
const row = page.getByRole('row', { name: 'Invoice #1042' });
await row.getByRole('button', { name: 'Download' }).click();
Chaining is the key to avoiding ambiguous locators when the same text appears in multiple places.
Filtering Locators
Use .filter() to narrow a locator by additional criteria:
// All list items, filtered to those containing "Pro"
const proItems = page.getByRole('listitem').filter({ hasText: 'Pro' });
// All product cards that contain an "Out of stock" badge
const outOfStock = page.getByTestId('product-card').filter({
has: page.getByText('Out of stock')
});
// Cards that do NOT contain "Out of stock"
const inStock = page.getByTestId('product-card').filter({
hasNot: page.getByText('Out of stock')
});
await expect(inStock).toHaveCount(4);
Working with Multiple Elements
When a locator matches multiple elements:
const items = page.getByRole('listitem');
// Count them
const count = await items.count();
// Get the first / last
await items.first().click();
await items.last().click();
// Get by index (zero-based)
await items.nth(2).click();
// Iterate over all
for (const item of await items.all()) {
console.log(await item.textContent());
}
// Assert on all of them
await expect(items).toHaveCount(5);
await expect(items).toContainText(['Item A', 'Item B', 'Item C']);
CSS Selectors — When You Need Them
CSS selectors are available but should be your last choice:
// ID selector
await page.locator('#submit-button').click();
// Class selector (fragile — classes change often)
await page.locator('.btn-primary').click();
// Attribute selector
await page.locator('[data-status="active"]').click();
// Compound selector
await page.locator('form.checkout input[type="email"]').fill('user@example.com');
The problem with CSS selectors: they couple your tests to implementation details. When a developer renames a class or restructures the DOM, your tests break even though the user experience didn't change.
XPath — Almost Never
XPath is supported but almost always the wrong choice:
// Possible but fragile
await page.locator('xpath=//button[@type="submit"]').click();
// The same thing, better written as:
await page.getByRole('button', { name: 'Submit' }).click();
The one case where XPath is genuinely useful: finding an element relative to its text sibling when no other approach works.
// "Find a button that comes after a heading that says 'Featured'"
await page.locator('xpath=//h2[text()="Featured"]/following-sibling::button').click();
Real-World Locator Examples
Here's how to approach common scenarios:
// ── Login form ──────────────────────────────────────────────────────────────
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Log in' }).click();
// ── Navigation ──────────────────────────────────────────────────────────────
await page.getByRole('navigation').getByRole('link', { name: 'Settings' }).click();
// ── Modal dialog ────────────────────────────────────────────────────────────
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await modal.getByRole('button', { name: 'Confirm' }).click();
// ── Data table ──────────────────────────────────────────────────────────────
const table = page.getByRole('table');
const row = table.getByRole('row').filter({ hasText: 'Order #1042' });
await expect(row.getByRole('cell').nth(2)).toHaveText('$49.99');
await row.getByRole('button', { name: 'View' }).click();
// ── Toast notification ───────────────────────────────────────────────────────
await expect(page.getByRole('alert')).toContainText('Saved successfully');
// ── Dropdown menu ────────────────────────────────────────────────────────────
await page.getByRole('button', { name: 'Account' }).click();
await page.getByRole('menuitem', { name: 'Sign out' }).click();
// ── File upload ──────────────────────────────────────────────────────────────
await page.getByLabel('Upload resume').setInputFiles('resume.pdf');
// ── Date picker ──────────────────────────────────────────────────────────────
await page.getByLabel('Check-in date').fill('2025-06-15');
The locator() Method
page.locator() accepts CSS selectors, XPath, or Playwright's extended selector syntax:
// CSS
page.locator('button.primary')
// XPath
page.locator('xpath=//button')
// Text selector (shorthand)
page.locator('text=Submit')
// Playwright pseudo-classes
page.locator('button:has-text("Submit")')
page.locator('button:visible')
page.locator(':nth-match(button, 3)') // third button on page
Debugging Locators
When a locator isn't finding what you expect, use the Playwright Inspector:
npx playwright test --debug
Or pause inside a test:
test('debug this', async ({ page }) => {
await page.goto('/checkout');
await page.pause(); // opens Playwright Inspector here
await page.getByRole('button', { name: 'Pay' }).click();
});
The Inspector lets you hover over elements to see what locators Playwright suggests for them.
What's Next
In Part 4 we cover assertions — the expect API, soft assertions that don't stop a test on first failure, polling assertions for async UI, and how to write custom matchers for your own data types.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.