📖 The Story: The Brittle Selector Disaster

Meet Raj. Raj wrote 200 Selenium tests using CSS class selectors like .btn-primary, .form-input, and .nav-link-active. Then the UX team redesigned the entire app. Every CSS class changed. .btn-primary became .Button_variant__filled. .form-input became .InputField_root. All 200 tests broke. Simultaneously. Raj spent two weeks updating selectors instead of writing new tests.

Then Raj discovered Playwright locators. He rewrote his tests using getByRole('button', { name: 'Submit' }) and getByLabel('Email'). When the next redesign happened, his tests still passed because the button's accessible role and label hadn't changed—even though every CSS class was different.

The lesson: Write tests that find elements the way users find them—by what they see and do, not by implementation details.

🎯 Why Locators Beat Selectors

🔄

Refactor-Resilient

Role-based locators survive CSS changes, framework migrations, and HTML restructuring.

Auto-Waiting

Locators automatically wait for elements to appear. No manual waitForSelector needed.

Accessibility-Aligned

getByRole finds elements the same way screen readers do—testing real user experience.

🧒 Explain Like I'm 10

Imagine you're in a crowded cafeteria looking for your friend. You could find them by their shirt color (CSS class)—but what if they change shirts? Or by their seat number (XPath)—but what if someone else sits there? The best way is by their name (getByRole/getByText)—even if they change clothes or seats, their name stays the same. Playwright locators find elements by their "name" (role, text, label), not their "shirt color" (CSS class).

🎓 Professional Explanation

A locator in Playwright is an object that encapsulates an element-finding strategy. Unlike raw selectors (strings), locators are lazy—they don't query the DOM at creation time. Instead, they resolve at action time (.click(), .fill()), which means they automatically wait for the element to become actionable. Locators also support chaining, filtering, and strict mode (throwing if multiple elements match).

📋 Locator Methods Reference

MethodFinds ByPriorityExample
getByRole()ARIA role + accessible name⭐ #1getByRole('button', { name: 'Submit' })
getByText()Text content⭐ #2getByText('Welcome back')
getByLabel()Label text (for form fields)⭐ #3getByLabel('Email address')
getByPlaceholder()Placeholder attribute#4getByPlaceholder('Enter password')
getByAltText()Alt text on images#5getByAltText('Company logo')
getByTitle()Title attribute#6getByTitle('Close dialog')
getByTestId()data-testid attribute#7getByTestId('submit-btn')
locator()CSS / XPath selector#8locator('.btn-primary')

⭐ getByRole: The Gold Standard

getByRole is Playwright's recommended locator. It finds elements by their ARIA role (button, link, textbox, checkbox, etc.) and optional accessible name. This is how screen readers identify elements—so your tests validate accessibility by default.

typescript
// HTML: <button>Sign in</button>
await page.getByRole('button', { name: 'Sign in' }).click();

// HTML: <input type="text" aria-label="Email">
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');

// HTML: <a href="/docs">Documentation</a>
await page.getByRole('link', { name: 'Documentation' }).click();

// HTML: <input type="checkbox" /> Accept terms
await page.getByRole('checkbox', { name: 'Accept terms' }).check();

// HTML: <nav>...</nav>
await expect(page.getByRole('navigation')).toBeVisible();

Common ARIA roles: button, link, textbox, checkbox, radio, combobox, listbox, tab, tabpanel, navigation, banner, main, heading, img, dialog, alert.

📝 getByText: Find by Content

typescript
// Exact match
await page.getByText('Submit', { exact: true }).click();

// Substring match (default)
await page.getByText('Welcome'); // Matches "Welcome back, Alex!"

// Regular expression
await page.getByText(/items? in cart/); // "item in cart" or "items in cart"

🏷️ getByLabel: For Form Fields

typescript
// HTML: <label for="email">Email</label><input id="email" />
await page.getByLabel('Email').fill('test@example.com');

// HTML: <input aria-label="Password" />
await page.getByLabel('Password').fill('secret123');

🧪 getByTestId: The Escape Hatch

When no semantic locator works, add data-testid to the element and use getByTestId:

html + typescript
// HTML: <div data-testid="cart-count">3</div>
await expect(page.getByTestId('cart-count')).toHaveText('3');
⚠️ Use getByTestId as a Last Resort

It requires modifying your application code (adding data-testid attributes). Prefer getByRole or getByLabel first. Use getByTestId only when dynamic content makes other locators impractical.

🔧 CSS Selectors: When You Need Them

Use page.locator() with CSS selectors when locators aren't sufficient:

typescript
// ID selector
await page.locator('#submit-btn').click();

// Class selector
await page.locator('.card').first().click();

// Attribute selector
await page.locator('[data-status="active"]').click();

// Pseudo-selector
await page.locator('input:focus').fill('text');

// Combinators
await page.locator('form > .submit').click();
await page.locator('nav li:nth-child(3)').click();

🔗 Chaining & Filtering Locators

typescript
// Chain: find within a section
await page.getByRole('listitem').filter({ hasText: 'Product A' }).getByRole('button', { name: 'Add to cart' }).click();

// Filter by text
await page.getByRole('row').filter({ hasText: 'John Doe' }).getByRole('button', { name: 'Edit' }).click();

// Nth element
await page.getByRole('tab').nth(1).click();

// First / Last
await page.getByRole('article').first().click();

⚠️ Common Mistakes

Mistake 1: Using CSS Classes for Everything

typescript · ❌ Brittle
// ❌ Breaks when CSS changes
await page.locator('.btn-primary').click();

// ✅ Survives CSS changes
await page.getByRole('button', { name: 'Submit' }).click();

Mistake 2: Using page.$() Instead of Locators

typescript · ❌ No auto-wait
// ❌ Returns null if element doesn't exist yet
const btn = await page.$('#submit');

// ✅ Auto-waits and auto-retries
await page.locator('#submit').click();

✅ Best Practices

  1. Prefer getByRole — it tests accessibility and survives refactors.
  2. Use getByLabel for form fields — matches how users interact with forms.
  3. Use getByText for non-interactive elements — headings, paragraphs, labels.
  4. Use getByTestId as a last resort — only when semantic locators can't uniquely identify the element.
  5. Avoid CSS class selectors — they're tied to implementation, not user behavior.
  6. Chain and filter to narrow down to the exact element.
  7. Never use page.$() — it doesn't auto-wait. Always use locators.

🏗️ Senior Engineer Deep Dive

Locator Strictness

Playwright locators are strict by default. If a locator matches multiple elements, Playwright throws an error instead of silently acting on the first one. This prevents a common Selenium bug where tests pass but interact with the wrong element. To handle multiple matches intentionally, use .first(), .nth(), or .filter().

Custom TestId Attribute

If your team uses a different attribute (e.g., data-cy in Cypress migrations), configure it in playwright.config.ts:

typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // Change the testId attribute from default 'data-testid'
    testIdAttribute: 'data-cy',
  },
});

Now getByTestId('login-btn') looks for data-cy="login-btn" instead of data-testid="login-btn". Seamless migration from Cypress.

💼 Interview Questions

Beginner
What is the difference between a selector and a locator?
Show answer
A selector is a string (CSS, XPath) that identifies a DOM element. A locator is a Playwright object that encapsulates a finding strategy with auto-waiting and retrying. Locators are lazy—they resolve at action time, not creation time.
Intermediate
Why is getByRole preferred over CSS selectors?
Show answer
getByRole finds elements by their ARIA role and accessible name, which matches how screen readers and users perceive the page. This makes tests resilient to CSS/HTML changes and validates accessibility by default. CSS selectors are tied to implementation details that change frequently.
Advanced
What does "locator strictness" mean in Playwright?
Show answer
Locators are strict—if a locator matches multiple elements, Playwright throws an error instead of silently using the first match. This prevents bugs where tests pass but interact with the wrong element. Use .first(), .nth(), or .filter() to explicitly handle multiple matches.
Scenario-Based
A test finds a button by CSS class .btn-submit. After a framework migration, the class becomes .Button_root__xyz. How do you rewrite it to survive future changes?
Show answer
Replace with getByRole('button', { name: 'Submit' }) if the button has visible text, or getByLabel('Submit') if it has an aria-label, or getByTestId('submit-btn') as a fallback by adding a data-testid attribute to the element.

🏋️ Practical Exercises

🛠️ Hands-On Beginner

Replace CSS Selectors with Locators

  1. Open a test file from Lesson 1.3.
  2. Replace any page.locator('.class') or page.$('selector') with getByRole, getByText, or getByLabel.
  3. Run the tests and verify they still pass.
  4. Navigate to https://playwright.dev and write 5 locators using different methods (getByRole, getByText, getByLabel, getByPlaceholder, getByTestId).
🔥 Challenge Intermediate

Locator Scavenger Hunt

  1. Navigate to https://demo.playwright.dev/todomvc.
  2. Write a locator for the input field using getByPlaceholder.
  3. Write a locator for the heading using getByText.
  4. Add 3 todo items. Write a locator that finds the second item using getByRole + nth.
  5. Write a locator that filters the list by text content using filter.

🧩 Mini Project

🎯 Project: Locator Migration

  1. Find or write 5 tests that use CSS class selectors (.something).
  2. Rewrite all 5 to use semantic locators (getByRole, getByLabel, getByText).
  3. For any that can't use semantic locators, add data-testid and use getByTestId.
  4. Run the full suite and verify all tests pass with the new locators.
  5. Document which locators you chose and why in comments.

🧠 Quiz

1. What is the highest-priority Playwright locator?
getByTestId
getByRole
locator('.class')
getByPlaceholder
2. Why are locators lazy?
They run slowly
They resolve at action time, not creation time
They skip element validation
They cache DOM elements
3. What does getByRole('button', { name: 'Login' }) find?
Any button on the page
A button whose accessible name is "Login"
A button with the CSS class "Login"
An element with id="Login"
4. When should you use getByTestId?
Always—it's the most reliable
When CSS selectors are too long
As a last resort when no semantic locator works
Never—it's deprecated
5. What happens if a locator matches multiple elements?
It uses the first match silently
It throws a strict mode violation error
It uses the last match
It selects all matches
6. Which locator is best for form input fields?
locator('input')
getByLabel()
getByAltText()
getByRole('field')
7. How do you change the default testId attribute?
It can't be changed
Pass it as a CLI flag
Set testIdAttribute in use config
Override it in each test file
8. What does .filter({ hasText: 'Active' }) do?
Filters elements that have the exact text "Active"
Filters elements whose text content contains "Active"
Removes elements with "Active" text
Sorts elements by text