Skip to main content
SeleniumDecoded

Locator Strategy Guide

A decision framework for choosing locators that survive UI changes: test IDs, the priority order, handling dynamic attributes, text-based lookups, uniqueness checks, and the anti-patterns that make suites brittle.

Selenium 3 & 4 Stable Updated 9 Sept 2026 · Verified against Selenium 4.48.0

The previous lessons taught the eight locator types. This one teaches judgement: given a real page, which locator do you write, and how do you keep it working for the next two years? Most locator failures are not caused by the wrong syntax but by the wrong choice.

The Priority Order

Work down this list and stop at the first that gives you a unique, readable match:

  1. A dedicated test attribute such as data-testid, data-test or data-qa. Exists only for tests, so nobody changes it for styling reasons.
  2. id, if it is stable and human-written (id="checkout-button", not id="ember1234").
  3. name, mainly for form controls; also stable because the backend depends on it.
  4. A CSS selector on semantic attributes: button[type=submit], input[aria-label='Search'], a[href$='/pricing'].
  5. Link text or partial link text, for navigation links whose wording is part of the product.
  6. A CSS selector on structure, kept as short as possible: form.login button.
  7. XPath, when you need text matching, parent traversal or conditions CSS cannot express.
  8. Relative locators, when only visual position is stable.

Notice what is not on the list: absolute XPath (/html/body/div[3]/div/ul/li[2]/a), class names used purely for styling (.mt-4.flex.items-center), and index-based selectors (div:nth-child(7)). They all pass today and fail on the next redesign.

Ask for Test IDs

If you can influence the codebase, the highest-leverage thing you can do for a Selenium suite is get data-testid attributes added to interactive elements. It costs developers seconds and removes most locator maintenance. Agree on a naming convention (feature-element-action, e.g. checkout-place-order) and enforce it in code review.

Locating by test ID, with a small helper
Selenium 3 & 4 Stable
// A helper keeps the attribute name in one place
public static By testId(String id) {
return By.cssSelector("[data-testid='" + id + "']");
}
driver.findElement(testId("checkout-place-order")).click();
driver.findElement(testId("checkout-promo-code")).sendKeys("SAVE10");
def test_id(value: str):
return (By.CSS_SELECTOR, f"[data-testid='{value}']")
driver.find_element(*test_id("checkout-place-order")).click()
driver.find_element(*test_id("checkout-promo-code")).send_keys("SAVE10")
const testId = (value) => By.css(`[data-testid='${value}']`);
await driver.findElement(testId('checkout-place-order')).click();
await driver.findElement(testId('checkout-promo-code')).sendKeys('SAVE10');
public static By TestId(string value) => By.CssSelector($"[data-testid='{value}']");
driver.FindElement(TestId("checkout-place-order")).Click();
driver.FindElement(TestId("checkout-promo-code")).SendKeys("SAVE10");

Handling Dynamic Attributes

Frameworks generate ids like input-7f3a or classes like css-1x2y3z. Use the stable part with CSS attribute operators, or move to a different attribute entirely.

OperatorMeaningExample
[attr^='x']starts with[id^='product-card-']
[attr$='x']ends with[href$='.pdf']
[attr*='x']contains[class*='btn-primary']
[attr~='x']whole word in space-separated list[class~='active']
Partial attribute matching
Selenium 3 & 4 Medium
// id is "order-row-8817" where 8817 changes per order
List<WebElement> rows = driver.findElements(By.cssSelector("[id^='order-row-']"));
// Class list contains a stable token among generated ones
WebElement primary = driver.findElement(By.cssSelector("button[class*='primary']"));
// XPath equivalent when you also need text
WebElement cancel = driver.findElement(
By.xpath("//button[contains(@class,'secondary') and normalize-space()='Cancel']"));
rows = driver.find_elements(By.CSS_SELECTOR, "[id^='order-row-']")
primary = driver.find_element(By.CSS_SELECTOR, "button[class*='primary']")
cancel = driver.find_element(
By.XPATH, "//button[contains(@class,'secondary') and normalize-space()='Cancel']")
const rows = await driver.findElements(By.css("[id^='order-row-']"));
const primary = await driver.findElement(By.css("button[class*='primary']"));
const cancel = await driver.findElement(
By.xpath("//button[contains(@class,'secondary') and normalize-space()='Cancel']"));
var rows = driver.FindElements(By.CssSelector("[id^='order-row-']"));
var primary = driver.FindElement(By.CssSelector("button[class*='primary']"));
var cancel = driver.FindElement(
By.XPath("//button[contains(@class,'secondary') and normalize-space()='Cancel']"));

Be careful with contains on class names: [class*='btn'] also matches btn-danger and subtn. Prefer ~= for whole-word matches.

Text-Based Locators

Only XPath can match on visible text (CSS has no text selector). Use normalize-space() to ignore whitespace and prefer exact matches over contains so “Save” does not match “Save as draft”. For deep XPath coverage, XPath Decoded has a full reference.

Matching by text
Selenium 3 & 4 Medium
// Exact text, whitespace-insensitive
By save = By.xpath("//button[normalize-space()='Save']");
// Text inside a child span: use . (string value of the element)
By addToCart = By.xpath("//button[normalize-space(.)='Add to cart']");
// Case-insensitive contains (XPath 1.0 has no lower-case(); translate() is the idiom)
By search = By.xpath("//a[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'pricing')]");
// Link text is simpler for anchors
By pricing = By.linkText("Pricing");
save = (By.XPATH, "//button[normalize-space()='Save']")
add_to_cart = (By.XPATH, "//button[normalize-space(.)='Add to cart']")
pricing = (By.LINK_TEXT, "Pricing")
const save = By.xpath("//button[normalize-space()='Save']");
const addToCart = By.xpath("//button[normalize-space(.)='Add to cart']");
const pricing = By.linkText('Pricing');
var save = By.XPath("//button[normalize-space()='Save']");
var addToCart = By.XPath("//button[normalize-space(.)='Add to cart']");
var pricing = By.LinkText("Pricing");

Text locators couple tests to copy. That is fine for product-critical wording (“Place order”) and wrong for text that marketing may change weekly. Localised apps need a different strategy entirely: test IDs or ARIA attributes.

Scope Your Searches

Searching from a container instead of the whole document makes locators shorter and unique. WebElement.findElement searches only within that element.

Search within a container
Selenium 3 & 4 Stable
WebElement card = driver.findElement(By.cssSelector("[data-testid='product-card-laptop']"));
WebElement price = card.findElement(By.cssSelector(".price"));
WebElement buy = card.findElement(By.tagName("button"));
// Table row by content, then a cell inside it
WebElement row = driver.findElement(By.xpath("//tr[td[normalize-space()='INV-1042']]"));
row.findElement(By.cssSelector("button.download")).click();
card = driver.find_element(By.CSS_SELECTOR, "[data-testid='product-card-laptop']")
price = card.find_element(By.CSS_SELECTOR, ".price")
buy = card.find_element(By.TAG_NAME, "button")
row = driver.find_element(By.XPATH, "//tr[td[normalize-space()='INV-1042']]")
row.find_element(By.CSS_SELECTOR, "button.download").click()
const card = await driver.findElement(By.css("[data-testid='product-card-laptop']"));
const price = await card.findElement(By.css('.price'));
const buy = await card.findElement(By.css('button'));
const row = await driver.findElement(By.xpath("//tr[td[normalize-space()='INV-1042']]"));
await row.findElement(By.css('button.download')).click();
var card = driver.FindElement(By.CssSelector("[data-testid='product-card-laptop']"));
var price = card.FindElement(By.CssSelector(".price"));
var buy = card.FindElement(By.TagName("button"));
var row = driver.FindElement(By.XPath("//tr[td[normalize-space()='INV-1042']]"));
row.FindElement(By.CssSelector("button.download")).Click();

Watch out for one XPath trap: inside card.findElement(By.xpath("//button")), the leading // still searches the whole document. Use .//button to search relative to the element.

Verify Uniqueness Before You Commit

findElement returns the first match silently. A locator that matches three elements works until the order changes. Check counts while writing locators, and consider a helper that fails on ambiguity.

Fail fast on ambiguous locators
Selenium 3 & 4 Stable
public static WebElement findUnique(SearchContext ctx, By by) {
List<WebElement> matches = ctx.findElements(by);
if (matches.size() != 1) {
throw new IllegalStateException("Expected 1 element for " + by + " but found " + matches.size());
}
return matches.get(0);
}
def find_unique(ctx, by, value):
matches = ctx.find_elements(by, value)
if len(matches) != 1:
raise AssertionError(f"Expected 1 element for {by}={value!r}, found {len(matches)}")
return matches[0]
async function findUnique(ctx, locator) {
const matches = await ctx.findElements(locator);
if (matches.length !== 1) {
throw new Error(`Expected 1 element for ${locator}, found ${matches.length}`);
}
return matches[0];
}
public static IWebElement FindUnique(ISearchContext ctx, By by)
{
var matches = ctx.FindElements(by);
if (matches.Count != 1)
throw new InvalidOperationException($"Expected 1 element for {by}, found {matches.Count}");
return matches[0];
}

In DevTools, $$('selector') and $x('xpath') return arrays so you can check counts before pasting into code.

Anti-Patterns and Their Fixes

Anti-patternWhy it breaksFix
Absolute XPath from DevTools “Copy XPath”Any structural changeUse attributes; DevTools “Copy selector” is only marginally better
Styling classes (.flex.mt-4.text-lg)Designers change them constantlyTest IDs or semantic attributes
nth-child / indexOrder changes with new contentMatch by content or attribute
contains(@class,'btn')Matches too muchWhole-word ~= or exact class
Locators inline in testsDuplicated across 40 testsPage Objects: one locator, one place
Text locators for changeable copyCopy edits break testsTest IDs; reserve text for product-critical labels
Locating by title or alt for buttonsOften missing or localisedARIA aria-label, role plus name

A Locator Review Checklist

Before merging a new page object, check each locator:

  • Would it survive a CSS refactor? (No styling classes.)
  • Would it survive reordering content? (No indexes.)
  • Is it unique on every state of the page, including with empty lists and error banners?
  • Is it readable? Someone else should know what element it targets without opening the page.
  • Is it as short as it can be while meeting the above?

Summary

  • Prefer test IDs, then ids and names, then semantic CSS, then text, then structure. Absolute XPath and styling classes are last resort or never.
  • Use attribute operators for dynamic values and normalize-space() for text.
  • Scope searches to containers and verify uniqueness while writing.
  • Locators live in page objects so a UI change is a one-line fix.

Related lessons