Locators: Finding Elements Reliably
The eight By strategies, when XPath earns its keep over CSS, Selenium 4's relative locators, and why a generated id makes a test that breaks next deploy.
Selenium gives you eight ways to find an element. Two of them do almost all the work, and the difference between a suite that survives a redesign and one that does not is mostly which locator you reached for.
The eight strategies
from selenium.webdriver.common.by import By
driver.find_element(By.ID, "todo-input")
driver.find_element(By.NAME, "email")
driver.find_element(By.CLASS_NAME, "new-todo")
driver.find_element(By.TAG_NAME, "h1")
driver.find_element(By.LINK_TEXT, "Active")
driver.find_element(By.PARTIAL_LINK_TEXT, "Act")
driver.find_element(By.CSS_SELECTOR, ".todo-list li .toggle")
driver.find_element(By.XPATH, "//li[contains(@class,'completed')]//label")
with webdriver.Chrome(options=opts) as driver:
driver.get("https://demo.playwright.dev/todomvc")
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
print("by class: ", driver.find_element(By.CLASS_NAME, "todo-count").text)
print("by css: ", driver.find_element(By.CSS_SELECTOR, ".todo-list li label").text)
print("by link: ", driver.find_element(By.LINK_TEXT, "Active").get_attribute("href"))
print("by xpath: ", driver.find_element(By.XPATH, "//label[text()='buy milk']").text)
by class: 1 item left
by css: buy milk
by link: https://demo.playwright.dev/todomvc/#/active
by xpath: buy milk
In preference order:
- A stable test attribute —
[data-testid="submit"]. Ask developers for one. By.ID, when the id is authored rather than generated.By.CSS_SELECTORon attributes tied to behaviour:name,type,aria-label,role.By.XPATH, for text matching and parent traversal only.- Everything else.
CSS, which covers most cases
driver.find_element(By.CSS_SELECTOR, "#todo-input") # id
driver.find_element(By.CSS_SELECTOR, ".todo-list li") # class
driver.find_element(By.CSS_SELECTOR, "input[name='email']") # attribute
driver.find_element(By.CSS_SELECTOR, "input[type='checkbox']:checked")
driver.find_element(By.CSS_SELECTOR, "[aria-label='Delete order']")
driver.find_element(By.CSS_SELECTOR, "a[href$='/active']") # ends with
driver.find_element(By.CSS_SELECTOR, "[data-testid^='row-']") # starts with
driver.find_element(By.CSS_SELECTOR, ".todo-list li:first-child")
driver.find_element(By.CSS_SELECTOR, "footer .todo-count") # descendant
driver.find_element(By.CSS_SELECTOR, ".main > ul") # direct child
print(driver.find_element(By.CSS_SELECTOR, "a[href$='/active']").text)
print(len(driver.find_elements(By.CSS_SELECTOR, ".todo-list li:not(.completed)")))
Active
1
Attribute substring selectors — ^= starts with, $= ends with, *= contains — are the
answer to most “the id has a random suffix” problems:
# id="order-row-a7f3c9" changes every render
driver.find_element(By.CSS_SELECTOR, "[id^='order-row-']")
XPath, for the two things CSS cannot do
Match on visible text:
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("walk the dog", Keys.ENTER)
exact = driver.find_element(By.XPATH, "//label[text()='buy milk']")
partial = driver.find_element(By.XPATH, "//label[contains(text(), 'dog')]")
print(exact.text, "|", partial.text)
buy milk | walk the dog
Walk upward to an ancestor:
row = driver.find_element(
By.XPATH, "//label[text()='buy milk']/ancestor::li"
)
row.find_element(By.CSS_SELECTOR, ".toggle").click()
print("row classes:", row.get_attribute("class"))
row classes: completed
“Find the row containing this text, then tick its checkbox” is the single most common thing a test needs to express, and CSS has no parent selector. This is XPath’s real job.
normalize-space() is worth knowing, because markup rarely has clean whitespace:
driver.find_element(By.XPATH, "//label[normalize-space()='buy milk']")
Avoid absolute paths entirely:
driver.find_element(By.XPATH, "/html/body/div/section/div/ul/li[1]/div/label")
selenium.common.exceptions.NoSuchElementException: Message: no such element
One wrapper <div> added by a developer and every such locator breaks. Copying “Full XPath”
out of devtools produces exactly this.
Scoping to a parent
rows = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")
for row in rows:
label = row.find_element(By.CSS_SELECTOR, "label").text
done = row.find_element(By.CSS_SELECTOR, ".toggle").is_selected()
print(f"{label:<14} {'done' if done else 'open'}")
buy milk done
walk the dog open
Calling find_element on an element searches only inside it. That is how you handle
repeating structures without nth-child chains that break when a row is inserted.
The row-by-text pattern, which is worth memorising:
def row_for(driver, text):
return driver.find_element(By.XPATH, f"//label[normalize-space()='{text}']/ancestor::li")
row_for(driver, "walk the dog").find_element(By.CSS_SELECTOR, ".toggle").click()
print(driver.find_element(By.CLASS_NAME, "todo-count").text)
0 items left
Relative locators
Selenium 4 can find elements by visual position:
from selenium.webdriver.support.relative_locator import locate_with
milk_label = driver.find_element(By.XPATH, "//label[text()='buy milk']")
checkbox = driver.find_element(
locate_with(By.CSS_SELECTOR, "input").to_left_of(milk_label)
)
checkbox.click()
print("checked:", checkbox.is_selected())
below = driver.find_element(
locate_with(By.TAG_NAME, "label").below(milk_label)
)
print("below:", below.text)
checked: True
below: walk the dog
Also .above(), .near() (within 50px by default) and .to_right_of(). They are genuinely
useful for a table cell with no distinguishing attributes, and genuinely fragile — a
responsive breakpoint that stacks a row vertically breaks every one of them. Reach for them
when the DOM gives you nothing better, not as a default.
Locators that will break
driver.find_element(By.CSS_SELECTOR, "#mui-4821") # generated id
driver.find_element(By.CSS_SELECTOR, ".css-1x2y3z4") # CSS-in-JS hash
driver.find_element(By.CSS_SELECTOR, "div > div > div:nth-child(3)") # structural
driver.find_element(By.XPATH, "/html/body/div[2]/div/section/ul/li[1]") # absolute
driver.find_element(By.CLASS_NAME, "btn-primary-lg-rounded") # styling
Each one binds a test to something no user perceives, so a change that breaks nothing for a user breaks your suite. The fix is nearly always to ask for a test attribute:
<button data-testid="submit-order">Place order</button>
driver.find_element(By.CSS_SELECTOR, "[data-testid='submit-order']")
That is a five-minute conversation with a developer that saves hours a month. Failing that,
prefer accessibility attributes — aria-label, role, name — since those break only when
the page genuinely changes for users too.
Checking existence without an exception
from selenium.common.exceptions import NoSuchElementException
def exists(driver, by, value):
return len(driver.find_elements(by, value)) > 0
print(exists(driver, By.CLASS_NAME, "todo-count"))
print(exists(driver, By.ID, "nope"))
True
False
find_elements returning a list is the clean way to assert absence. Note that neither form
waits — find_elements returns [] immediately if the page has not rendered, which makes it
a poor way to prove something is not there. Lesson 3 covers doing that properly.
Practice
1. Find the row containing a given text and tick its checkbox.
row = driver.find_element(By.XPATH, "//label[normalize-space()='buy milk']/ancestor::li")
row.find_element(By.CSS_SELECTOR, ".toggle").click()
print(row.get_attribute("class"))
completed
CSS cannot express “the ancestor of the thing containing this text”, which is why XPath keeps its place despite being slower and harder to read.
2. Copy an absolute XPath from devtools, then add a wrapper div.
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to
locate element: {"method":"xpath","selector":"/html/body/div/section/div/ul/li[1]/div/label"}
One layout change and the locator is dead. Devtools’ “Copy full XPath” is a trap; “Copy
selector” is only slightly better, since it produces nth-child chains.
3. Scope a search to a parent element.
row = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")[1]
print(row.find_element(By.CSS_SELECTOR, "label").text)
walk the dog
Searching within the row means the locator inside it can stay simple. It also documents
intent — row.find_element(...) says which row you meant.
4. Use a relative locator, then narrow the browser window.
driver.set_window_size(400, 800)
driver.find_element(locate_with(By.CSS_SELECTOR, "input").to_left_of(label))
selenium.common.exceptions.NoSuchElementException: Cannot locate relative element with:
{'css selector': 'input'}
At a narrow width the layout stacked and nothing is to the left any more. Relative locators depend on rendered geometry, so they are a last resort rather than a style.
Next: waits — the difference between a test suite that passes and one that passes reliably.