Finding Elements Reliably
Every Selenium test does the same two things over and over: find an element on the page, then do something with it — click it, type into it, read its text. Get the finding part wrong and nothing else matters, because a test that cannot locate the button it needs to press is dead on arrival. This module teaches you the two skills that separate rock-solid automation from the flaky, frustrating kind: writing locators that keep working when the page changes, and waiting correctly so your test and the browser stay in sync. Master these and most of the pain of UI automation simply disappears.
Learning objectives
By the end of this lesson you will be able to:
- Use all eight Selenium
Bylocator strategies and choose the right one for each situation. - Write CSS selectors and XPath expressions by hand, and explain when to prefer CSS over XPath.
- Recognise brittle selectors — absolute paths, auto-generated ids — and rewrite them to be stable.
- Explain why timing is the number-one cause of flaky tests.
- Apply implicit, explicit and fluent waits correctly, and explain why you must never mix implicit and explicit waits.
- Replace
Thread.sleepwithWebDriverWaitandExpectedConditions.
What a locator really is
A locator is a recipe Selenium uses to find an element in the live page’s structure — the DOM (Document Object Model), the tree of HTML elements the browser builds from the page. You hand Selenium a By strategy and a value, and it searches the DOM for a match. Everything in Selenium starts here:
WebElement loginButton = driver.findElement(By.id("login"));
loginButton.click();
findElement returns the first matching element, or throws NoSuchElementException if nothing matches. Its sibling findElements (note the plural) returns a List<WebElement> — possibly empty, never an exception — which is how you test “are there zero of these?” safely.
An analogy that will stick
Think of a locator like giving someone directions to find a book in a library. You could say “the third book on the second shelf of the fourth aisle” (an absolute path) — precise, but it breaks the moment a librarian reshuffles anything. Or you could say “the book with ISBN 978-0… on the spine” (a stable, unique identifier) — it keeps working no matter how the shelves are rearranged. Good locators are the second kind: they describe what the element is, not where it happens to sit today.
The eight By locator strategies
Selenium’s By class gives you eight ways to describe an element. Here they are, with the kind of HTML each targets and honest guidance on when to reach for it.
| Strategy | Java call | Matches on | Use it when… |
|---|---|---|---|
| id | By.id("username") |
The element’s id attribute |
Always first choice — ids are meant to be unique and fast. |
| name | By.name("email") |
The name attribute |
Common on form fields that lack an id. |
| className | By.className("btn-primary") |
A single CSS class | The class is meaningful and reasonably unique. One class only — no spaces. |
| tagName | By.tagName("a") |
The HTML tag | Grabbing all elements of a type, e.g. every <a> or <tr>. |
| linkText | By.linkText("Sign out") |
The exact visible text of a link | Clicking an anchor by its full label. |
| partialLinkText | By.partialLinkText("Sign") |
Part of a link’s visible text | Link text is long or partly dynamic. |
| cssSelector | By.cssSelector("input[name='q']") |
A CSS selector expression | Your workhorse for anything ids cannot cover — fast and readable. |
| xpath | By.xpath("//button[text()='Save']") |
An XPath expression | You need to match on visible text, or traverse to a parent/sibling CSS cannot reach. |
A simple priority order
When several strategies could work, prefer them roughly in this order: id → name → cssSelector → linkText/partialLinkText → xpath. The guiding principle is: pick the most stable, most unique, most readable locator available. A clean id beats a clever XPath every time.
Tutor tip: a dedicated test hook beats everything
The most stable locator is one the developers add on purpose for testing, such as data-testid="login-submit". It never changes for styling reasons and screams “a test depends on me” to anyone editing the code. If you can influence the dev team, ask for data-testid attributes on key elements — then locate with By.cssSelector("[data-testid='login-submit']"). It is the single highest-leverage thing you can do for test stability.
CSS selectors: your everyday workhorse
CSS selectors are the same syntax stylesheets use to target elements, and they are compact, fast, and readable. Learn these building blocks and you can express almost any locator:
| Goal | CSS selector | Reads as |
|---|---|---|
| By id | #login |
element with id “login” |
| By class | .btn-primary |
element with class “btn-primary” |
| By tag | input |
any <input> |
| By attribute | input[name='email'] |
input whose name is “email” |
| Attribute starts with | a[href^='/users'] |
link whose href starts with /users |
| Attribute ends with | img[src$='.png'] |
image whose src ends in .png |
| Attribute contains | div[class*='alert'] |
div whose class contains “alert” |
| Descendant | form .error |
element with class error anywhere inside a form |
| Direct child | ul > li |
li that is an immediate child of ul |
| Nth child | tr:nth-child(2) |
the second row |
Combine them freely. To find the submit button inside a login form you might write By.cssSelector("form#login button[type='submit']") — the submit button inside the form whose id is “login”. Each extra qualifier makes the match more precise without hard-coding a fragile position.
XPath: reach where CSS cannot
XPath is a query language for navigating the DOM tree. It can do everything CSS can and two extra things CSS cannot: match on an element’s visible text, and walk upwards or sideways to parents and siblings. That power comes at a cost in readability and (sometimes) speed, so use it when CSS falls short.
Relative vs absolute XPath
An absolute XPath starts at the document root with a single slash and spells out every step: /html/body/div[2]/div/form/input[1]. Avoid these. They are the library-aisle directions from our analogy — the instant any element is added or moved, the whole path shifts and your test breaks.
A relative XPath starts with a double slash //, meaning “search anywhere in the document,” and describes the element by what it is: //input[@id='email']. Always prefer relative XPath.
// Brittle — absolute path, breaks on any layout change
driver.findElement(By.xpath("/html/body/div[2]/div/form/input[1]"));
// Robust — relative, describes the element itself
driver.findElement(By.xpath("//input[@id='email']"));
Text matching and contains()
This is XPath’s signature trick. To click a button by the words on it:
// Exact text match
driver.findElement(By.xpath("//button[text()='Save changes']"));
// Partial / whitespace-tolerant match with contains()
driver.findElement(By.xpath("//button[contains(text(),'Save')]"));
// contains() also works on attributes
driver.findElement(By.xpath("//div[contains(@class,'alert-danger')]"));
Axes: moving through the tree
XPath axes let you navigate relative to an element you can already find — invaluable when the element you want has no good attributes but sits next to one that does.
// Find the input that follows a given label
driver.findElement(By.xpath("//label[text()='Email']/following-sibling::input"));
// Climb up to an ancestor row, then find a button inside it
driver.findElement(By.xpath("//td[text()='Asha']/ancestor::tr//button[text()='Delete']"));
// The parent of an element
driver.findElement(By.xpath("//span[@class='price']/parent::div"));
Common axes: parent, child, ancestor, descendant, following-sibling, preceding-sibling. The “find the delete button in the same row as the user named Asha” pattern above is one you will use constantly on tables.
Deep dive: CSS or XPath — how to choose
Reach for CSS by default. It is generally faster (browsers optimise CSS matching natively), more concise, and easier for teammates to read. Reach for XPath only when you need something CSS genuinely cannot express: matching an element by its visible text, or traversing upward/sideways to an ancestor or sibling. CSS can only ever go down the tree (parents to children); it has no “select the parent of” operator and no text() function. So the rule of thumb is: “CSS unless I need text or a parent, then XPath.” Whatever you choose, make the locator describe the element’s identity, not its position.
Brittle locators that will haunt you
- Absolute XPath (
/html/body/div[3]/…) — copied straight from browser “Copy full XPath.” Breaks on the smallest markup change. Never ship it. - Auto-generated ids like
id="ember459",id="input-9f3a2b", or framework-generated hashes. They change on every build or page load. Match on a stable attribute instead, or ask for adata-testid. - Index-only selectors like
tr:nth-child(5)as your only qualifier — the fifth row today is the sixth tomorrow. Anchor on data (a name, an id) where you can. - Styling-driven classes like
.mt-4or.col-md-6— these exist for layout and designers delete them freely. - Over-long chains that encode the entire ancestry. The more nodes a locator mentions, the more ways it can break. Use the shortest selector that is still unique.
The real enemy: timing
Here is the single most important sentence in this module: the number-one cause of flaky UI tests is timing. Modern web pages are asynchronous. When you call driver.get(url), the HTML arrives, but then JavaScript runs, data is fetched over the network, spinners appear, and elements are inserted into the DOM milliseconds or seconds later. Your test code, meanwhile, races ahead at machine speed. If it tries to click a button that has not been rendered yet, you get a NoSuchElementException — or worse, an intermittent one that passes on a fast machine and fails on a slow CI server.
The cure is waiting: telling Selenium to pause until the page is actually ready. There are three kinds of wait, and knowing which to use — and which never to mix — is what makes tests reliable.
1. Implicit wait — a global grace period
An implicit wait tells the driver: “whenever you cannot find an element, keep retrying for up to N seconds before giving up.” You set it once and it applies to every findElement call for the life of the driver.
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
It is simple, but blunt: it only waits for an element to exist in the DOM, not for it to be visible, clickable, or populated. And because it is global, you cannot tune it per situation. Treat it as a mild safety net at best — many teams set it to zero and rely entirely on explicit waits.
2. Explicit wait — wait for a specific condition
An explicit wait is the professional default. You create a WebDriverWait and tell it exactly what condition to wait for, using the ExpectedConditions helper. It polls the page repeatedly (every 500 ms by default) until the condition is true or the timeout expires.
import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.support.ui.ExpectedConditions;
import java.time.Duration;
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Wait until the element is visible, then interact
WebElement message = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.id("welcome")));
System.out.println(message.getText());
// Wait until a button is clickable before clicking it
WebElement save = wait.until(
ExpectedConditions.elementToBeClickable(By.cssSelector("button[type='submit']")));
save.click();
The beauty of explicit waits is precision: you wait for the exact state you need, only where you need it. Here are the conditions you will use most:
| ExpectedCondition | Waits until… |
|---|---|
visibilityOfElementLocated(by) |
the element is present and displayed (non-zero size) |
presenceOfElementLocated(by) |
the element exists in the DOM (may be hidden) |
elementToBeClickable(by) |
the element is visible and enabled |
textToBePresentInElementLocated(by, text) |
the element contains the given text |
invisibilityOfElementLocated(by) |
the element is gone or hidden (great for spinners) |
titleContains(text) |
the page title contains the text |
numberOfElementsToBe(by, n) |
exactly n matching elements exist |
3. Fluent wait — full control over polling and exceptions
A FluentWait is the configurable cousin of WebDriverWait (in fact WebDriverWait is a FluentWait under the hood). It lets you set the total timeout, how often to poll, and which exceptions to swallow while retrying. Use it when the default polling is too aggressive, or when an element throws transient exceptions while the page settles.
import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.Wait;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.StaleElementReferenceException;
import java.time.Duration;
import java.util.function.Function;
Wait<WebDriver> fluentWait = new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(15)) // give up after 15s
.pollingEvery(Duration.ofMillis(250)) // check every 250ms
.ignoring(NoSuchElementException.class) // retry while not found
.ignoring(StaleElementReferenceException.class);
WebElement result = fluentWait.until(new Function<WebDriver, WebElement>() {
public WebElement apply(WebDriver d) {
WebElement el = d.findElement(By.id("result"));
return el.isDisplayed() ? el : null; // return null to keep polling
}
});
The ignoring clause is the key feature: without it, the first NoSuchElementException would abort the wait; with it, the exception is treated as “not ready yet, poll again.”
Never mix implicit and explicit waits
This is the classic trap that produces baffling, unpredictable timeouts. If you set an implicit wait and use explicit waits, their timeouts compound unpredictably. When an explicit wait polls for a condition, each internal findElement call is itself subject to the implicit wait, so a 10-second explicit wait polling every 500 ms can balloon into minutes — or produce inconsistent waits that differ run to run. The official Selenium guidance is blunt: do not mix them. Pick one strategy. The professional choice is to set implicit wait to zero (or never set it) and use explicit waits everywhere you need to synchronise.
Thread.sleep is an anti-pattern
It is tempting to “fix” a timing failure with Thread.sleep(3000) — just pause three seconds. Do not. It is wrong in both directions. If the element is ready in 200 ms, you have wasted 2.8 seconds on every run — multiply that across hundreds of tests and your suite crawls. If the element takes 3.1 seconds on a slow day, your test fails anyway. A hard sleep guesses at a fixed duration; an explicit wait reacts to reality, continuing the instant the condition is met and only waiting longer when it genuinely needs to. Reserve Thread.sleep for throwaway debugging, never for committed tests.
// Anti-pattern: blind, slow, still flaky
Thread.sleep(3000);
driver.findElement(By.id("welcome")).getText();
// Correct: fast when ready, patient when not
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.visibilityOfElementLocated(By.id("welcome")))
.getText();
Why this matters on a real team
A flaky test — one that passes and fails without any code change — is worse than no test at all, because people stop trusting the whole suite and start ignoring red builds. In practice, the overwhelming majority of UI flakiness traces back to two root causes from this module: a locator that occasionally matches the wrong thing, and a missing or wrong wait. Teams that take locators and waits seriously routinely run thousands of UI tests with near-zero flakiness; teams that sprinkle Thread.sleep and copy absolute XPaths spend their lives re-running failed jobs. The skills in this lesson are the difference between the two.
Hands-on exercise
Open a practice site such as the-internet.herokuapp.com (the “Dynamic Loading” and “Add/Remove Elements” pages are ideal) and, in a Java + Selenium project:
- Locate the same button three different ways — by
id, bycssSelector, and by relativexpath. Confirm all three click it. - Write a CSS selector that targets an input by its
nameattribute, and an XPath that targets a link by its exact visible text. - On the Dynamic Loading page, click “Start,” then use a
WebDriverWaitwithvisibilityOfElementLocatedto wait for the hidden “Hello World!” text to appear, and assert its value. - Deliberately replace that wait with
Thread.sleep(500)and observe it fail intermittently; then restore the explicit wait. - Write a relative XPath that finds a “Delete” button in the same table row as a given name, using the
ancestoraxis.
Knowledge check
- Name all eight
Bylocator strategies. - Why should you avoid absolute XPath and auto-generated ids?
- Give one thing XPath can do that CSS selectors cannot.
- What is the single most common cause of flaky UI tests?
- Why must you never combine an implicit wait with explicit waits?
- Why is
Thread.sleepinferior to an explicit wait? - Which
ExpectedConditionwould you use before clicking a button that may still be disabled?
Show answers
- id, name, className, tagName, linkText, partialLinkText, cssSelector, xpath.
- Both encode position or build-time noise rather than the element’s identity, so they break on the smallest markup change or on the next build.
- Match an element by its visible text (
text()/contains(text(),…)), or traverse upward/sideways to a parent or sibling via axes — CSS can only go down the tree. - Timing — the test interacts with an element before the asynchronous page has rendered or populated it.
- Their timeouts compound unpredictably: each internal lookup inside an explicit wait is also subject to the implicit wait, producing inconsistent, inflated waits. Selenium explicitly warns against mixing them.
Thread.sleepwaits a fixed time regardless of reality — wasting time when the element is ready early and still failing when it is ready late. An explicit wait continues the instant the condition is met.ExpectedConditions.elementToBeClickable(by), which waits until the element is both visible and enabled.
Key takeaways
- Selenium finds elements with eight
Bystrategies; prefer id → name → cssSelector → linkText → xpath, and ask fordata-testidhooks where you can. - Good locators describe what an element is, not where it sits; avoid absolute XPath, auto-generated ids, and index-only selectors.
- Use CSS by default; use XPath only for text matching or parent/sibling traversal.
- Timing is the number-one source of flakiness — synchronise your test with the page using waits.
- Prefer explicit waits (
WebDriverWait+ExpectedConditions); useFluentWaitfor custom polling and ignored exceptions. - Never mix implicit and explicit waits, and never use
Thread.sleepin committed tests.
Go deeper
Two advanced problems bite almost everyone once their suites grow past the basics. The first is the StaleElementReferenceException: you find a WebElement, the page re-renders that part of the DOM (an AJAX refresh, a framework re-paint), and your stored reference now points at an element that no longer exists. The cure is never to hold a WebElement across an action that mutates the page — re-find it just before use, or let a wait that re-locates inside its loop retry for you:
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Re-locate inside the wait so a stale reference is simply retried
String price = wait.until(d -> {
WebElement el = d.findElement(By.cssSelector(".price"));
return el.getText(); // throws if stale; the wait polls again
});
The second is waiting on a condition the built-in ExpectedConditions does not cover. Because until accepts any function returning a truthy value, you can write your own as a lambda — here, waiting until a cart badge shows an exact count:
wait.until(d ->
d.findElement(By.id("cart-count")).getText().equals("3"));
- Selenium 4 relative locators let you find an element by its visual position relative to another, e.g.
RelativeLocator.with(By.tagName("input")).above(By.id("password")). Handy when a field has no stable attributes, but more fragile than a good id — reach for them last. - Elements inside a Shadow DOM cannot be reached by an ordinary XPath that spans the boundary; locate the host element, call
getShadowRoot(), then search from there with a CSS selector. - Prefer waiting on the state you will actually use over
presenceOfElementLocated. “Present in the DOM” often happens seconds before the element is populated, visible, or clickable — wait for visibility or clickability instead so you do not act on a half-rendered element.