Quick answer
A Locator is a lazy reference to element(s) on the page. It auto-waits, auto-retries, and is strict by default. The five modern locators are getByRole, getByLabel, getByPlaceholder, getByText, and getByTestId. Use them in that order.
1. The locator priority order
| Priority | Locator | Use for |
|---|---|---|
| 1 | getByRole('button', { name }) | Anything with a semantic role — buttons, links, headings |
| 2 | getByLabel('Email') | Form inputs paired with a <label> |
| 3 | getByPlaceholder('Search…') | Inputs with placeholder-only labels |
| 4 | getByText('Sign in') | Unique visible text, banners, error messages |
| 5 | getByTestId('cart-badge') | Non-semantic elements or noisy text — last resort |
| — | locator('css=…') / xpath | Third-party widgets only. Avoid otherwise. |
getByRole can find it too — and your test doubles as an accessibility check.2. getByRole — the workhorse
// Buttons — the name is the visible/accessible label
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('button', { name: /save|submit/i }).click();
// Links — even if wrapped in weird markup
await page.getByRole('link', { name: 'Pricing' }).click();
// Headings & landmarks
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await page.getByRole('navigation').getByRole('link', { name: 'Docs' }).click();
// Checkboxes & radios
await page.getByRole('checkbox', { name: 'I agree' }).check();
await page.getByRole('radio', { name: 'Standard shipping' }).check();
// Rows & cells inside a table
const row = page.getByRole('row', { name: /^Alice/ });
await expect(row.getByRole('cell').nth(2)).toHaveText('Admin');Options you'll actually use
{ name: "Save" }— exact or regex accessible name.{ exact: true }— disable substring/case matching.{ level: 2 }— headings only (h1, h2…).{ checked: true }— filter by state.{ pressed: true }— toggle buttons.
3. Form locators — label wins over placeholder
// Preferred: matches <label for="email">Email</label>
await page.getByLabel('Email').fill('a@b.co');
// Placeholder-only inputs
await page.getByPlaceholder('Search products…').fill('lens');
// Alt text for images
await page.getByAltText('Company logo').click();
// Title attribute (tooltips)
await page.getByTitle('Delete row').click();4. getByText & filter
// Exact or substring visible text
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('Payment successful', { exact: true })).toBeVisible();
// Regex — dynamic text
await expect(page.getByText(/order #\d{6}/)).toBeVisible();
// Filter matching elements
const rows = page.getByRole('row');
await rows.filter({ hasText: 'Alice' }).getByRole('button', { name: 'Edit' }).click();
await rows.filter({ hasNotText: 'Archived' }).first().click();5. Chaining & scoping locators
// Scope every action to the "Orders" section — no full-page ambiguity
const orders = page.getByRole('region', { name: 'Orders' });
await orders.getByRole('button', { name: 'Refresh' }).click();
await expect(orders.getByRole('row')).toHaveCount(10);
// Reusable locator variable — DRY, readable
const cartBadge = page.getByTestId('cart-badge');
await addToCart(page);
await expect(cartBadge).toHaveText('1');
// nth / first / last
await page.getByRole('article').first().click();
await page.getByRole('article').nth(2).click();
await page.getByRole('article').last().click();const is free and keeps tests readable.6. Strict mode — your friend, not your enemy
When a locator matches 2+ elements, Playwright refuses to guess and throws strict mode violation. The error tells you every match — pick a narrower locator:
// ❌ throws — three buttons named "Delete" on the page
await page.getByRole('button', { name: 'Delete' }).click();
// ✅ scope to the row you actually mean
await page.getByRole('row', { name: 'Milk' })
.getByRole('button', { name: 'Delete' })
.click();
// ✅ or filter by proximity
await page.getByRole('button', { name: 'Delete' })
.and(page.locator(':near(:text("Milk"))'))
.click();7. Common mistakes to avoid
.btn.btn-primary.mt-4 is 4 selectors on one element. Any style tweak breaks the test. Replace with getByRole('button', { name: /save/i }).
Don't copy #__next > main > div:nth-child(3) > ul > li:nth-child(2) from DevTools. Ask: what would a user say to identify this element? Turn that into a role/label.
Locators auto-wait — you almost never need waitForSelector. Assert visibility instead: await expect(locator).toBeVisible().
8. Hands-on task (15 minutes)
- 1
Open the demo TodoMVC app
Navigate to
https://demo.playwright.dev/todomvc. - 2
Rewrite selectors using only role/label/text
Open UI Mode, click "Pick locator" and rewrite three actions using
getByRole,getByPlaceholder, andgetByText. No CSS, no XPath. - 3
Trigger strict mode
Add three todos, then try
page.getByRole('button', { name: "×" }). Fix with.filter({ hasText }). - 4
Verify a11y bonus
Anywhere
getByRolecouldn't find the element, ask a dev to add a proper role/label — you just improved accessibility.
9. What's next
Locators point at elements. Assertions check them. Continue to Module 2 / Lesson 2: Web-First Assertions.