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_roleandget_by_textare 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
| Type | Example | Stability | Recommendation |
|---|---|---|---|
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
| Situation | CSS | XPath |
|---|---|---|
| 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 |
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
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
| Aspect | Selenium | Playwright |
|---|---|---|
| Default waiting behavior | None (immediate lookup) | ◎ Auto-waiting built in |
| Explicit waits | Must write WebDriverWait yourself | Rarely 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
| Feature | Selenium | Playwright |
|---|---|---|
| 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
| Priority | Locator | When to use |
|---|---|---|
| ① First choice | get_by_test_id() | When data-testid attributes are already set up. Requires coordination with the dev team |
| ② Recommended | get_by_role() | Buttons, text inputs, checkboxes, and other interactive elements |
| ③ Recommended | get_by_label() | Form inputs associated with a <label> element |
| ④ Acceptable | get_by_text() | Elements identifiable by visible text (use with caution in multilingual apps) |
| ⑤ Acceptable | locator() (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")
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.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.
- Selenium + Python Setup — From WebDriver Config to Your First Test
- Getting Started with Playwright — Common Pitfalls and How to Avoid Them
- Page Object Model — A Design Pattern for Maintainable E2E Tests
- What Is Software Testing? — Unit, Integration & E2E Tests Explained
- Fixing Flaky Tests — A Practical Guide from Root Cause to Resolution
7 Common Locator Pitfalls
⚠️ Watch out for these
- 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. - Using dynamically generated class names or IDs
Frameworks like React and Vue sometimes generate hashed class names likeclass="btn-abc123"at build time. Using these values as locators breaks every build. Usedata-testidor stable attributes instead. - 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. Usefind_elements(plural) to check how many matched, and tighten the locator. - Can’t access elements inside an iframe
Elements inside an iframe aren’t reachable with a plainfind_element. In Selenium, switch context first withdriver.switch_to.frame(). In Playwright, usepage.frame_locator(). - Trying to interact with an element that isn’t visible yet
Accessing elements while the page is still loading or waiting on async requests raisesElementNotInteractableException. In Selenium, useWebDriverWait. Playwright handles this with auto-waiting, though hidden elements still need extra handling. - Text-based locators break in multilingual apps
//button[text()='Login']fails when the page renders in another language. In multilingual apps, usedata-testidor roles instead of visible text. - 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 regularpage.locator()just works. Closed Shadow DOM is not accessible. Selenium requires additional steps for Shadow DOM access.
Frequently Asked Questions (FAQ)
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.
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.
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.
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']").
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.
“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.
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 point | What to remember |
|---|---|
| Locator priority order | data-testid → ID → CSS → XPath (relative) |
| CSS selectors | #id, .class, [attr], parent-child. Readable and concise |
| XPath | For text content, upward traversal, and conditions CSS can’t handle. Avoid absolute XPath |
| Playwright locators | get_by_test_id → get_by_role → get_by_label (in that order) |
| data-testid | Set up with the dev team. Focus on interactive elements. Immune to design changes |
| What to avoid | Absolute 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.

