Chapter 9 – Selectors and Locators

In the last chapter, we explored fixtures a powerful Playwright feature that simplifies setup and teardown for tests. Now that we can prepare and manage test environments efficiently, it’s time to master one of the most critical aspects of automation testing: finding and interacting with elements on a webpage.

In this chapter, we’ll focus on selectors and locators, how Playwright identifies elements, the different types of selectors you can use, and best practices to make your selectors robust, maintainable, and reliable.

We’ll start with the basics (perfect for beginners) and then move to advanced techniques used by experienced automation engineers.

What are selectors and locators?

When you interact with a webpage clicking buttons, filling forms, or reading text Playwright needs a way to find the elements. This is done using selectors.

A selector is a string that tells Playwright which element to find, similar to how you use CSS or XPath selectors in the browser console.

A locator, on the other hand, is Playwright’s enhanced API for working with elements. It automatically waits for elements to appear, be visible, and be ready before interacting with them.

Example:

// Using a selector directly
await page.click('#login-button');

// Using a locator (recommended)
const loginButton = page.locator('#login-button');
await loginButton.click();

While both methods work, locators are preferred because they are more stable, readable, and reliable.

1. Types of selectors in Playwright

Playwright supports a variety of selector types, you can mix and match them based on what’s best for your application.

1.1 CSS selectors

The most common way to locate elements.

await page.locator('button.login');
await page.locator('#username');
await page.locator('form > input[name="email"]');

1.2 Text selectors

Find elements based on visible text.

await page.locator('text=Login');
await page.locator('text="Submit"');

Partial text matches are also supported:

await page.locator('text=Get Started');

1.3 Role selectors (Accessibility-based)

Find elements using ARIA roles (great for accessibility testing).

await page.getByRole('button', { name: 'Submit' });
await page.getByRole('link', { name: 'Home' });

Best practice: Role selectors are preferred when available because they’re tied to accessibility standards.

1.4 Data attribute selectors

Many teams add custom attributes (like data-testid or data-qa) for testing purposes.

await page.locator('[data-testid="login-button"]');
await page.locator('[data-qa="cart-item"]');

These are stable and rarely change, making them ideal for automation.

1.5 XPath selectors

XPath is powerful but should be used sparingly, it’s less readable and can break easily.

await page.locator('//button[text()="Login"]');

Use XPath only when no other selector works.

2. Locators in action

A locator in Playwright is more than a simple selector, it’s an object that represents one or multiple elements. You can perform multiple actions on a locator without re-querying the DOM each time.

Example:

const loginButton = page.locator('#login-button');
await loginButton.hover();
await loginButton.click();
await expect(loginButton).toBeVisible();

Playwright ensures:

  • The element exists.
  • It’s visible and stable.
  • The action (click, hover, etc.) is completed successfully.

You don’t need to write explicit waits, Playwright handles that automatically.

3. Chaining locators

You can chain locators to find nested elements.

const form = page.locator('#login-form');
const username = form.locator('input[name="username"]');
await username.fill('john_doe');

This approach makes your selectors more specific and avoids conflicts with elements having the same selector elsewhere.

4. Filtering locators

You can use filters to refine locators even further.

Example:

await page.locator('button', { hasText: 'Add to Cart' }).click();

Or use nested locators:

await page.locator('.product').locator('button:has-text("Buy")').click();

5. Working with multiple elements

If your locator matches multiple elements, you can work with them collectively or individually.

Example:

const items = page.locator('.product-item');
const count = await items.count();

for (let i = 0; i < count; i++) {
  console.log(await items.nth(i).textContent());
}

nth(index) returns the element at a given position, similar to an array index.

6. Waiting for elements explicitly

Although Playwright auto-waits, sometimes you’ll need explicit waits.

Example:

await page.waitForSelector('#success-message');
await expect(page.locator('#success-message')).toHaveText('Success!');

Avoid fixed sleeps like waitForTimeout(), they make tests slower and less reliable.

7. Best practices for stable selectors

  • Prefer data attributes (e.g., data-testid or data-qa).
  • Avoid relying on text unless it’s static and unlikely to change.
  • Use role selectors for accessibility-friendly tests.
  • Avoid XPath unless absolutely necessary.
  • Don’t use auto-generated selectors from browser dev tools, they break easily.
  • Keep selectors short and clear, avoid deeply nested CSS paths.

8. Debugging selectors

If a selector fails, Playwright offers great tools for debugging.

Using Playwright Inspector

Run your test with the --debug flag:

npx playwright test --debug

This opens the Playwright Inspector, where you can step through each command and inspect locators.

Using Codegen to generate selectors

Playwright’s Codegen can record your actions and suggest selectors.

npx playwright codegen https://example.com

This opens an interactive browser that records your clicks and generates corresponding Playwright code with selectors.

Using locator.highlight()

You can visually highlight elements your locator finds:

await page.locator('#login-button').highlight();

This is helpful for debugging in headed mode.

9. Handling dynamic elements

Modern applications often render elements dynamically (after AJAX calls or animations). To handle these cases:

  • Use auto-waiting locators (Playwright does this by default).
  • Wait for specific states:
await page.locator('#message').waitFor({ state: 'visible' });
  • Use expect() to verify element readiness instead of manual waits:
await expect(page.locator('#message')).toBeVisible();

10. Combining selectors for flexibility

You can combine selector types to make them even more specific:

await page.locator('button[data-testid="buy"]:has-text("Buy Now")');

Or use logical combinations:

await page.locator('div.item >> text=Details');

11. Testing for missing elements

Sometimes you need to verify that something is not present.

await expect(page.locator('.error-message')).toHaveCount(0);

Or that an element is hidden:

await expect(page.locator('#loading')).toBeHidden();

Exercise

Task 1: Locate and click a button using text, role, and data-testid selectors.
Task 2: Chain locators to interact with nested elements.
Task 3: Use filters to find a specific element among several.
Bonus: Use the Playwright Inspector to debug selectors.

Cheat Sheet

TypeExampleUse Case
CSS#id, .class, div > inputMost common and flexible
Texttext=LoginQuick checks for visible text
RolegetByRole('button', { name: 'Submit' })Accessibility-based tests
Data[data-testid="btn"]Best for stable automation
XPath//button[text()="Login"]Only when needed
Chainedpage.locator('#form').locator('button')Target nested elements

Summary

In this chapter, you learned:

  • The difference between selectors and locators.
  • How to use various selector types (CSS, text, role, data, XPath).
  • How to chain and filter locators.
  • How to debug and validate selectors.
  • Best practices for writing robust, future-proof locators.

With strong locator strategies, your Playwright tests become far more stable and reliable, even as the UI evolves.