Locators in Playwright: Complete Reference & Best Practices
A locator is Playwright's way of finding and interacting with elements on a web page. Rather than hardcoding fragile selectors that break when your HTML changes, Playwright locators are intelligent—they auto-retry, wait for elements to be actionable, and support multiple selection strategies so you can pick the most maintainable one for your use case. This guide covers all locator types, their performance trade-offs, and when to use each in your React tests.
What Are Locators and Why They Matter
A locator is a query object that represents one or more elements on the page. When you call page.locator(selector), Playwright doesn't immediately find the element—instead, it returns a Locator object that will search for the element only when you interact with it (click(), fill(), expect()). This lazy evaluation is key to Playwright's reliability: if an element takes 500ms to appear (because React is rendering it or an API response is pending), Playwright will retry the locator for up to 30 seconds by default.
Example:
const submitButton = page.locator('button:has-text("Submit")');
await submitButton.click(); // Playwright waits up to 30s for this button to appear
Locators are also chainable and reusable, so you can build complex page objects without repeating selectors.
CSS Selectors: The Most Common Strategy
CSS selectors are fast, readable, and built into all browsers. Use them whenever possible:
// By class
await page.locator('.submit-button').click();
// By ID
await page.locator('#email-input').fill('[email protected]');
// By attribute
await page.locator('[data-testid="login-form"]').fill('password');
// Descendant combinator
await page.locator('.form .submit-button').click();
// Child combinator (direct child)
await page.locator('.container > button').click();
// Multiple classes
await page.locator('.btn.btn-primary').click();
// Pseudo-class (first child, nth-child, etc.)
await page.locator('li:first-child').click();
For React apps, always use data-testid attributes for reliable selectors—they're immune to styling changes:
// In your React component
<form data-testid="signup-form">
<input data-testid="email-input" type="email" />
<button data-testid="submit-btn">Sign Up</button>
</form>
// In your test
await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="submit-btn"]').click();
XPath: When CSS Selectors Aren't Enough
XPath is a query language for XML/HTML documents. It's more powerful than CSS but slower and harder to read. Use it sparingly, mainly for complex scenarios:
// By text (exact match)
await page.locator('xpath=//button[text()="Sign Up"]').click();
// By text (partial match)
await page.locator('xpath=//button[contains(text(), "Sign")]').click();
// By attribute and text
await page.locator('xpath=//input[@name="email" and @required]').fill('[email protected]');
// Following sibling
await page.locator('xpath=//label[contains(text(), "Email")]/following-sibling::input').fill('[email protected]');
// Parent element
await page.locator('xpath=//button[text()="Save"]/parent::form').screenshot();
For React, XPath is rarely needed. Prefer data-testid + CSS.
Role-Based Locators: The Accessibility-First Approach
Role locators find elements by their ARIA role, making tests more accessible and maintainable. Playwright traverses the accessibility tree, so your test actually verifies that elements are semantically correct:
// By role (most common)
await page.locator('role=button[name="Submit"]').click();
// Inputs by role and name
await page.locator('role=textbox[name="Email"]').fill('[email protected]');
// Links
await page.locator('role=link[name="Learn more"]').click();
// Combo box (select)
await page.locator('role=combobox[name="Country"]').selectOption('US');
// Checkboxes
await page.locator('role=checkbox[name="I agree"]').check();
// Radio buttons
await page.locator('role=radio[name="Monthly"]').click();
// Headings
const heading = page.locator('role=heading[name="Welcome"]');
await expect(heading).toBeVisible();
Role-based locators are the most future-proof because they express intent rather than implementation. If you rename a class or restructure the DOM, role-based tests often still pass.
Text Selectors: Simple but Powerful
Text-based locators search by visible or exact text content:
// Exact text match (strict)
await page.locator('text="Click me"').click();
// Partial text match
await page.locator('text=/submit/i').click(); // case-insensitive regex
// Combined with role
await page.locator('role=button[name=/add/i]').click();
// For links
await page.locator('text="Learn more"').click();
Caution: Text locators break if content changes (e.g., translations, updated copy). Pair them with data-testid for stability.
Composite Locators and Chaining
Chain locators to narrow down your selection without creating a single complex CSS path:
// First, find the form, then find the input inside it
const form = page.locator('[data-testid="login-form"]');
const emailInput = form.locator('input[name="email"]');
await emailInput.fill('[email protected]');
// Or inline
await page.locator('[data-testid="login-form"]')
.locator('input[name="email"]')
.fill('[email protected]');
// Count elements
const buttonCount = await page.locator('button').count();
console.log(`Found ${buttonCount} buttons`);
// Get the nth element
const thirdButton = page.locator('button').nth(2); // zero-indexed
await thirdButton.click();
Chaining is excellent for encapsulation—define a Page Object (reusable test helper) that returns locators:
class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
get emailInput() {
return this.page.locator('[data-testid="email-input"]');
}
get passwordInput() {
return this.page.locator('[data-testid="password-input"]');
}
get submitButton() {
return this.page.locator('role=button[name="Login"]');
}
}
Comparing Locator Strategies
| Strategy | Speed | Readability | Brittleness | Best For |
|---|---|---|---|---|
| CSS (data-testid) | Fastest | Excellent | None | Primary choice for React |
| CSS (class) | Fastest | Good | High (breaks on style changes) | Stable, non-critical elements |
| Role | Moderate | Excellent | Low | Accessible, semantic-first tests |
| XPath | Slow | Poor | Moderate | Complex queries; rare in modern tests |
| Text | Moderate | Good | High (breaks on copy changes) | Labels, visible text only |
Waiting and Auto-Retry: Built-In Timeout Handling
Playwright automatically waits for locators. When you call an action on a locator, Playwright retries the locator query for up to 30 seconds:
// Playwright waits up to 30s for this button to appear and become clickable
await page.locator('role=button[name="Load More"]').click();
// Customize timeout (in milliseconds)
await page.locator('role=button[name="Load More"]').click({ timeout: 5000 });
// Wait for visibility
const message = page.locator('text="Success"');
await message.waitFor({ state: 'visible', timeout: 10000 });
// Check if element exists (no error if missing)
const count = await page.locator('[data-testid="item"]').count();
This auto-retry eliminates flakiness from race conditions and timing-dependent renders.
Key Takeaways
- Locators are lazy queries that auto-retry for up to 30 seconds, making tests stable and avoiding flakiness.
- Use
data-testidCSS selectors as your primary strategy—they're fast, readable, and immune to styling changes. - Role-based locators (
role=button[name="..."]) are the most accessible and future-proof. - Chain locators to avoid complex CSS paths; extract reusable getters into Page Objects for maintainability.
- Text and XPath locators are powerful but fragile—use only when other strategies don't fit.
Frequently Asked Questions
What's the difference between getByRole and locator('role=...')?
getByRole() is an older API that's been superseded by locator('role=...'). Both do the same thing, but the locator API is more consistent and works everywhere. Use locator('role=...') for new tests.
Should I add data-testid to every element?
No—add it to elements your tests interact with or assert on. Stable, semantic elements (like a navigation link with a unique href) don't need data-testid. Reserve it for dynamic or ambiguous elements.
Why does my CSS selector work in DevTools but not in my Playwright test?
Playwright runs in a real browser, but selectors are sometimes scoped to iframes or Shadow DOM boundaries. Use page.frameLocator() for iframes and test the accessibility tree with role selectors for Shadow DOM.
How do I handle dynamic IDs or classes that change?
Use data-testid instead of IDs/classes. If the ID must be matched (e.g., it's part of the API contract), use an attribute selector with regex: [id*="user-"] (contains) or [id^="user_"] (starts with).
What timeout should I use for slow elements?
Start with the default 30 seconds. If tests are timing out, your app is likely slow—investigate performance before extending timeouts. For truly slow operations (file uploads, heavy processing), bump timeout to 60 seconds and add a comment explaining why.