Selenium and Playwright Locators: A Beginner’s Guide to CSS Selectors, XPath, and data-testid

When you start browser automation with Selenium or Playwright, the first major challenge is “how do I target the right element?” Understanding CSS selectors and XPath dramatically changes how stable your test code is. This guide covers locator fundamentals through practical patterns that hold up in real projects.

📌 Who This Article Is For

✅ Beginners with Selenium or Playwright who are unsure how to target elements
✅ Anyone who wants to understand the difference between CSS selectors and XPath
✅ Those whose test code keeps breaking whenever the UI changes
✅ Anyone getting started with Playwright’s modern locators like get_by_role

📖 What You’ll Be Able to Do After This Guide

✔ Read HTML and write CSS selectors and XPath expressions yourself
✔ Use locators correctly in both Selenium and Playwright
✔ Choose locators that resist breaking under UI changes
✔ Use data-testid to reduce long-term maintenance costs

I have worked on browser automation tests using Selenium and Playwright across multiple projects.
Unstable locators are one of the most common causes of flaky E2E tests, and this article focuses on practical patterns that actually hold up in real-world projects.

✅ Key Takeaways

  • General locator priority: data-testid → ID → CSS → XPath
  • Playwright’s semantic locators like get_by_role and get_by_text are more resilient to UI changes
  • Avoiding absolute XPath and positionally dependent CSS significantly reduces long-term maintenance burden

What Is a Locator?

A locator is the way you tell Selenium or Playwright which specific element on a web page to interact with — a button, a text field, a link, and so on.

Whenever Selenium or Playwright operates a browser — clicking something, typing into a field — you use a locator to specify which element to act on.

# Locator examples (Selenium)
driver.find_element(By.ID, "login-btn")                               # by ID
driver.find_element(By.CSS_SELECTOR, "#form input[type='email']")     # by CSS
driver.find_element(By.XPATH, "//button[@type='submit']")             # by XPath

A broken locator = a failing test — it’s that direct. Choosing locators that resist breaking is one of the most impactful things you can do for test stability.

Types of Locators and When to Use Each

TypeExampleStabilityRecommendation
data-testid[data-testid="login-btn"]⭐⭐⭐ Highest🔴 First choice
ID#login-btn⭐⭐⭐ High🟡 Recommended
name attribute[name="username"]⭐⭐⭐ High🟡 Recommended
CSS selector.login-form input⭐⭐ Moderate🟡 Acceptable
XPath (relative)//button[@type='submit']⭐⭐ Moderate🟡 Acceptable
Text content//button[text()='Login']⭐ Low⚠️ Context-dependent
XPath (absolute)/html/body/div[2]/form/button⭐ Lowest❌ Avoid

Writing CSS Selectors

CSS selectors use the HTML structure to identify elements. They’re familiar to front-end developers and work in both Selenium and Playwright.

Basic Syntax

# HTML example:
# <input id="email" class="form-input" type="email" name="email" />

# Select by ID (prefix with #)
"#email"

# Select by class (prefix with .)
".form-input"

# Select by attribute (wrap in [ ])
"[type='email']"
"[name='email']"

# Tag + class combined
"input.form-input"

# Descendant (space-separated: any level down)
".login-form input"

# Direct child (> separator)
".login-form > input"

Locator Selection Flow

Does the element have a data-testid attribute?
✅ YES
→ Use data-testid (highest priority)
❌ NO → Next condition ↓
Does it have a unique ID?
✅ YES
→ Use #id
❌ NO → Next condition ↓
Can it be identified by role or label in Playwright?
✅ YES
→ Use get_by_role / get_by_label
❌ NO → Next condition ↓
Can it be identified by name, type, or another attribute?
✅ YES
→ Use a CSS selector
❌ NO → Next condition ↓
🔴 Last resort: use a relative XPath

Playwright’s philosophy is to prefer elements that users can recognize. If a user sees a “Login” button, get_by_role("button", name="Login") is the natural choice. If they see an “Email” field, get_by_label("Email") is the natural choice.

Common Real-World Patterns

# data-testid (most stable)
"[data-testid='login-button']"

# Form inputs
"input[type='email']"
"input[type='password']"
"input[placeholder='Enter your email']"

# Buttons
"button[type='submit']"
"button.btn-primary"

# Links
"a[href='/dashboard']"

# Partial matching (^= starts with, $= ends with, *= contains)
"[data-testid^='item-']"    # data-testid starts with "item-"
"[class*='active']"          # class contains "active"

Writing XPath

XPath is a query language for traversing XML and HTML documents. It’s more expressive than CSS selectors, handling conditions like text content and navigating parent or sibling relationships that CSS cannot.

Basic Syntax

# //tag[@attribute='value']  ← most fundamental relative XPath
"//button[@type='submit']"
"//input[@id='email']"

# Match by text content (use with caution in multilingual apps)
"//button[text()='Login']"
"//button[contains(text(),'Log')]"    # partial match

# Multiple conditions (use and)
"//input[@type='text' and @name='username']"

# Scoped from a parent element (./)
"//form[@id='login-form']//button[@type='submit']"

# Absolute XPath (avoid — breaks on any HTML structure change)
# /html/body/div[1]/main/form/button[2]  ← never use this

CSS vs XPath — When to Use Which

SituationCSSXPath
Target by ID, class, or attribute◎ Concise○ Slightly verbose
Target by text content✕ Not straightforward◎ text() works well
Traverse to parent / sibling / ancestor✕ Cannot go upward◎ parent:: / following-sibling:: / ancestor::
Readability and maintainability◎ Cleaner△ Can get long
Execution speed◎ Slightly faster△ Slightly slower
💡 Practical decision rule
If you can write it with an ID, attribute, or class — use CSS selectors. Reach for XPath when you need to match by text content or traverse upward through the DOM tree, where CSS falls short. When both work, choose whichever reads more clearly.

Using Locators in Selenium

In Selenium 4.x, use the By class to specify elements.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome()
driver.get("https://example.com/login")  # sample URL

# Find elements and interact with them
email_input = driver.find_element(By.ID, "email")
email_input.send_keys("user@example.com")              # type text

password_input = driver.find_element(By.CSS_SELECTOR, "input[type='password']")
password_input.send_keys("password123")                # type text

submit_btn = driver.find_element(By.CSS_SELECTOR, "[data-testid='login-button']")
submit_btn.click()                                     # click

# Wait for an element to appear, then interact (recommended)
wait = WebDriverWait(driver, 10)
error_msg = wait.until(
    EC.presence_of_element_located((By.CSS_SELECTOR, "[data-testid='error-message']"))
)
assert "Incorrect password" in error_msg.text
⚠️ String-based find_element is deprecated
In Selenium 4+, driver.find_element("id", "email") is deprecated. Always use the By.ID, By.CSS_SELECTOR constants instead.

Selenium vs Playwright: Waiting Strategies

AspectSeleniumPlaywright
Default waiting behaviorNone (immediate lookup)◎ Auto-waiting built in
Explicit waitsMust write WebDriverWait yourselfRarely needed (waits before each action)
Risk of time.sleep creep△ Common trap○ Much less common

Using Locators in Playwright

Playwright offers a newer locator API with semantic locators — targeting elements by their role or visible text rather than HTML structure, which makes tests far more resilient to design changes.

Selenium vs Playwright: Locator Feature Comparison

FeatureSeleniumPlaywright
CSS selectors
XPath
get_by_role / get_by_text
Dedicated data-testid API△ CSS workaround✅ get_by_test_id
Auto-waiting△ Must write explicitly✅ Built in as standard
Open Shadow DOM△ Extra steps needed✅ Transparent access

Recommended Locators in Playwright

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com/login")  # sample URL

    # data-testid (most stable)
    page.get_by_test_id("login-button").click()

    # role + name (uses ARIA roles)
    page.get_by_role("button", name="Login").click()
    page.get_by_role("textbox", name="Email").fill("user@example.com")

    # Text content
    page.get_by_text("Login").click()                   # exact match
    page.get_by_text("Log", exact=False).click()        # partial match

    # Form field by its label text
    page.get_by_label("Email").fill("user@example.com")

    # Placeholder text
    page.get_by_placeholder("Enter your email").fill("user@example.com")

    # CSS selector (still works as always)
    page.locator("[data-testid='login-button']").click()
    page.locator("input[type='password']").fill("password123")

    browser.close()

Playwright Locator Priority

PriorityLocatorWhen to use
① First choiceget_by_test_id()When data-testid attributes are already set up. Requires coordination with the dev team
② Recommendedget_by_role()Buttons, text inputs, checkboxes, and other interactive elements
③ Recommendedget_by_label()Form inputs associated with a <label> element
④ Acceptableget_by_text()Elements identifiable by visible text (use with caution in multilingual apps)
⑤ Acceptablelocator() (CSS/XPath)When none of the above fit, or when sharing code with Selenium

Building Resilient Tests with data-testid

The data-testid attribute is a test-only identifier that is immune to design changes and refactoring. Adding it to key elements — in coordination with the development team — can dramatically reduce maintenance costs.

<!-- Add data-testid to HTML elements -->
<button type="submit" data-testid="login-submit-btn">
  Login
</button>

<input type="email"
       data-testid="login-email-input"
       placeholder="Enter your email" />
# Selenium
driver.find_element(By.CSS_SELECTOR, "[data-testid='login-submit-btn']").click()

# Playwright
page.get_by_test_id("login-submit-btn").click()
page.get_by_test_id("login-email-input").fill("user@example.com")
💡 A consistent naming convention makes data-testid manageable
Using a pattern like page-component-action (e.g. login-email-input, checkout-submit-btn) makes it instantly clear from the test code what’s being interacted with.
💡 data-testid doesn’t need to go on every element
In practice, focus on interactive elements that users actually operate — buttons, inputs, links. Adding it to every decorative div or span just creates maintenance overhead.

7 Common Locator Pitfalls

⚠️ Watch out for these

  1. Using absolute XPath
    When you “Copy XPath” from the browser DevTools, it often generates an absolute XPath like /html/body/div[2]/main/form/button[1]. This breaks the moment any HTML structure changes. Always rewrite it as a relative XPath using attributes.
  2. Using dynamically generated class names or IDs
    Frameworks like React and Vue sometimes generate hashed class names like class="btn-abc123" at build time. Using these values as locators breaks every build. Use data-testid or stable attributes instead.
  3. find_element() silently returns the first match when multiple elements match
    find_element (singular) doesn’t throw an error when multiple elements match — it just returns the first one. You may be operating on the wrong element without knowing. Use find_elements (plural) to check how many matched, and tighten the locator.
  4. Can’t access elements inside an iframe
    Elements inside an iframe aren’t reachable with a plain find_element. In Selenium, switch context first with driver.switch_to.frame(). In Playwright, use page.frame_locator().
  5. Trying to interact with an element that isn’t visible yet
    Accessing elements while the page is still loading or waiting on async requests raises ElementNotInteractableException. In Selenium, use WebDriverWait. Playwright handles this with auto-waiting, though hidden elements still need extra handling.
  6. Text-based locators break in multilingual apps
    //button[text()='Login'] fails when the page renders in another language. In multilingual apps, use data-testid or roles instead of visible text.
  7. Can’t access elements inside Shadow DOM
    Some Web Components and libraries use Shadow DOM. Playwright handles Open Shadow DOM transparently — in most cases, a regular page.locator() just works. Closed Shadow DOM is not accessible. Selenium requires additional steps for Shadow DOM access.

Frequently Asked Questions (FAQ)

Q. Is there a tool that generates locators automatically?

Playwright Codegen (playwright codegen https://example.com) lets you operate the browser and generates locator code as you go. Use the generated code as a starting point, then refine it toward data-testid-based locators where possible. In Chrome DevTools, you can also test CSS selectors with document.querySelector('.btn') and XPath with $x('//button') right in the console.

Q. Which is faster — CSS selectors or XPath?

CSS selectors are generally slightly faster, since browsers’ rendering engines are optimized for them. In practice though, the difference is imperceptible in test execution time. Prioritize readability and stability over performance when choosing locators.

Q. Is it enough to learn just CSS selectors, or do I need XPath too?

Learn CSS selectors first. They’re more readable, easier to write, and cover the majority of real-world use cases. Use XPath when you need to match by text content, traverse upward to a parent element, or handle conditions CSS can’t express. Learning a bit of both is the most practical approach.

Q. How do I test CSS selectors and XPath in Chrome DevTools?

Open the page in Chrome, right-click an element → “Inspect” to see the HTML. In the DevTools console, type document.querySelector('#email') to test a CSS selector on the spot. For XPath, use $x("//button[@type='submit']").

Q. What ARIA roles can I use with Playwright’s get_by_role?

It’s based on ARIA roles: button, textbox, checkbox, link, heading, combobox, listbox, and many more. Most HTML elements have a corresponding ARIA role. See the “getByRole” section in the Playwright docs for the full list.

Q. How do I ask the dev team to add data-testid attributes?

“For test automation, we need stable identifiers on key interactive elements. Could you add data-testid attributes to buttons, inputs, and links?” It helps to point out that data-testid is a test-only attribute and has zero effect on production behavior — that tends to make the request easier to accept. Agreeing on a naming convention upfront makes things smoother for everyone.

Q. Which has more expressive locators — Selenium or Playwright?

For new projects, Playwright’s locator API is more expressive and produces more resilient tests. Semantic locators like get_by_role and get_by_test_id are available out of the box. For existing Selenium projects, pairing data-testid with CSS selectors provides plenty of stability.

Summary

Key pointWhat to remember
Locator priority orderdata-testid → ID → CSS → XPath (relative)
CSS selectors#id, .class, [attr], parent-child. Readable and concise
XPathFor text content, upward traversal, and conditions CSS can’t handle. Avoid absolute XPath
Playwright locatorsget_by_test_id → get_by_role → get_by_label (in that order)
data-testidSet up with the dev team. Focus on interactive elements. Immune to design changes
What to avoidAbsolute XPath, dynamic class names, positional selectors (div[2], etc.)

Improving how you write locators can make a dramatic difference in test stability.
A good first step: scan your existing test code for absolute XPath and dynamic class names, then rewrite them using data-testid or attribute-based selectors.

Once your locators are stable, the natural next step is using Page Object Model (POM) to organize your test code into a structure that’s even easier to maintain.

Copied title and URL