A CSS locator like button.btn-primary-v2 breaks the moment a developer renames that class. getByRole('button', { name: 'Submit' }) survives the rename because it finds the button the way a user does: by role and label. Playwright's six locator types are ordered from most to least recommended, and getByRole is first because its failure mode matches real user impact: if the accessible name changes, the test should break. This guide covers each type, when to use getByLabel or getByTestId instead, chaining with filter(), and what strict mode violation means when a locator matches more than one element.

Why locators matter

The most common cause of flaky tests isn't timing. It's brittle locators. A test that finds a button by its CSS class (button.btn-primary-v2) breaks the moment a developer renames that class. A test that finds a button by its role and label (getByRole('button', { name: 'Submit' })) survives any CSS change because it looks for the button the same way a user does: by what it says and what it does.

Playwright gives you six locator types. They're listed here from most to least recommended.

getByRole: use this first

getByRole finds elements by their ARIA role and accessible name. This is the locator Playwright recommends as the default, and for good reason: it's how users and screen readers identify elements. If the accessible name changes, the test should break, because that's a real UX change.

// Buttons
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Delete item' }).click();

// Links
await page.getByRole('link', { name: 'Home' }).click();
await page.getByRole('link', { name: 'View details' }).click();

// Headings
await expect(page.getByRole('heading', { name: 'My Travel Items' })).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toContainText('Dashboard');

// Form elements
await page.getByRole('textbox', { name: 'Search' }).fill('Tokyo');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('combobox', { name: 'Status' }).selectOption('active');

// Tables
const rows = page.getByRole('row');
await expect(rows).toHaveCount(6); // 1 header + 5 data rows

Common ARIA roles you'll use: button, link, heading, textbox, checkbox, radio, combobox (dropdown), listitem, row, cell, dialog, table, navigation, main.

The name option matches the element's accessible name. For buttons and links, that's the visible text. For inputs, it's the associated label. Case-insensitive by default.

// exact: false (default) — partial match
page.getByRole('button', { name: 'sub' }) // matches "Submit", "Subscribe"

// exact: true — full match only
page.getByRole('button', { name: 'Submit', exact: true }) // only "Submit"

getByLabel: for form inputs

getByLabel finds an input, select, or textarea by its associated element. This is the right locator for login forms, search bars, and any form field that has a visible label.

await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Date of birth').fill('1990-01-15');

It works whether the label uses for/id, aria-label, or wraps the input. You don't need to know how the label is implemented. Playwright figures it out.

<!-- All three of these are found by getByLabel('Email') -->
<label for="email">Email</label><input id="email" />
<label><span>Email</span><input /></label>
<input aria-label="Email" />

getByPlaceholder: when there's no label

Some inputs have placeholder text instead of a visible label. getByPlaceholder handles this case.

await page.getByPlaceholder('Search destinations...').fill('Tokyo');
await page.getByPlaceholder('Enter your email').fill('test@example.com');

Prefer getByLabel when a label exists. getByPlaceholder is for inputs that only have placeholder text.

getByText: for non-interactive elements

getByText finds elements by their visible text content. Use it for text you want to verify exists on the page, not for elements you want to click (use getByRole for those).

// Check that text is present
await expect(page.getByText('My Travel Items')).toBeVisible();
await expect(page.getByText('Invalid credentials')).toBeVisible();

// Exact vs partial matching
page.getByText('Travel')          // matches "My Travel Items", "Travel guide"
page.getByText('Travel', { exact: true })  // only exact "Travel"

// Scoped to a specific element type
page.locator('p').getByText('Error occurred')  // only <p> elements

getByText finds all elements containing that text, including parent containers. If "Submit" appears in a paragraph and a button, getByText('Submit') returns multiple elements. For interactive elements, use getByRole instead.

getByTestId: the explicit contract

getByTestId finds elements by their data-testid attribute (configurable). This is the locator to use when developers explicitly add test hooks to the DOM.

<button data-testid="submit-payment">Pay now</button>
<div data-testid="success-message">Payment complete</div>

await page.getByTestId('submit-payment').click();
await expect(page.getByTestId('success-message')).toBeVisible();

The advantage: data-testid attributes are invisible to users and have no functional meaning, so developers won't accidentally rename them. The disadvantage: someone has to add them to the code. For apps you control, this is fine. For third-party apps, you're stuck with whatever structure exists.

If your team doesn't use data-testid yet, propose it. Ask developers to add data-testid attributes to key interactive elements. It takes minutes per component and makes locators trivially stable.

getByAltText and getByTitle

Two less common but occasionally useful locators:

// Images with alt text
await page.getByAltText('User profile picture').click();
await expect(page.getByAltText('Company logo')).toBeVisible();

// Elements with title attributes
await page.getByTitle('Close dialog').click();

You'll use these rarely. Most interactive elements should be reachable via getByRole.

Chaining locators

When you need to narrow down to a specific element within a container, chain locators:

// Find a row containing "Tokyo", then click its Delete button
const tokyoRow = page.getByRole('row').filter({ hasText: 'Tokyo' });
await tokyoRow.getByRole('button', { name: 'Delete' }).click();

// Find a form section, then interact with its input
const addressSection = page.locator('.address-section');
await addressSection.getByLabel('City').fill('Warsaw');

filter({ hasText: '...' }) narrows a locator to elements containing specific text. Combined with nth() for indexed selection:

// First row in the table (index 0)
const firstRow = page.getByRole('row').nth(1); // nth(0) is header, nth(1) is first data row
await firstRow.getByRole('button', { name: 'Edit' }).click();

What to avoid

CSS selectors. Fragile, implementation-specific, break on refactors:

// Bad
page.locator('.btn.btn-primary')
page.locator('#submit-button')
page.locator('div > ul > li:nth-child(3)')

XPath. Verbose, brittle, hard to read:

// Bad
page.locator('//button[@class="btn btn-primary" and text()="Submit"]')

Text selectors without context. Ambiguous when text appears multiple places:

// Risky — what if "Edit" appears in multiple places?
page.getByText('Edit')
// Better — scoped to the row you care about
page.getByRole('row').filter({ hasText: 'Tokyo' }).getByRole('button', { name: 'Edit' })

Practical exercise on lab.becomeqa.com

Open https://lab.becomeqa.com and try to write locators for:

1. The Login button in the navigation

2. The Username and Password inputs in the login modal

3. The Submit button in the login modal

4. The table row containing a specific destination after login

5. The Add Item button on the dashboard

import { test, expect } from '@playwright/test';

test('locator practice', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');

  // 1. Navigation login button
  await page.getByRole('button', { name: 'Login' }).click();

  // 2 & 3. Login form
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();

  // 4. Specific row in table
  const parisRow = page.getByRole('row').filter({ hasText: 'Paris' });
  await expect(parisRow).toBeVisible();

  // 5. Add item button
  await expect(page.getByRole('button', { name: 'Add Item' })).toBeVisible();
});

FAQ

When should I use locator() directly instead of the getBy* methods?

When you need CSS or attribute selectors that the getBy* methods don't cover. For example, page.locator('[data-status="active"]') finds all elements with a specific data attribute value. Use it as a last resort, not a first choice.

Can I combine multiple locators?

Yes. locator.and(otherLocator) finds elements matching both:

// A button that is both visible and has the text "Submit"
page.getByRole('button').and(page.getByText('Submit'))

What if two elements match my locator?

Playwright throws strict mode violation if a locator matches more than one element when you try to interact with it. Fix it by making the locator more specific: add a filter, use nth(), or scope it to a parent container.

How do I debug a locator that isn't finding anything?

Use highlight mode in Playwright Inspector: PWDEBUG=1 npx playwright test. Or call await locator.highlight() in your test to visually mark the matched element during a headed run.

→ See also: Playwright Assertions: The Complete Guide | Getting Started with Playwright: Your First Tests in 30 Minutes | Playwright Codegen: Record Tests Without Writing Code | How to Read Playwright Error Messages