Lesson 4 of 12 · Automation Frameworks
Selenium WebDriver Basics
How Selenium 4 drives browsers, Selenium Manager, locators, interactions, explicit waits with WebDriverWait, and the exceptions you will meet, in Java with JUnit 5.
beginner 6 min read Updated
You will learn to
- Explain the WebDriver architecture and what Selenium Manager does
- Write a JUnit 5 test that opens a browser, finds elements and interacts with them
- Synchronise reliably with explicit waits instead of sleeps
- Diagnose the common Selenium exceptions and their usual causes
Prerequisites
- Java for Testers
- Choosing an Automation Tool
Selenium is the longest-established browser automation project and still one of the most used, especially in Java shops. Its WebDriver API became a W3C standard, every major browser vendor ships a driver for it, and a huge amount of existing test code is written with it. Even if your next project uses Playwright, you will meet Selenium in interviews and in legacy suites.
This lesson covers Selenium 4 with Java 21 and JUnit 5. The concepts carry over directly to the Python, C# and JavaScript bindings.
How WebDriver works
Selenium is not a browser and does not run inside one. It is a client library that sends commands over HTTP to a driver process, which controls the real browser:
Your test (Java) --HTTP + JSON (W3C WebDriver)--> chromedriver --> Chrome
geckodriver --> Firefox
msedgedriver --> Edge
safaridriver --> Safari
Every call such as findElement or click is a separate HTTP round trip. That explains two of Selenium’s characteristics: it can drive any browser that has a driver, and it does not automatically wait for the page to be ready. Synchronisation is your job.
Selenium Grid extends the same protocol over the network: tests send commands to a Grid, which routes them to browsers on other machines. Your test code changes only in how the driver is created (RemoteWebDriver instead of ChromeDriver).
Selenium Manager
Older tutorials tell you to download chromedriver and set webdriver.chrome.driver. Since Selenium 4.6, Selenium Manager does this for you: when you create a driver, it detects the installed browser version, downloads a matching driver into a cache and uses it. It can also download a browser if none is installed. You no longer need WebDriverManager or manual driver management for local runs.
Project setup
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.27.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>
Use the newest 4.x release from Maven Central; the API shown here is stable across Selenium 4.
A first test
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
class LoginTest {
private WebDriver driver;
private WebDriverWait wait;
@BeforeEach
void setUp() {
ChromeOptions options = new ChromeOptions();
if (System.getenv("CI") != null) {
options.addArguments("--headless=new", "--window-size=1366,768");
}
driver = new ChromeDriver(options); // Selenium Manager resolves chromedriver
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
@AfterEach
void tearDown() {
if (driver != null) driver.quit();
}
@Test
void validUserSeesDashboard() {
driver.get("https://staging.example.com/login");
driver.findElement(By.id("email")).sendKeys("ana@example.com");
driver.findElement(By.id("password")).sendKeys(System.getenv("TEST_PASSWORD"));
driver.findElement(By.cssSelector("button[type='submit']")).click();
var heading = wait.until(ExpectedConditions.visibilityOfElementLocated(By.tagName("h1")));
assertEquals("Dashboard", heading.getText());
assertEquals("https://staging.example.com/dashboard", driver.getCurrentUrl());
}
}
Key points:
driver.quit()ends the session and closes every window.driver.close()closes only the current window, and leaves the driver process running if it was the last one. Alwaysquit()in teardown.findElementreturns the first match or throwsNoSuchElementException.findElementsreturns a possibly empty list and never throws, which is the right tool for asserting that something is absent.
Locators
Selenium’s By class offers eight strategies. In practice you will use a few:
| Locator | Example | When to use |
|---|---|---|
By.id |
By.id("email") |
Stable, unique ids: the first choice when available |
By.cssSelector |
By.cssSelector("[data-testid='cart-total']") |
Most other cases; fast and readable |
By.xpath |
By.xpath("//tr[td[text()='Blue Mug']]//button") |
Text-based or structural queries CSS cannot express |
By.name |
By.name("q") |
Form fields with stable name attributes |
By.linkText |
By.linkText("Forgot password?") |
Links with fixed visible text |
Prefer attributes that exist for testing or accessibility (id, name, data-testid, aria-label) over layout-dependent paths like /html/body/div[3]/div[2]/span, which break on any markup change. Test selectors in the browser console with document.querySelector(...) or $x(...), or with the XPath and CSS Selector Tester.
Selenium 4 also added relative locators, which find elements by their visual position relative to others:
import static org.openqa.selenium.support.locators.RelativeLocator.with;
WebElement passwordField = driver.findElement(
with(By.tagName("input")).below(By.id("email")));
They are handy for poorly marked-up forms but depend on layout, so use them sparingly.
Interactions
// Text and clicks
WebElement search = driver.findElement(By.name("q"));
search.clear();
search.sendKeys("mechanical keyboard", Keys.ENTER);
// Dropdowns built with <select>
new Select(driver.findElement(By.id("country"))).selectByVisibleText("India");
// Hover, drag and drop, keyboard chords
new Actions(driver).moveToElement(driver.findElement(By.id("account-menu"))).perform();
// Alerts, frames and windows
driver.switchTo().alert().accept();
driver.switchTo().frame("payment-frame");
driver.switchTo().defaultContent();
driver.switchTo().newWindow(WindowType.TAB);
// Reading state
String total = driver.findElement(By.cssSelector("[data-testid='cart-total']")).getText();
boolean enabled = driver.findElement(By.id("checkout")).isEnabled();
String typed = driver.findElement(By.id("email")).getDomProperty("value"); // current value
Screenshots for failure evidence:
File png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
Files.copy(png.toPath(), Path.of("target/screenshots/failure.png"), StandardCopyOption.REPLACE_EXISTING);
Waiting correctly
Because Selenium does not auto-wait, most flaky Selenium tests come from acting before the page is ready. There are three tools, and one to avoid:
Thread.sleep: avoid. It is either too short (flaky) or too long (slow), usually both on different machines.- Implicit wait (
driver.manage().timeouts().implicitlyWait(...)): makes everyfindElementpoll for up to N seconds until the element exists. Simple, but it only waits for presence, not visibility or clickability, and it slows down every check for absence. - Explicit wait (
WebDriverWait): waits for a specific condition on a specific element. This is the recommended default. - Fluent wait: an explicit wait with custom polling interval and ignored exceptions.
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement button = wait.until(ExpectedConditions.elementToBeClickable(By.id("checkout")));
button.click();
wait.until(ExpectedConditions.textToBe(By.cssSelector("[data-testid='cart-total']"), "$12.00"));
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.className("spinner")));
// Any condition can be a lambda that returns a truthy value
wait.until(d -> d.findElements(By.cssSelector("tbody tr")).size() == 5);
Exceptions you will meet
| Exception | Usual cause | Fix |
|---|---|---|
NoSuchElementException |
Locator wrong, or element not rendered yet | Verify the locator; wait for the element |
TimeoutException |
An explicit wait’s condition never became true | Read the condition and screenshot; it is often a real bug |
StaleElementReferenceException |
The DOM re-rendered after you found the element | Re-find the element; store By locators, not WebElement fields |
ElementClickInterceptedException |
Another element (overlay, cookie banner, spinner) covers the target | Wait for the overlay to disappear; close banners in setup |
ElementNotInteractableException |
Element present but hidden or disabled | Wait for visibility or clickability |
When a test fails, these names are your first clue. A TimeoutException waiting for a success message is frequently the application failing, not the test.
Practice exercise
Write a JUnit 5 test for this scenario on a shop’s staging site: search for “mouse”, wait for the results list, check that every result title contains “Mouse” (case-insensitive), add the first in-stock result to the cart, and assert the cart badge shows 1. Assume these elements: a search box name="q", results as [data-testid='product-card'] each with an h3 title and an “Add to cart” button (disabled when out of stock), and a cart badge [data-testid='cart-count']. Use only explicit waits. You can practise the locator and wait patterns in the Selenium Lab.
Show a model answer
@Test
void addFirstInStockMouseToCart() {
driver.get("https://staging.example.com");
WebElement search = wait.until(ExpectedConditions.elementToBeClickable(By.name("q")));
search.sendKeys("mouse", Keys.ENTER);
By cards = By.cssSelector("[data-testid='product-card']");
List<WebElement> results = wait.until(ExpectedConditions.numberOfElementsToBeMoreThan(cards, 0));
for (WebElement card : results) {
String title = card.findElement(By.tagName("h3")).getText();
assertTrue(title.toLowerCase().contains("mouse"), "Unexpected result: " + title);
}
WebElement firstInStock = results.stream()
.map(card -> card.findElement(By.xpath(".//button[normalize-space()='Add to cart']")))
.filter(WebElement::isEnabled)
.findFirst()
.orElseThrow(() -> new AssertionError("No in-stock mouse found"));
firstInStock.click();
wait.until(ExpectedConditions.textToBe(By.cssSelector("[data-testid='cart-count']"), "1"));
}Points that matter: the wait for results uses a condition rather than a sleep; the XPath starts with .// so it searches inside each card rather than the whole page; the final assertion is itself a wait, because the badge updates asynchronously after the click.
Key takeaways
- Selenium sends W3C WebDriver commands over HTTP to a browser-specific driver; Selenium Manager now finds or downloads that driver for you.
- Prefer
By.idand CSS selectors on stable, test-friendly attributes; use XPath for text and structure. - Selenium does not auto-wait. Use explicit waits (
WebDriverWaitwithDuration), neverThread.sleep, and do not mix implicit and explicit waits. - Always
quit()the driver in teardown; usefindElementsto assert absence. - Exception names (
StaleElementReference,ElementClickIntercepted,Timeout) point straight to the usual causes.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.