XPath Locators in Playwright
CSS selectors and role-based locators cover 90% of automation needs. But when you're staring at a legacy table with no IDs, no roles, and dynamically generated classes, XPath is the swiss army knife that saves the day.
📖 The Story: The Legacy Table Nightmare
Meet Priya. She joined a company with a 10-year-old web application built with server-rendered HTML tables. No React. No Vue. No semantic HTML. No ARIA roles. No data-testid attributes. Just <tr>, <td>, and dynamically generated class names like .cell_X7A2 that changed every build.
getByRole? No buttons with roles. getByLabel? No labels. getByTestId? No test IDs existed. CSS selectors? The classes were hashed gibberish. Priya's only option was XPath—the only tool powerful enough to traverse that table and find "the cell in the 3rd row that contains 'Total' and then click the button in the adjacent cell."
XPath is your tool of last resort. But when you need it, nothing else will do.
🎯 When to Use XPath
Legacy Systems
Old apps with no semantic HTML, no ARIA roles, no test IDs.
DOM Traversal
Moving UP the DOM tree (parent, ancestor) — impossible with CSS.
Complex Text
Finding elements by partial text when getByText isn't enough.
🧒 Explain Like I'm 10
CSS selectors are like using a street address (123 Main Street, Apt 4B). XPath is like giving directions using landmarks (Go to the building with the red door, then turn left to the 3rd room on the right). Street addresses are faster and more reliable—but sometimes a building doesn't have an address, and you need landmarks.
🎓 Professional Explanation
XPath (XML Path Language) is a query language for selecting nodes from an XML/HTML document. Playwright supports XPath via page.locator('xpath=//...') or implicitly when a string starts with // or ... XPath is more powerful than CSS selectors but slower to evaluate because the browser engine must compile and execute the XPath query rather than using the native CSS selector engine.
⚖️ Absolute vs Relative XPath
| Feature | Absolute XPath | Relative XPath |
|---|---|---|
| Starts with | /html/body/... | // |
| Length | Very long | Short |
| Resilience | ❌ Breaks with any DOM change | ✅ Survives most DOM changes |
| Speed | Slower (traverses from root) | Faster (searches anywhere) |
| Recommendation | ❌ Never use | ✅ Always use |
// ❌ Absolute XPath — NEVER use this page.locator('/html/body/div[2]/form/table/tr[3]/td[1]/input') // ✅ Relative XPath — Use this page.locator('//input[@id="email"]')
📋 XPath Syntax Reference
| Syntax | What It Does | Example |
|---|---|---|
//tagname | Find all elements with tag | //button |
//tagname[@attr='val'] | By attribute | //input[@type='submit'] |
//*[@attr='val'] | Any element with attribute | //*[@data-role='admin'] |
//tagname[contains(@attr, 'val')] | Partial attribute match | //div[contains(@class, 'active')] |
//tagname[text()='Val'] | Exact text match | //button[text()='Login'] |
//tagname[contains(text(), 'Val')] | Partial text match | //h2[contains(text(), 'Welcome')] |
//tag1//tag2 | Descendant | //form//input |
//tag1/tag2 | Direct child | //div/button |
.. | Parent element | //input[@id='x']/.. |
following-sibling:: | Next siblings | //td[text()='Label']/following-sibling::td |
preceding-sibling:: | Previous siblings | //td[text()='Value']/preceding-sibling::td |
🎭 Using XPath in Playwright
// Method 1: Explicit xpath= prefix (recommended for clarity) await page.locator('xpath=//button[text()="Submit"]').click(); // Method 2: Implicit (string starts with // or ..) await page.locator('//button[text()="Submit"]').click(); // Find row by text, then click button in adjacent cell await page.locator('//tr[td[text()="John Doe"]]//button[text()="Edit"]').click(); // Find element by partial text using contains await page.locator('//*[contains(text(), "Welcome back")]').isVisible(); // Traverse UP to parent (CSS can't do this!) await page.locator('//input[@id="email"]/..').evaluate(el => el.tagName);
When to use xpath= prefix: Always. It makes your intent explicit and prevents confusion with CSS selectors. A string starting with // works implicitly, but xpath= is self-documenting.
⚠️ Common Mistakes
Mistake 1: Using Absolute XPath from Browser DevTools
// ❌ Chrome DevTools "Copy XPath" gives absolute paths page.locator('/html/body/div[3]/div[1]/section[2]/ul/li[5]/a') // ✅ Write a relative XPath instead page.locator('xpath=//a[contains(text(), "Contact Us")]')
Mistake 2: Using XPath for Things CSS Does Better
// ❌ XPath for simple ID lookup — CSS is faster page.locator('xpath=//*[@id="submit"]') // ✅ CSS for ID — native and faster page.locator('#submit')
Mistake 3: Fragile Index-Based XPath
// ❌ Breaks if a new row is added page.locator('xpath=//tr[3]/td[2]') // ✅ Find by content, not position page.locator('xpath=//tr[td[text()="John Doe"]]/td[2]')
✅ Best Practices
- Use XPath only as a last resort — prefer getByRole, getByText, CSS, then XPath.
- Always use relative XPath (starts with
//), never absolute. - Always use
xpath=prefix for explicitness. - Prefer
contains()over exact match for text and classes to survive minor changes. - Avoid index-based selectors (
//div[3]) — use content-based matching instead. - Use XPath for DOM traversal (parent, sibling) where CSS can't reach.
- If you're using XPath for everything, refactor your app — add data-testid or ARIA roles.
🏗️ Senior Engineer Deep Dive
Why Playwright Discourages XPath
Playwright's official guidance is clear: Avoid XPath. The reasons are architectural:
- Performance: XPath queries are compiled and evaluated by a separate engine in the browser. CSS selectors use the browser's native
querySelectorengine, which is heavily optimized. In benchmarks, CSS selectors are 2-5x faster. - Strictness: Playwright locators (getByRole, etc.) are strict by default. XPath strings are not — they can silently match multiple elements or the wrong element.
- Resilience: XPath is tied to DOM structure. Semantic locators are tied to user-perceivable behavior. DOM structure changes frequently; user behavior doesn't.
When XPath Is the ONLY Option
There is exactly one scenario where XPath is superior: traversing UP the DOM tree. CSS selectors can only traverse down (parent > child, ancestor descendant). XPath can traverse in any direction using axes like parent::, ancestor::, following-sibling::, and preceding-sibling::. If you need to "find the parent form of this input" or "find the cell next to this label," XPath is your only option.
💼 Interview Questions
Show answer
/html/body/...) and traces the full path. It breaks with any DOM change. Relative XPath starts with // and searches anywhere in the document, making it much more resilient to DOM restructuring.Show answer
Show answer
🏋️ Practical Exercises
XPath Scavenger Hunt
- Navigate to
https://demo.playwright.dev/todomvc. - Write an XPath that finds the input by its placeholder using
contains(). - Add 3 todos. Write an XPath that finds the second todo item by its text.
- Write an XPath that finds the count text ("3 items left") using
contains(text(), 'items'). - Write an XPath that traverses from a todo item UP to its parent
liusing/...
🧠 Quiz
///./*//input[@id='email']/.. select?text()contains()starts-with()equals()select=xpath=path=//*[contains(@class, 'active')] find?following-sibling:: do in XPath?❓ FAQ
getByRole?Show answer
page.locator('xpath=//...'). The built-in methods (getByRole, getByText) use their own engines and don't accept XPath syntax.Show answer
/html/body/div[3]/...). These are extremely brittle and break with any DOM change. Always write relative XPath manually.xpath=//button and just //button in Playwright?Show answer
// or .. as XPath. However, using the xpath= prefix is recommended because it makes your intent explicit and self-documenting.📌 Summary
🎯 Key Takeaways
- XPath is a last-resort tool — prefer getByRole, getByText, CSS selectors first.
- Always use relative XPath (
//), never absolute (/html/body/...). - Use
xpath=prefix for explicit, self-documenting locators. - XPath's superpower: DOM traversal in any direction (parent, sibling, ancestor).
- CSS is faster — XPath uses a separate evaluation engine.
- Use
contains()for partial text and attribute matching. - Avoid index-based XPath (
//div[3]) — match by content instead. - If you use XPath everywhere, refactor your app — add test IDs and ARIA roles.
