Finding Elements the Right Way

Master Playwright's locator strategies for reliable element selection

7 min readNode.js

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 input
  • aria-label attributes
  • aria-labelledby references

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:

  1. getByRole — Best. Tests accessibility too.
  2. getByLabel — Great for form fields.
  3. getByPlaceholder — When labels are missing.
  4. getByText — For finding by content.
  5. getByTestId — Stable, but not user-facing.
  6. 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:

  • getByRole should be your first choice
  • getByLabel is perfect for form fields
  • getByTestId is 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!