Skip to main content
Selenium beginner Lesson 2 of 10

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:

  1. A stable test attribute[data-testid="submit"]. Ask developers for one.
  2. By.ID, when the id is authored rather than generated.
  3. By.CSS_SELECTOR on attributes tied to behaviour: name, type, aria-label, role.
  4. By.XPATH, for text matching and parent traversal only.
  5. 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.

Frequently Asked Questions

Should I use CSS selectors or XPath in Selenium?
CSS for almost everything — it is faster, more readable, and supported identically across browsers. Use XPath for the two things CSS cannot do: matching on visible text, and walking up to a parent or ancestor.
Why do my tests break when the developers change the CSS?
Because the locator binds to styling rather than to identity. Ask for a stable `data-testid` attribute, or locate by an attribute tied to behaviour such as `name` or `aria-label`. A class named `btn-primary-lg` is a styling decision and will change.
What are relative locators in Selenium 4?
`locate_with(...).above(...)`, `.below()`, `.near()`, `.to_left_of()` and `.to_right_of()` find elements by visual position relative to another element. They are useful for grids and forms with no structural relationship, and fragile under responsive layout changes.
How do I find an element inside another element?
Call `find_element` on the parent element rather than on the driver — `row.find_element(By.CSS_SELECTOR, 'input')` searches only within that row. This scoping is how you avoid brittle `nth-child` chains when a page has repeating structures.