Introduction
Selenium WebDriver controls a real browser through the W3C WebDriver standard, from almost any programming language. Many teams choose it because it fits an existing Java or C# codebase, runs on every major browser, and works with every cloud grid.
This tutorial builds a small, complete project and runs it. You'll test the login page of The Internet, a public practice site, with:
- a page object that hides locators from the tests,
- explicit waits instead of sleeps,
- JUnit 6 tests you run with
mvn test.
Every file below was compiled and run on Java 21 with Maven 3.9.9. The outputs are copied from those runs.
What you need
- A JDK, version 17 or newer. Check with
java -version. - Maven. Check with
mvn -v. - Google Chrome.
- An IDE such as IntelliJ IDEA or VS Code with Java support.
You don't need to download ChromeDriver. Since Selenium 4.6, Selenium Manager finds your browser and downloads the matching driver automatically the first time a test runs. Older tutorials that add WebDriverManager or set webdriver.chrome.driver are solving a problem that no longer exists.
Step 1: the Maven project
Create this layout:
selenium-demo/
├── pom.xml
└── src/test/java/
├── LoginTest.java
└── pages/LoginPage.javaIn pom.xml, add two dependencies:
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.49.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>Also set <maven.compiler.release>17</maven.compiler.release> in <properties>, and pin a recent Surefire plugin (we used maven-surefire-plugin 3.5.4) under <build><plugins>, so Maven runs JUnit tests with a known version instead of whatever your Maven installation defaults to.
Step 2: a page object
A page object is a class that knows how a page is built, so tests don't have to. When a locator changes, you fix it in one place. Create src/test/java/pages/LoginPage.java:
package pages;
import java.time.Duration;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public class LoginPage {
private static final String URL = "https://the-internet.herokuapp.com/login";
private final WebDriver driver;
private final WebDriverWait wait;
private final By username = By.id("username");
private final By password = By.id("password");
private final By submit = By.cssSelector("button[type='submit']");
private final By flash = By.id("flash");
public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
public LoginPage open() {
driver.get(URL);
return this;
}
public LoginPage logInAs(String user, String pass) {
driver.findElement(username).sendKeys(user);
driver.findElement(password).sendKeys(pass);
driver.findElement(submit).click();
return this;
}
public String flashMessage() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(flash)).getText();
}
public String heading() {
return driver.findElement(By.tagName("h2")).getText();
}
}A few choices worth copying:
- Locators are fields, not strings scattered through methods.
By.idis the most stable choice when the page has IDs; the submit button has none, so a short CSS selector on its type attribute is used instead. - Methods describe user actions (
logInAs), not clicks and keystrokes. - Methods return
this, so a test can chainopen().logInAs(...). - The page object doesn't assert. It returns values, and the test decides what's correct.
Step 3: explicit waits, not sleeps
After a login, the page reloads and shows a message in an element with the ID flash. If the test reads it too early, it fails with NoSuchElementException.
The fix is in flashMessage(): WebDriverWait checks the condition repeatedly, every 500 milliseconds by default, and continues as soon as the element is visible, up to 10 seconds. A fast page costs a fraction of a second; a slow page still passes.
Avoid the two alternatives beginners reach for:
Thread.sleep(3000)waits three seconds on every run, and still fails when the page takes four.- Implicit waits (
driver.manage().timeouts().implicitlyWait(...)) apply to everyfindElementcall and don't mix well with explicit waits. Selenium's documentation warns that combining the two can cause unpredictable wait times.
Step 4: the tests
Create src/test/java/LoginTest.java:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import pages.LoginPage;
class LoginTest {
private static final String PASSWORD =
System.getenv().getOrDefault("INTERNET_PASSWORD", "SuperSecretPassword!");
private WebDriver driver;
@BeforeEach
void startBrowser() {
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new");
driver = new ChromeDriver(options);
}
@AfterEach
void stopBrowser() {
driver.quit();
}
@Test
void validUserReachesTheSecureArea() {
LoginPage login = new LoginPage(driver).open().logInAs("tomsmith", PASSWORD);
assertTrue(login.flashMessage().contains("You logged into a secure area!"));
assertEquals("Secure Area", login.heading());
}
@Test
void wrongPasswordShowsAnError() {
LoginPage login = new LoginPage(driver).open().logInAs("tomsmith", "wrong-password");
assertTrue(login.flashMessage().contains("Your password is invalid!"));
}
}What each piece does:
@BeforeEachstarts a new browser for every test, and@AfterEachalways callsquit(), even when a test fails. Tests never share cookies or a logged-in session, so they can run in any order.--headless=newruns Chrome without a window. Remove it to watch the test.quit(), notclose().close()closes one window;quit()ends the browser and the driver process. Forgetting it leaves Chrome processes running on CI agents.- The password comes from an environment variable with the practice site's public password as a fallback. It's a good habit even when the password isn't secret.
- The flash message is checked with
contains, because the element also includes a "×" close button.
Step 5: run it
mvn testThe first run downloads dependencies and the browser driver, so it's slower. Our run:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 22.96 s -- in LoginTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS"Tests run: 3" in the total includes the example in the next section. Most of the 23 seconds is starting Chrome twice; each test's own steps take a few seconds against the public site.
When you need XPath
Prefer IDs, then short CSS selectors on stable attributes. XPath earns its place when you need to find an element by its text or by what's inside it. This test adds a specific product to the cart on the Sauce Demo practice shop, where every product card has the same "Add to cart" button:
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.WebElement;
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 CartTest {
private WebDriver driver;
@BeforeEach
void startBrowser() {
driver = new ChromeDriver(new ChromeOptions().addArguments("--headless=new"));
}
@AfterEach
void stopBrowser() {
driver.quit();
}
@Test
void addsTheBackpackToTheCart() {
driver.get("https://www.saucedemo.com/");
driver.findElement(By.cssSelector("[data-test='username']")).sendKeys("standard_user");
driver.findElement(By.cssSelector("[data-test='password']"))
.sendKeys(System.getenv().getOrDefault("SAUCE_PASSWORD", "secret_sauce"));
driver.findElement(By.cssSelector("[data-test='login-button']")).click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement backpack = wait.until(ExpectedConditions.visibilityOfElementLocated(
By.xpath("//div[@data-test='inventory-item'][.//div[text()='Sauce Labs Backpack']]")));
backpack.findElement(By.tagName("button")).click();
assertEquals("1", driver.findElement(By.cssSelector("[data-test='shopping-cart-badge']")).getText());
}
}The XPath reads: "the product card that contains a div whose text is Sauce Labs Backpack". The button is then found inside that card, so the test clicks the right product even if the list order changes. It passed in 13 seconds, including browser startup. The same scenario in Playwright and Cypress is in our framework comparison.
Troubleshooting: errors we actually hit
NoSuchElementException right after a click. The next page hasn't loaded yet. Wait for an element on the new page with WebDriverWait, as flashMessage() does. Don't add a sleep.
StaleElementReferenceException. You kept a WebElement from before the page changed. Find the element again after the change. Keeping By locators in the page object, instead of WebElement fields, avoids this by design.
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version …. A manually installed driver no longer matches the browser. Remove the old driver from your PATH and let Selenium Manager handle it.
java.io.IOException: Unable to establish loopback connection on Windows. Every test failed with this error on our Windows 11 machine, before any browser opened. It comes from the JDK's HTTP client, which Selenium uses to talk to the driver, when the JDK can't create its internal socket in the temp directory. A plain Java program using java.net.http.HttpClient failed the same way, which ruled out Selenium. Pointing the JDK at a short temporary directory fixed it:
mvn test "-DargLine=-Djdk.net.unixdomain.tmpdir=C:/t"The directory (C:\t here) must exist. If you see this error, try this setting before changing anything in your tests.
Running on more browsers
To run the same tests on Firefox or Edge, replace ChromeDriver with FirefoxDriver or EdgeDriver; Selenium Manager fetches those drivers too. To run on many browser and OS combinations, or in parallel on CI, use a Selenium Grid or a cloud provider such as BrowserStack or Sauce Labs. You create a RemoteWebDriver with the grid's URL and the desired browser options instead of a local driver, and the tests themselves don't change. Check your provider's documentation for the exact capabilities.
Conclusion
A maintainable Selenium suite rests on four habits: page objects that own the locators, explicit waits on the condition you need, a fresh browser per test that is always quit, and no hard-coded secrets. With Selenium Manager handling drivers, the project above is all the setup you need to start.