CSS Selectors & Playwright Locators
Finding the right element is 80% of test automation. Use the wrong strategy and your tests break every time a developer changes a CSS class. Use the right locator and your tests survive refactors, redesigns, and framework migrations.
📖 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
| Method | Finds By | Priority | Example |
|---|---|---|---|
getByRole() | ARIA role + accessible name | ⭐ #1 | getByRole('button', { name: 'Submit' }) |
getByText() | Text content | ⭐ #2 | getByText('Welcome back') |
getByLabel() | Label text (for form fields) | ⭐ #3 | getByLabel('Email address') |
getByPlaceholder() | Placeholder attribute | #4 | getByPlaceholder('Enter password') |
getByAltText() | Alt text on images | #5 | getByAltText('Company logo') |
getByTitle() | Title attribute | #6 | getByTitle('Close dialog') |
getByTestId() | data-testid attribute | #7 | getByTestId('submit-btn') |
locator() | CSS / XPath selector | #8 | locator('.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.
// 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
// 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
// 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: <div data-testid="cart-count">3</div> await expect(page.getByTestId('cart-count')).toHaveText('3');
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:
// 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
// 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
// ❌ 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
// ❌ 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
- Prefer
getByRole— it tests accessibility and survives refactors. - Use
getByLabelfor form fields — matches how users interact with forms. - Use
getByTextfor non-interactive elements — headings, paragraphs, labels. - Use
getByTestIdas a last resort — only when semantic locators can't uniquely identify the element. - Avoid CSS class selectors — they're tied to implementation, not user behavior.
- Chain and filter to narrow down to the exact element.
- 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:
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
Show answer
getByRole preferred over CSS selectors?Show answer
Show answer
.btn-submit. After a framework migration, the class becomes .Button_root__xyz. How do you rewrite it to survive future changes?Show answer
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
Replace CSS Selectors with Locators
- Open a test file from Lesson 1.3.
- Replace any
page.locator('.class')orpage.$('selector')withgetByRole,getByText, orgetByLabel. - Run the tests and verify they still pass.
- Navigate to
https://playwright.devand write 5 locators using different methods (getByRole, getByText, getByLabel, getByPlaceholder, getByTestId).
Locator Scavenger Hunt
- Navigate to
https://demo.playwright.dev/todomvc. - Write a locator for the input field using
getByPlaceholder. - Write a locator for the heading using
getByText. - Add 3 todo items. Write a locator that finds the second item using
getByRole+nth. - Write a locator that filters the list by text content using
filter.
🧩 Mini Project
🎯 Project: Locator Migration
- Find or write 5 tests that use CSS class selectors (
.something). - Rewrite all 5 to use semantic locators (getByRole, getByLabel, getByText).
- For any that can't use semantic locators, add
data-testidand usegetByTestId. - Run the full suite and verify all tests pass with the new locators.
- Document which locators you chose and why in comments.
🧠 Quiz
getByTestIdgetByRolelocator('.class')getByPlaceholdergetByRole('button', { name: 'Login' }) find?getByTestId?locator('input')getByLabel()getByAltText()getByRole('field')testIdAttribute in use config.filter({ hasText: 'Active' }) do?