CSS selectors handle most locator needs, but XPath fills the gaps CSS cannot reach. Need to find an element by its visible text? XPath. Need to navigate from a child element up to its parent? XPath. Need to combine text content with attribute conditions in a single expression? XPath. Understanding when XPath is the right tool — and when it is overkill — keeps your test suite both powerful and maintainable.
XPath Syntax — Axes, Functions and Practical Patterns
XPath navigates the DOM as a tree, using path expressions to select nodes. While its syntax is more verbose than CSS, it offers capabilities that CSS simply does not have.
from selenium.webdriver.common.by import By
# ── XPath patterns — when CSS cannot do the job ──
XPATH_PATTERNS = [
# --- Text-based selection (CSS cannot do this) ---
{
"name": "Exact text match",
"xpath": "//button[text()='Submit Order']",
"matches": "<button>Submit Order</button>",
"css_possible": False,
"use": "Finding buttons/links by their visible label",
},
{
"name": "Partial text (contains)",
"xpath": "//button[contains(text(),'Submit')]",
"matches": "Any button whose text includes 'Submit'",
"css_possible": False,
"use": "When text has dynamic suffixes (e.g. 'Submit (3 items)')",
},
{
"name": "Normalise whitespace",
"xpath": "//button[normalize-space()='Submit Order']",
"matches": "Button with text 'Submit Order' ignoring extra spaces/newlines",
"css_possible": False,
"use": "Text matching when HTML has irregular whitespace",
},
# --- Parent / ancestor traversal (CSS cannot go upward) ---
{
"name": "Parent axis",
"xpath": "//input[@id='email']/parent::div",
"matches": "The <div> that is the direct parent of the email input",
"css_possible": False,
"use": "Navigating up to a container from a known child",
},
{
"name": "Ancestor axis",
"xpath": "//span[text()='Error']/ancestor::form",
"matches": "The <form> that contains a <span> with text 'Error'",
"css_possible": False,
"use": "Finding a container based on content of a descendant",
},
{
"name": "Following sibling",
"xpath": "//label[text()='Email']/following-sibling::input",
"matches": "The <input> that follows a <label> with text 'Email'",
"css_possible": False,
"use": "Targeting an input by the text of its label",
},
# --- Combining conditions ---
{
"name": "AND condition",
"xpath": "//input[@type='text' and @name='search']",
"matches": "Text input with name 'search'",
"css_possible": True,
"use": "Multiple attribute conditions (CSS can also do this)",
},
{
"name": "OR condition",
"xpath": "//input[@type='text' or @type='email']",
"matches": "Input that is either text or email type",
"css_possible": False,
"use": "Matching elements that could be one of several types",
},
{
"name": "Position (index)",
"xpath": "(//div[@class='product-card'])[3]",
"matches": "Third product card on the page",
"css_possible": True,
"use": "Selecting a specific item by index (1-based in XPath)",
},
# --- Anti-patterns to AVOID ---
{
"name": "AVOID: Absolute path",
"xpath": "/html/body/div[1]/div[2]/form/div/input",
"matches": "Fragile — breaks if ANY ancestor changes",
"css_possible": True,
"use": "NEVER — always use relative paths (//)",
},
]
print("XPath Patterns — When CSS Cannot Do the Job")
print("=" * 70)
css_count = sum(1 for p in XPATH_PATTERNS if not p["css_possible"])
print(f"\n {css_count} of {len(XPATH_PATTERNS)} patterns are XPath-exclusive (CSS cannot replicate)\n")
for p in XPATH_PATTERNS:
exclusive = " [XPath-exclusive]" if not p["css_possible"] else ""
print(f" {p['name']}{exclusive}")
print(f" XPath: {p['xpath']}")
print(f" Use: {p['use']}")
print()
(//div[@class='item'])[1] selects the first item, not the second. This is the opposite of most programming languages and CSS’s :nth-child(1) (which is also 1-indexed, but developers often confuse the two). Getting the index wrong is a common source of “element not found” errors that are difficult to debug because the selector looks correct at a glance.contains() and normalize-space() together for robust text matching: //button[contains(normalize-space(), 'Submit')]. This handles elements where the text has leading/trailing whitespace, line breaks, or extra spaces between words — all common in real HTML. Without normalize-space(), an exact text match fails on <button> Submit </button> because the text includes invisible whitespace./html/body/div[1]/div[2]/form/input are the most fragile locators in all of Selenium. They encode the exact position of every ancestor element. Adding a single wrapper div, reordering sections, or inserting a banner at the top of the page breaks every absolute XPath in your suite. Always start with // (descendant search) and target elements by attributes or text, not by their exact position in the DOM tree.Common Mistakes
Mistake 1 — Using XPath when CSS can do the job
❌ Wrong: //input[@type='email'] — this works but is slower and more verbose than the CSS equivalent.
✅ Correct: input[type='email'] (CSS) — use XPath only when you need text matching, parent traversal, or OR conditions that CSS cannot express.
Mistake 2 — Copying XPath from Chrome DevTools without review
❌ Wrong: Right-clicking an element → “Copy XPath” → pasting /html/body/div[3]/div/div/ul/li[2]/a directly into your test.
✅ Correct: Using the copied XPath as a starting point, then rewriting it as a relative, attribute-based expression: //ul[@class='nav-menu']//a[text()='Products'].