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 rowsCommon 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> elementsgetByText 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.
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)')// Bad
page.locator('//button[@class="btn btn-primary" and text()="Submit"]')// 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 uselocator() 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.
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'))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.
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.