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.
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:
- A dedicated test attribute such as
data-testid,data-testordata-qa. Exists only for tests, so nobody changes it for styling reasons. id, if it is stable and human-written (id="checkout-button", notid="ember1234").name, mainly for form controls; also stable because the backend depends on it.- A CSS selector on semantic attributes:
button[type=submit],input[aria-label='Search'],a[href$='/pricing']. - Link text or partial link text, for navigation links whose wording is part of the product.
- A CSS selector on structure, kept as short as possible:
form.login button. - XPath, when you need text matching, parent traversal or conditions CSS cannot express.
- 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.
// A helper keeps the attribute name in one placepublic 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.
| Operator | Meaning | Example |
|---|---|---|
[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'] |
// id is "order-row-8817" where 8817 changes per orderList<WebElement> rows = driver.findElements(By.cssSelector("[id^='order-row-']"));
// Class list contains a stable token among generated onesWebElement primary = driver.findElement(By.cssSelector("button[class*='primary']"));
// XPath equivalent when you also need textWebElement 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.
// Exact text, whitespace-insensitiveBy 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 anchorsBy 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.
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 itWebElement 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.
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-pattern | Why it breaks | Fix |
|---|---|---|
| Absolute XPath from DevTools “Copy XPath” | Any structural change | Use attributes; DevTools “Copy selector” is only marginally better |
Styling classes (.flex.mt-4.text-lg) | Designers change them constantly | Test IDs or semantic attributes |
nth-child / index | Order changes with new content | Match by content or attribute |
contains(@class,'btn') | Matches too much | Whole-word ~= or exact class |
| Locators inline in tests | Duplicated across 40 tests | Page Objects: one locator, one place |
| Text locators for changeable copy | Copy edits break tests | Test IDs; reserve text for product-critical labels |
Locating by title or alt for buttons | Often missing or localised | ARIA 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.