Module 2: Locators Mastery
A bad locator makes your tests flaky. A good locator makes them robust — resilient to style changes, refactors, and data refreshes. Locators are a design decision, not an afterthought.
🎬 Video coming soon
New to locators? Start here
If you’ve never used a browser-automation tool, here’s the whole idea in a minute:
- A locator is how you point Playwright at an element on the page — think of it as an address for “the Log In button” or “the email field.”
- It’s lazy: creating a locator doesn’t grab the element yet. Playwright re-finds it (and waits for it to be ready) every time you act on it — that’s what makes locators resilient to timing.
- Every action starts with a locator:
page.getByRole('button', { name: 'Log In' }).click()means locate the button, then click it. - A locator can match one element or many. When you act, Playwright insists it match exactly one — that’s strict mode (covered later in this module).
- There are several ways to locate an element — by role, label, text, test-id, or raw CSS. Choosing the best one is exactly what the rest of this module teaches.
Don’t worry about memorizing these — they’ll click as you work through the examples below.
Why locator strategy matters
Section titled “Why locator strategy matters”Every Playwright action — click, fill, check, expect — starts with a locator. The locator tells Playwright which element to act on. Pick the wrong strategy and your tests will fail on the first designer sprint.
Here are three fragile locators from real codebases:
// ❌ Fragile: CSS class changes when a designer tweaks layoutpage.locator('.btn.btn-primary.mt-2').click();
// ❌ Fragile: breaks when a wrapper <div> is addedpage.locator('div > div > form > button').click();
// ❌ Fragile: exact text match breaks on whitespace changespage.locator('text="Log In"').click();Compare those with their resilient equivalents:
// ✅ Resilient: matches how assistive technology sees the pagepage.getByRole('button', { name: 'Log In' }).click();
// ✅ Resilient: tied to the <label> associationpage.getByLabel('Email').fill('user@test.io');
// ✅ Resilient: explicit, never changes unless you change itpage.getByTestId('submit-btn').click();Playwright’s recommended locator priority:
getByRole— prefer this firstgetByLabel— for form fieldsgetByPlaceholder— when no label existsgetByText— for links and visible textgetByTestId— when nothing else is stable- CSS / XPath — last resort
getByRole — the recommended default
Section titled “getByRole — the recommended default”getByRole matches elements the way assistive technology (screen readers) sees them. It uses the element’s implicit ARIA role and its accessible name. In plain terms: a role is what an element is (button, link, heading), and its accessible name is the human-readable label a screen reader announces for it — usually its visible text.
// Matches <button>Log In</button>page.getByRole('button', { name: 'Log In' })
// Matches <a href="/register">Register</a>page.getByRole('link', { name: 'Register' })
// Matches <h1>Welcome to TestMarket</h1>page.getByRole('heading', { name: 'Welcome to TestMarket' })
// Matches <select> associated with a label "Category"page.getByRole('combobox', { name: 'Category' })Common role mappings:
| HTML Element | ARIA Role |
|---|---|
<button> | button |
<a href> | link |
<input type="text"> | textbox |
<input type="checkbox"> | checkbox |
<select> | combobox |
<h1>–<h6> | heading |
<img alt="..."> | img |
Role + accessible name
Section titled “Role + accessible name”The name option is what makes getByRole precise. Without it, getByRole('button') matches every button on the page — a guaranteed strict mode violation (Playwright refuses to act when a locator matches more than one element; full explanation below).
The accessible name comes from:
- Button / link text content:
<button>Log In</button>→ name is'Log In' aria-labelattribute:<button aria-label="Close dialog">X</button>→ name is'Close dialog'alton images:<img alt="Company logo">→ name is'Company logo'- Associated
<label>for form fields:<label for="email">Email</label>→ name is'Email'
// ✅ Precise — name option narrows to exactly one buttonpage.getByRole('button', { name: 'Add to Cart' })
// ❌ Imprecise — matches all buttons, causes strict mode violationpage.getByRole('button')Remember: getByRole is the default — match an element by what it is (its role) plus its accessible name.
getByText — matching visible text
Section titled “getByText — matching visible text”getByText matches any element that contains the given text. Use it for links and standalone text elements, but use it sparingly — it matches any element in the DOM.
// Matches <a href="/products/new">New Product</a>page.getByText('New Product')
// Exact match — prevents partial matches on similar textpage.getByText('Log In', { exact: true })
// Regex match — useful for case-insensitive or partial matchingpage.getByText(/login/i)When to use getByText: links where no accessible role is more descriptive, or standalone paragraph text you want to assert is visible.
When NOT to use it: form fields (use getByLabel), buttons (use getByRole).
Remember: use getByText for links and standalone text, sparingly; add { exact: true } to avoid partial matches.
getByLabel — the best choice for form fields
Section titled “getByLabel — the best choice for form fields”getByLabel matches form inputs via their associated <label> element. It respects the for / id association and the aria-labelledby / aria-label attributes.
// Matches <input id="email"> with <label for="email">Email</label>page.getByLabel('Email').fill('customer@test.io')
// Matches <input id="password"> with <label for="password">Password</label>page.getByLabel('Password').fill('customer123')
// Matches <input id="shipping_name"> with label "Shipping Name"page.getByLabel('Shipping Name').fill('Jane Smith')This is the right choice for login forms, checkout forms, and admin product forms — wherever the HTML has proper <label> associations.
Remember: a form field with a visible label → reach for getByLabel first.
getByPlaceholder — when there is no label
Section titled “getByPlaceholder — when there is no label”When a field has no <label> but has a placeholder attribute, use getByPlaceholder.
// Matches <input placeholder="Search products...">page.getByPlaceholder('Search products...')
// Matches <input placeholder="Enter your email address">page.getByPlaceholder('Enter your email address')Use this only when there is genuinely no label. If a label exists, getByLabel is more robust (labels are less likely to change than placeholder text).
Remember: use getByPlaceholder only when there’s genuinely no label.
getByTestId — stable but requires code changes
Section titled “getByTestId — stable but requires code changes”getByTestId matches elements with a data-testid attribute. It is the most stable locator because test IDs are changed intentionally and never by accident.
// Matches <button data-testid="submit-btn">Place Order</button>page.getByTestId('submit-btn').click()
// Matches <div data-testid="product-card">...</div>page.getByTestId('product-card')When to add data-testid: when an element has no accessible name, no stable text, and no unique ID. Common examples: generic <div> containers, SVG icons, complex widget boundaries.
Don’t add data-testid to every element. Prefer role-based locators first.
Remember: getByTestId is the most stable, but add data-testid only when role/label/text can’t identify the element.
CSS locators as fallback
Section titled “CSS locators as fallback”CSS locators are not bad — they are just less resilient. Use them when:
- Selecting by ID (
#emailis very stable) - Using attribute selectors (
button[type="submit"]) - Selecting for generic elements with no accessible role
- Targeting structural classes that almost never change
// ID selector — stable and fastpage.locator('#shipping_name')
// Attribute selector — better than class-basedpage.locator('button[type="submit"]')
// Class composition for flash messages — acceptablepage.locator('.alert.alert-success')
// Product card grid itempage.locator('.product-card')Prefer IDs over classes. CSS classes change when designers refactor styling. IDs rarely change because they’re also used for <label for="..."> associations and JavaScript hooks.
Remember: CSS is a fallback — prefer IDs over classes (classes change when styling is refactored).
Find a locator by inspecting the page
Section titled “Find a locator by inspecting the page”You rarely guess a locator — you read it off the page. Three tools do the heavy lifting:
1. Browser DevTools. Right-click the element → Inspect. In the Elements panel you can read its tag, its role/aria attributes, its label, and any data-testid — exactly the things getByRole, getByLabel, and getByTestId rely on.
2. npx playwright codegen — let Playwright write the locator for you. Run:
npx playwright codegen https://your-app.testA browser opens; as you click and type, Playwright writes the matching locators (preferring getByRole/getByLabel) into a window you can copy from. It’s the fastest way to learn good locators — you see the recommended one for every element.
3. page.pause() + the Playwright Inspector. Drop await page.pause(); into a test and run it; the Inspector opens with a Pick locator button. Hover any element and it shows the locator Playwright suggests — great for debugging a tricky element mid-test.
Chained locators
Section titled “Chained locators”Chaining lets you navigate the DOM tree with precision. Start broad — find the container — then drill into child elements.
The simplest chain is two steps — container → child:
// 1. Find the product card (the container)const card = page.locator('.product-card').first();
// 2. Find the button inside that card (the child)await card.getByRole('button', { name: 'Add to Cart' }).click();Each step narrows the search to inside the previous result. Build up from there — here’s a fuller example on the admin products table:
// Find the admin products table, then all rowsconst rows = page.locator('table tbody tr.table-row');
// Get the second row (index 1)const secondRow = rows.nth(1);
// Get the product name (second column) in the second rowconst productName = secondRow.locator('td').nth(1);
// Click the Edit button in the last rowawait rows.last().locator('a:has-text("Edit")').click();Cart example — two levels of chaining:
// Get the quantity input in the first cart rowconst firstCartQuantity = page.locator('.cart-table .table-row') .first() .locator('input[name="quantity"]');Performance tip: Playwright evaluates locators lazily. Defining a long chain has zero cost — the chain resolves only when you perform an action on it.
Remember: chain broad → narrow (container → child) to pin down one element.
filter({ hasText }) and filter({ has })
Section titled “filter({ hasText }) and filter({ has })”filter narrows a set of matching elements to those that meet an extra condition. It is more readable than complex CSS selectors.
filter({ hasText })
Section titled “filter({ hasText })”Matches elements whose text content (or any descendant’s text) contains the given string. Case-insensitive, substring match.
// Admin dashboard: all stat cards look the same — filter by heading textconst totalProductsCard = page.locator('.card') .filter({ hasText: 'Total Products' });
// Get the count value from that specific cardconst count = await totalProductsCard.locator('p').textContent();
// Find the product card for "Gaming Keyboard"await page.locator('.product-card') .filter({ hasText: 'Gaming Keyboard' }) .locator('button:has-text("Add to Cart")') .click();filter({ has })
Section titled “filter({ has })”Matches elements that contain a child element matching the given locator. Use when you need structural matching rather than text matching.
// Find all stat cards that contain a <p> elementconst allStatCards = page.locator('.card') .filter({ has: page.locator('p') });
// Find all order rows that have a "pending" status badgeconst pendingRows = page.locator('tr.table-row') .filter({ has: page.locator('.badge-warning') });Gotcha: hasText matches text in descendants too. If your search text appears in multiple places within the element, you may get unexpected matches. Be specific enough to narrow to one element.
Remember: narrow a matched set with filter({ hasText }) or filter({ has }) before acting.
.first(), .last(), .nth()
Section titled “.first(), .last(), .nth()”When a locator matches multiple elements and you need one specific one:
const rows = page.locator('table tbody tr.table-row');
await rows.first().locator('td').nth(0).textContent(); // first row, first cellawait rows.last().locator('button:has-text("Delete")').click(); // last row deleteawait rows.nth(2).locator('a:has-text("Edit")').click(); // third row editPrefer filter({ hasText }) over .nth() when you know the content. Index-based selection breaks if rows are reordered; content-based selection is robust.
// ❌ Fragile — breaks if sort order changesawait rows.nth(0).locator('button:has-text("Edit")').click();
// ✅ Robust — finds the right row regardless of sort orderawait rows.filter({ hasText: 'Wireless Mouse' }) .locator('button:has-text("Edit")') .click();Remember: prefer a more specific locator over .nth() — index-based picks break when order changes.
Strict mode violations
Section titled “Strict mode violations”Playwright’s strict mode throws an error whenever your locator matches more than one element and you perform an action like click, fill, or textContent. This is intentional — Playwright refuses to guess which element you meant.
Error: strict mode violation: page.locator('button') resolved to 15 elementsWhy it happens
Section titled “Why it happens”// ❌ 15 buttons on the page — which one?await page.locator('button').click();
// ❌ Multiple links — which one?await page.locator('a').click();
// ❌ Two <select> elements on the pageawait page.locator('select').selectOption('electronics');Three fixes
Section titled “Three fixes”Fix 1 — Be more specific with role + name:
// ✅ Only one button is labeled "Log In"await page.getByRole('button', { name: 'Log In' }).click();
// ✅ Only one link is labeled "Register"await page.getByRole('link', { name: 'Register' }).click();
// ✅ Only one select has the label "Category"await page.getByRole('combobox', { name: 'Category' }).selectOption('electronics');Fix 2 — Chain into a container:
// ✅ Scope the button to its parent containerawait page.locator('.login-form').getByRole('button', { name: 'Log In' }).click();
// ✅ Find the Remove button within the right cart rowawait page.locator('.cart-table .table-row') .filter({ hasText: 'Wireless Mouse' }) .getByRole('button', { name: 'Remove' }) .click();Fix 3 — Use .first(), .last(), or .nth() when order is stable:
// ✅ First notification close button — acceptable when order is guaranteedawait page.locator('.notification button.close').first().click();Dynamic IDs — a strict-mode adjacent problem
Section titled “Dynamic IDs — a strict-mode adjacent problem”Some pages generate IDs with random or data-driven values:
<!-- Product detail page — slug or hash in the ID --><div id="product_abc123f">...</div><div id="product_def456a">...</div>These IDs change when the database is re-seeded or a product is recreated. The rule:
If it contains a dynamic part (hash, UUID, slug, timestamp), do not use it as a locator.
Use stable alternatives instead:
// ❌ Breaks on data refreshpage.locator('#product_abc123f')
// ✅ Stable — class or role won't changepage.locator('.product-card').filter({ hasText: 'Wireless Mouse' })page.getByRole('article').filter({ hasText: 'Wireless Mouse' })page.getByTestId('product-card').filter({ hasText: 'Wireless Mouse' })Remember: if a locator matches more than one element, Playwright refuses to act — narrow it rather than defaulting to .first().
Shadow DOM basics
Section titled “Shadow DOM basics”Some component libraries use Shadow DOM — a separate DOM tree that is encapsulated inside a host element. Standard CSS selectors cannot pierce Shadow DOM boundaries.
Playwright handles this automatically for most cases via its pierce selector engine:
// Playwright automatically pierces open shadow rootspage.locator('my-component >> button')
// Or use the pierce selector explicitlypage.locator('pierce/button')The >> is Playwright’s piercing combinator — it crosses the shadow-DOM boundary between the host (my-component) and the element inside it.
For closed shadow roots (rare in practice), you will need to work with the component’s documented API or data-testid attributes placed on the shadow host.
Remember: Playwright’s getBy* locators pierce shadow DOM automatically — no special syntax needed.
Table rows — putting it all together
Section titled “Table rows — putting it all together”Tables are the most complex locator challenge in most apps. They combine chained locators, filter, and role-based locators in one pattern.
The admin orders table has customer info, order totals, status badges, AND a form with a <select> and <button> in every row. Here is the full pattern:
// All rows in the orders tableconst orderRows = page.locator('table tbody tr.table-row');
// Strategy 1 — by index (quick but fragile if order changes)const firstRow = orderRows.first();
// Strategy 2 — by text content (robust for known content)const janeRow = orderRows.filter({ hasText: 'Jane Smith' });
// Strategy 3 — by child element (structural matching)const pendingRow = orderRows.filter({ has: page.locator('.badge-warning')});
// Strategy 4 — full chain: find row + interact with form elementsconst janeSelect = orderRows .filter({ hasText: 'Jane Smith' }) .locator('select[name="status"]');
const janeUpdateBtn = orderRows .filter({ hasText: 'Jane Smith' }) .locator('button:has-text("Update")');
await janeSelect.selectOption('shipped');await janeUpdateBtn.click();Remember: tables pull every locator skill together — find the row by its text with filter({ hasText }), then chain into the cell, badge, or form control you need.
Exercises
Section titled “Exercises”These exercises run against TestMarket Lab, a small e-commerce app you run on your own machine. You’ll reuse it throughout the rest of the course, so set it up once now (requires Node.js 20+, installed in Module 0):
# Clone the app (one time)git clone https://github.com/TesterBaku/testmarket-lab.gitcd testmarket-lab
# Install dependencies and start the servernpm installnpm startLeave it running and open http://localhost:3000 in your browser. When an exercise needs a logged-in user, sign in with the demo accounts:
- Customer —
customer@test.io/customer123 - Admin —
admin@test.io/admin123
Need a clean slate? Restart the app, or send
POST http://localhost:3000/api/resetto restore the seed data.
Exercise 1: Identify locator types
Section titled “Exercise 1: Identify locator types”For each HTML element below, write the best Playwright locator:
| HTML Element | Best Locator |
|---|---|
<input id="email"> with <label for="email">Email</label> | |
<button type="submit">Log In</button> | |
<a href="/auth/register">Register</a> | |
<input placeholder="Search products..."> | |
<div class="alert alert-success">Login successful!</div> |
Solution
page.getByLabel('Email'); // label + input associationpage.getByRole('button', { name: 'Log In' }); // role + accessible namepage.getByRole('link', { name: 'Register' }); // links are a role toopage.getByPlaceholder('Search products...'); // no label, only a placeholderpage.getByText('Login successful!'); // standalone flash text// (CSS fallback also works for the flash: page.locator('.alert.alert-success'))Exercise 2: Rewrite fragile locators
Section titled “Exercise 2: Rewrite fragile locators”Rewrite each fragile locator using a resilient strategy:
// 1. ❌ Fragile: CSS classpage.locator('.btn.btn-primary')
// 2. ❌ Fragile: DOM pathpage.locator('div > div > form > input').first()
// 3. ❌ Fragile: dynamic IDpage.locator('#product_abc123')
// 4. ❌ Fragile: nth-childpage.locator('table tbody tr:first-child td:nth-child(2)')Solution
// 1. Role + accessible name instead of CSS classespage.getByRole('button', { name: 'Log In' });
// 2. The field's label instead of a brittle DOM pathpage.getByLabel('Email');
// 3. Stable content instead of a dynamic idpage.locator('.product-card').filter({ hasText: 'Wireless Mouse' });
// 4. Find the row by content, then the cell — not by positionpage.locator('tr.table-row') .filter({ hasText: 'Wireless Mouse' }) .locator('td').nth(1); // nth(1) = the second <td> (0-indexed)Exercise 3: Form field locators
Section titled “Exercise 3: Form field locators”Write getByLabel locators for the TestMarket checkout form fields: Shipping Name, Shipping Address, Shipping City, Shipping Zip Code, and the Place Order button.
Write getByLabel locators for the admin product form: Name, Price, Category, Stock, and the Create/Update Product button.
Solution
// Checkout form (/checkout)page.getByLabel('Shipping Name');page.getByLabel('Shipping Address');page.getByLabel('Shipping City');page.getByLabel('Shipping Zip Code');page.getByRole('button', { name: 'Place Order' });
// Admin product form (/admin/products/new and /admin/products/:id/edit)page.getByLabel('Name');page.getByLabel('Price');page.getByLabel('Category');page.getByLabel('Stock');page.getByRole('button', { name: 'Create Product' }); // 'Update Product' when editingExercise 4: Chained locators
Section titled “Exercise 4: Chained locators”Using views/admin/products.ejs:
- Get all table rows with class
table-row - Get the third row (index 2)
- Get the product name (second
<td>) in the first row - Click the Edit button in the last row
- Click the Delete button in the second row
Solution
// 1. All rowsconst rows = page.locator('tr.table-row');
// 2. Third row (0-indexed)const thirdRow = rows.nth(2);
// 3. Product name is the second cell of the first rowconst firstName = rows.first().locator('td').nth(1);
// 4. Edit is a <a> link in this appawait rows.last().getByRole('link', { name: 'Edit' }).click();
// 5. Delete is a <button> (inside a small form)await rows.nth(1).getByRole('button', { name: 'Delete' }).click();Exercise 5: filter practice
Section titled “Exercise 5: filter practice”Using the admin dashboard stat cards:
- Find the card containing “Total Products” and read its
<p>count - Find the card containing “Total Users” and read its
<p>count - Click “Add to Cart” for the product named “Gaming Keyboard” on the shop page
Solution
// 1. The "Total Products" card's countconst products = await page.locator('.card') .filter({ hasText: 'Total Products' }) .locator('p').textContent();
// 2. The "Total Users" card's countconst users = await page.locator('.card') .filter({ hasText: 'Total Users' }) .locator('p').textContent();
// 3. Add the right product to the cart (shop /products page)await page.locator('.product-card') .filter({ hasText: 'Gaming Keyboard' }) .getByRole('button', { name: 'Add to Cart' }) .click();Exercise 6: Admin table row targeting (challenge)
Section titled “Exercise 6: Admin table row targeting (challenge)”Write a test sequence that:
- Navigates to the admin orders page
- Finds the row for customer “Jane Smith”
- Asserts the status badge says “pending”
- Changes the status to “shipped” via the
<select>and Update button - Asserts the flash message confirms the update
Solution
await page.goto('http://localhost:3000/admin/orders');
// Find the row by the customer's nameconst janeRow = page.locator('tr.table-row').filter({ hasText: 'Jane Smith' });
// The status badge reads "pending"await expect(janeRow.locator('.badge')).toHaveText('pending');
// Change status via the row's <select> + Update buttonawait janeRow.locator('select[name="status"]').selectOption('shipped');await janeRow.getByRole('button', { name: 'Update' }).click();
// The success flash confirms itawait expect(page.locator('.alert.alert-success')).toBeVisible();Exercise 7: Strict mode fix
Section titled “Exercise 7: Strict mode fix”Fix each strict mode violation:
// ❌ Fix this:await page.locator('button').click(); // 15 buttonsawait page.locator('a').click(); // 50+ linksawait page.locator('select').selectOption('electronics'); // 2 selectsawait page.locator('button:has-text("Remove")').click(); // 2+ Remove buttonsSolution
// One specific button / link by role + accessible nameawait page.getByRole('button', { name: 'Log In' }).click();await page.getByRole('link', { name: 'Register' }).click();
// The category <select> has no <label> in this app — use its test idawait page.getByTestId('category-filter').selectOption('electronics');
// Scope "Remove" to the cart row you actually meanawait page.locator('.cart-table .table-row') .filter({ hasText: 'Wireless Mouse' }) .getByRole('button', { name: 'Remove' }) .click();Exercise 8: getByTestId migration
Section titled “Exercise 8: getByTestId migration”Given that data-testid="product-card" has been added to product card elements, migrate the existing class-based locator page.locator('.product-card') to use getByTestId. Then do the same for table-row and cart-row.
Solution
// product-card already carries a real data-testid on the /products pagepage.getByTestId('product-card');
// table-row and cart-row don't have a data-testid yet — you'd add one in the// app first (e.g. data-testid="table-row"), then locate it the same way:page.getByTestId('table-row');page.getByTestId('cart-row');The lesson: getByTestId is only available once the attribute exists in the markup. product-card has one; the row examples show what you’d write after adding it.
Key takeaways
Section titled “Key takeaways”Three rules to carry into every test you write:
- Prefer
getByRolefirst — “find the button labeled Log In” beats “find the thing with these CSS classes” getByLabelfor forms,getByTestIdfor stability — forms have labels for accessibility; use them- Chain + filter for precision — do not write one massive selector; chain small readable locators and filter by content you can see
Locator cheat-sheet — which one do I reach for?
| Situation | Use |
|---|---|
| Button, link, heading, checkbox… | getByRole(role, { name }) |
| Form field with a visible label | getByLabel |
| Field with no label, only a placeholder | getByPlaceholder |
| A link or standalone text | getByText |
| No accessible name, text, or stable id | getByTestId |
| Structural / by id only | CSS (page.locator('#id')) |
| One of many matches | chain + filter, then .first()/.nth() as a last resort |
Module 3 covers fixtures and auth state — you’ll skip the login form entirely using pre-authenticated browser contexts.