← Blog

"Playwright Series #3: Locators — The Right Way to Find Elements"

The difference between a brittle test suite and a resilient one is almost entirely about locators. Learn getByRole, getByLabel, getByTestId, chaining, filtering and when to reach for CSS selectors.

reading now
views
comments

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.

0 / 1000