Skip to content

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.


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 layout
page.locator('.btn.btn-primary.mt-2').click();
// ❌ Fragile: breaks when a wrapper <div> is added
page.locator('div > div > form > button').click();
// ❌ Fragile: exact text match breaks on whitespace changes
page.locator('text="Log In"').click();

Compare those with their resilient equivalents:

// ✅ Resilient: matches how assistive technology sees the page
page.getByRole('button', { name: 'Log In' }).click();
// ✅ Resilient: tied to the <label> association
page.getByLabel('Email').fill('user@test.io');
// ✅ Resilient: explicit, never changes unless you change it
page.getByTestId('submit-btn').click();

Playwright’s recommended locator priority:

  1. getByRole — prefer this first
  2. getByLabel — for form fields
  3. getByPlaceholder — when no label exists
  4. getByText — for links and visible text
  5. getByTestId — when nothing else is stable
  6. CSS / XPath — last resort

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 ElementARIA Role
<button>button
<a href>link
<input type="text">textbox
<input type="checkbox">checkbox
<select>combobox
<h1><h6>heading
<img alt="...">img

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-label attribute: <button aria-label="Close dialog">X</button> → name is 'Close dialog'
  • alt on 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 button
page.getByRole('button', { name: 'Add to Cart' })
// ❌ Imprecise — matches all buttons, causes strict mode violation
page.getByRole('button')

Remember: getByRole is the default — match an element by what it is (its role) plus its accessible name.


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 text
page.getByText('Log In', { exact: true })
// Regex match — useful for case-insensitive or partial matching
page.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 are not bad — they are just less resilient. Use them when:

  • Selecting by ID (#email is 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 fast
page.locator('#shipping_name')
// Attribute selector — better than class-based
page.locator('button[type="submit"]')
// Class composition for flash messages — acceptable
page.locator('.alert.alert-success')
// Product card grid item
page.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).


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:

Terminal window
npx playwright codegen https://your-app.test

A 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.


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 rows
const 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 row
const productName = secondRow.locator('td').nth(1);
// Click the Edit button in the last row
await rows.last().locator('a:has-text("Edit")').click();

Cart example — two levels of chaining:

// Get the quantity input in the first cart row
const 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 narrows a set of matching elements to those that meet an extra condition. It is more readable than complex CSS selectors.

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 text
const totalProductsCard = page.locator('.card')
.filter({ hasText: 'Total Products' });
// Get the count value from that specific card
const 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();

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> element
const allStatCards = page.locator('.card')
.filter({ has: page.locator('p') });
// Find all order rows that have a "pending" status badge
const 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.


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 cell
await rows.last().locator('button:has-text("Delete")').click(); // last row delete
await rows.nth(2).locator('a:has-text("Edit")').click(); // third row edit

Prefer 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 changes
await rows.nth(0).locator('button:has-text("Edit")').click();
// ✅ Robust — finds the right row regardless of sort order
await 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.


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 elements
// ❌ 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 page
await page.locator('select').selectOption('electronics');

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 container
await page.locator('.login-form').getByRole('button', { name: 'Log In' }).click();
// ✅ Find the Remove button within the right cart row
await 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 guaranteed
await 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 refresh
page.locator('#product_abc123f')
// ✅ Stable — class or role won't change
page.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().


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 roots
page.locator('my-component >> button')
// Or use the pierce selector explicitly
page.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.


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 table
const 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 elements
const 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.


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):

Terminal window
# Clone the app (one time)
git clone https://github.com/TesterBaku/testmarket-lab.git
cd testmarket-lab
# Install dependencies and start the server
npm install
npm start

Leave it running and open http://localhost:3000 in your browser. When an exercise needs a logged-in user, sign in with the demo accounts:

  • Customercustomer@test.io / customer123
  • Adminadmin@test.io / admin123

Need a clean slate? Restart the app, or send POST http://localhost:3000/api/reset to restore the seed data.


For each HTML element below, write the best Playwright locator:

HTML ElementBest 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 association
page.getByRole('button', { name: 'Log In' }); // role + accessible name
page.getByRole('link', { name: 'Register' }); // links are a role too
page.getByPlaceholder('Search products...'); // no label, only a placeholder
page.getByText('Login successful!'); // standalone flash text
// (CSS fallback also works for the flash: page.locator('.alert.alert-success'))

Rewrite each fragile locator using a resilient strategy:

// 1. ❌ Fragile: CSS class
page.locator('.btn.btn-primary')
// 2. ❌ Fragile: DOM path
page.locator('div > div > form > input').first()
// 3. ❌ Fragile: dynamic ID
page.locator('#product_abc123')
// 4. ❌ Fragile: nth-child
page.locator('table tbody tr:first-child td:nth-child(2)')
Solution
// 1. Role + accessible name instead of CSS classes
page.getByRole('button', { name: 'Log In' });
// 2. The field's label instead of a brittle DOM path
page.getByLabel('Email');
// 3. Stable content instead of a dynamic id
page.locator('.product-card').filter({ hasText: 'Wireless Mouse' });
// 4. Find the row by content, then the cell — not by position
page.locator('tr.table-row')
.filter({ hasText: 'Wireless Mouse' })
.locator('td').nth(1); // nth(1) = the second <td> (0-indexed)

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 editing

Using views/admin/products.ejs:

  1. Get all table rows with class table-row
  2. Get the third row (index 2)
  3. Get the product name (second <td>) in the first row
  4. Click the Edit button in the last row
  5. Click the Delete button in the second row
Solution
// 1. All rows
const 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 row
const firstName = rows.first().locator('td').nth(1);
// 4. Edit is a <a> link in this app
await 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();

Using the admin dashboard stat cards:

  1. Find the card containing “Total Products” and read its <p> count
  2. Find the card containing “Total Users” and read its <p> count
  3. Click “Add to Cart” for the product named “Gaming Keyboard” on the shop page
Solution
// 1. The "Total Products" card's count
const products = await page.locator('.card')
.filter({ hasText: 'Total Products' })
.locator('p').textContent();
// 2. The "Total Users" card's count
const 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:

  1. Navigates to the admin orders page
  2. Finds the row for customer “Jane Smith”
  3. Asserts the status badge says “pending”
  4. Changes the status to “shipped” via the <select> and Update button
  5. Asserts the flash message confirms the update
Solution
await page.goto('http://localhost:3000/admin/orders');
// Find the row by the customer's name
const 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 button
await janeRow.locator('select[name="status"]').selectOption('shipped');
await janeRow.getByRole('button', { name: 'Update' }).click();
// The success flash confirms it
await expect(page.locator('.alert.alert-success')).toBeVisible();

Fix each strict mode violation:

// ❌ Fix this:
await page.locator('button').click(); // 15 buttons
await page.locator('a').click(); // 50+ links
await page.locator('select').selectOption('electronics'); // 2 selects
await page.locator('button:has-text("Remove")').click(); // 2+ Remove buttons
Solution
// One specific button / link by role + accessible name
await 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 id
await page.getByTestId('category-filter').selectOption('electronics');
// Scope "Remove" to the cart row you actually mean
await page.locator('.cart-table .table-row')
.filter({ hasText: 'Wireless Mouse' })
.getByRole('button', { name: 'Remove' })
.click();

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 page
page.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.


Three rules to carry into every test you write:

  1. Prefer getByRole first — “find the button labeled Log In” beats “find the thing with these CSS classes”
  2. getByLabel for forms, getByTestId for stability — forms have labels for accessibility; use them
  3. 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?

SituationUse
Button, link, heading, checkbox…getByRole(role, { name })
Form field with a visible labelgetByLabel
Field with no label, only a placeholdergetByPlaceholder
A link or standalone textgetByText
No accessible name, text, or stable idgetByTestId
Structural / by id onlyCSS (page.locator('#id'))
One of many matcheschain + 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.