Back to All Articles
Automation

Selenium WebDriver with Java — Complete Guide

Honnesh Muppala May 5, 2026 17 min read

Why Java for Selenium?

Java has been the dominant language for enterprise Selenium automation frameworks for over a decade, and in 2026 it remains the most commonly required language in QA engineering job descriptions at large organisations. Understanding why requires looking at what enterprise test automation actually demands beyond just "make a browser do things".

The first advantage of Java for Selenium is strong static typing. Java catches errors at compile time that would only surface at runtime in Python or JavaScript. A misspelled method name, a wrong parameter type, a null reference — the IntelliJ IDE flags these issues the moment you write the code, long before you run a single test. This compile-time safety is enormously valuable in large teams where multiple engineers contribute to a shared codebase. When you have 100,000 lines of test code across dozens of page objects and utility classes, catching type errors at compile time rather than in production CI runs is not a luxury — it is a practical necessity.

The Java ecosystem for test automation is also exceptionally rich. TestNG provides annotations, listeners, parallel execution, and data providers out of the box. Maven and Gradle give you dependency management, build lifecycle management, and seamless CI integration. ExtentReports and Allure produce beautiful HTML test reports with screenshots, logs, and trend charts. WebDriverManager (by Boni Garcia) handles browser driver management as elegantly as Python's webdriver-manager. Apache POI handles Excel test data. Rest-Assured handles API testing in the same test project. These tools have been battle-tested in enterprise environments for years.

At Viasat, where I worked on quality engineering for inflight connectivity and entertainment systems, the regression framework was Java-based. Tests had to run across multiple browser versions, on real hardware platforms, and in environments with strict corporate governance requirements around build tooling. Maven's reproducible build model and TestNG's enterprise-grade reporting made it the natural choice. The framework I maintained there ran over 300 end-to-end scenarios on Jenkins, integrating with JIRA for automatic defect linking when tests failed.

Java also has excellent IntelliJ IDEA support, widely considered the best IDE for any language. Code completion for WebDriver methods, refactoring support (rename a method and every usage updates), integrated debugger with breakpoints you can set inside a running test — these tools dramatically accelerate development and debugging compared to a typical Python IDE setup.

Maven Project Setup

Maven manages dependencies, builds, and the test execution lifecycle through a single configuration file — pom.xml. The Maven approach is that you declare what you need (dependencies and their versions) and Maven downloads them from Maven Central, stores them in a local cache, and makes them available on the classpath. This ensures that every developer and every CI build uses exactly the same library versions.

Here is a complete, production-ready pom.xml for a Selenium 4 + TestNG + ExtentReports framework:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.qachronicles</groupId>
    <artifactId>selenium-framework</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <selenium.version>4.20.0</selenium.version>
        <testng.version>7.10.2</testng.version>
        <webdrivermanager.version>5.8.0</webdrivermanager.version>
    </properties>

    <dependencies>
        <!-- Selenium WebDriver -->
        <dependency>
            <groupId>org.seleniumhq.selenium</groupId>
            <artifactId>selenium-java</artifactId>
            <version>${selenium.version}</version>
        </dependency>

        <!-- WebDriverManager: auto-downloads correct browser drivers -->
        <dependency>
            <groupId>io.github.bonigarcia</groupId>
            <artifactId>webdrivermanager</artifactId>
            <version>${webdrivermanager.version}</version>
        </dependency>

        <!-- TestNG: test runner with parallel execution and listeners -->
        <dependency>
            <groupId>org.testng</groupId>
            <artifactId>testng</artifactId>
            <version>${testng.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- ExtentReports: rich HTML test reports -->
        <dependency>
            <groupId>com.aventstack</groupId>
            <artifactId>extentreports</artifactId>
            <version>5.1.1</version>
        </dependency>

        <!-- Apache POI: read Excel test data files -->
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>5.2.5</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- Surefire plugin runs TestNG test suites -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.2.5</version>
                <configuration>
                    <suiteXmlFiles>
                        <suiteXmlFile>testng.xml</suiteXmlFile>
                    </suiteXmlFiles>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

The properties section centralises version numbers so you change them in one place to upgrade all usages. The scope: test on TestNG means it is only on the classpath during testing, not bundled into any production artifact. After creating this file, run mvn clean install -DskipTests to download all dependencies and verify the project compiles before writing a single test.

Your project directory structure should mirror standard Maven conventions:

selenium-framework/
├── pom.xml
├── testng.xml
├── src/
│   ├── main/java/
│   │   ├── pages/          # Page Object classes
│   │   │   ├── LoginPage.java
│   │   │   └── CheckoutPage.java
│   │   └── utils/          # Utility classes
│   │       ├── BaseTest.java
│   │       └── TestListener.java
│   └── test/java/
│       └── tests/          # Test classes
│           ├── LoginTest.java
│           └── CheckoutTest.java
└── test-data/
    └── login_data.xlsx

TestNG Configuration

TestNG is configured via an XML file that defines suites, tests, and classes to run. The real power of this configuration is parallel execution control and listener registration — you can run all methods in parallel across 4 threads and automatically attach your custom reporting listener to every test run.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="Regression Suite" parallel="methods" thread-count="4">

    <!-- Register your TestListener for screenshots and reporting -->
    <listeners>
        <listener class-name="utils.TestListener"/>
    </listeners>

    <test name="Login Tests">
        <classes>
            <class name="tests.LoginTest"/>
        </classes>
    </test>

    <test name="Checkout Tests">
        <classes>
            <class name="tests.CheckoutTest"/>
        </classes>
    </test>

</suite>

The parallel="methods" attribute tells TestNG to run individual test methods in parallel, each in its own thread. With thread-count="4", up to 4 tests run simultaneously. Combined with a BaseTest that manages a ThreadLocal WebDriver, each thread gets its own isolated browser session — no thread-safety issues, no shared state between parallel tests.

Other parallel options available: parallel="classes" runs each class in its own thread (all methods in a class run sequentially within that thread), and parallel="tests" runs each <test> block in its own thread. The right choice depends on whether your tests within a class share state intentionally — if they do, use parallel="classes"; if every method is fully independent, use parallel="methods" for maximum throughput.

Your First Test

Let us write a complete, executable Java test class that sets up a ChromeDriver, performs a login flow, and verifies the outcome with TestNG assertions. This test is self-contained — it handles its own setup and teardown — before we refactor to a BaseTest class later.

package tests;

import io.github.bonigarcia.wdm.WebDriverManager;
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;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import java.time.Duration;

public class LoginTest {

    private WebDriver driver;
    private WebDriverWait wait;

    @BeforeMethod
    public void setUp() {
        // WebDriverManager detects Chrome version and downloads matching ChromeDriver
        WebDriverManager.chromedriver().setup();

        ChromeOptions options = new ChromeOptions();
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        options.addArguments("--window-size=1920,1080");
        // Uncomment for headless mode in CI:
        // options.addArguments("--headless=new");

        driver = new ChromeDriver(options);
        driver.manage().window().maximize();

        // Explicitly set implicit wait to zero — we will use explicit waits only
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(0));
        driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));

        wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }

    @Test(description = "Valid credentials should redirect to the dashboard")
    public void testSuccessfulLogin() {
        driver.get("https://app.example.com/login");

        // Wait for the page to be ready before interacting
        wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("email")))
            .sendKeys("user@example.com");

        driver.findElement(By.id("password")).sendKeys("Password123");
        driver.findElement(By.id("loginBtn")).click();

        // Verify that login succeeded by checking the URL
        wait.until(ExpectedConditions.urlContains("/dashboard"));
        Assert.assertTrue(driver.getCurrentUrl().contains("/dashboard"),
            "Expected to land on dashboard after successful login. " +
            "Actual URL: " + driver.getCurrentUrl());
    }

    @Test(description = "Wrong password should show an error message")
    public void testInvalidPasswordShowsError() {
        driver.get("https://app.example.com/login");

        wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("email")))
            .sendKeys("user@example.com");

        driver.findElement(By.id("password")).sendKeys("wrongpassword");
        driver.findElement(By.id("loginBtn")).click();

        // Verify error message is displayed
        String errorText = wait.until(
            ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".error-message"))
        ).getText();

        Assert.assertTrue(errorText.contains("Invalid"),
            "Expected 'Invalid' in error message. Actual: " + errorText);
    }

    @AfterMethod
    public void tearDown() {
        // Always quit the driver to close browser and end ChromeDriver process
        if (driver != null) {
            driver.quit();
        }
    }
}

Several things to note about this Java test. The @BeforeMethod annotation runs the setup before each test method — equivalent to pytest's scope="function" fixture. @AfterMethod runs teardown after each method, and the null check on driver prevents a NullPointerException if the browser failed to launch during setup. The Duration.ofSeconds() syntax (introduced in Java 8) replaces the older integer-based timeout methods and reads more clearly.

TestNG's Assert class provides soft and hard assertions. Assert.assertTrue(condition, message) is a hard assertion — it stops the test immediately on failure. For scenarios where you want to verify multiple things and report all failures in one test run, SoftAssert collects assertion failures and reports them all at the end when you call softAssert.assertAll().

Locator Strategies in Java

Java Selenium uses exactly the same eight locator strategies as Python Selenium, since both implement the same W3C WebDriver specification. The syntax differs only in language idiom. Understanding which strategy to use in which situation is language-agnostic — the decision logic is identical whether you are writing Java or Python.

import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;

// By ID — most reliable; IDs are unique by HTML spec
WebElement submitBtn = driver.findElement(By.id("submit-btn"));

// By Name — good for form fields
WebElement usernameField = driver.findElement(By.name("username"));

// By CSS Selector — preferred for complex queries; fast and readable
WebElement emailInput = driver.findElement(By.cssSelector("input[type='email']"));
WebElement loginBtn = driver.findElement(By.cssSelector(".login-form .btn-primary"));
WebElement testIdEl = driver.findElement(By.cssSelector("[data-testid='checkout-btn']"));

// By XPath — use only when CSS cannot express the condition
WebElement loginLink = driver.findElement(By.xpath("//a[text()='Log In']"));
WebElement errorMsg = driver.findElement(
    By.xpath("//div[contains(@class,'error') and contains(text(),'Invalid')]"));

// By Link Text — for anchor elements with exact visible text
WebElement homeLink = driver.findElement(By.linkText("Home"));

// By Partial Link Text — for anchors where text might vary
WebElement link = driver.findElement(By.partialLinkText("Learn more"));

// By Class Name — single CSS class only; use CSS selector for multiple classes
WebElement header = driver.findElement(By.className("page-header"));

// By Tag Name — when element type is unique enough (e.g., only one h1)
WebElement pageTitle = driver.findElement(By.tagName("h1"));

Java's strong typing means you always get a WebElement object back from findElement(), and the IDE provides full autocomplete for all WebElement methods (click(), sendKeys(), getText(), getAttribute(), isDisplayed(), etc.). If you need a list of elements, use findElements() (plural) which returns a List<WebElement> — an empty list if nothing matches, never null.

CSS selectors deserve particular attention in Java. Chained CSS selectors let you express complex hierarchical relationships without resorting to fragile positional XPath:

// Descendant (space): any div inside .container
driver.findElement(By.cssSelector(".container div"));

// Child (>): direct child only
driver.findElement(By.cssSelector(".nav > li"));

// Attribute equals
driver.findElement(By.cssSelector("input[name='email']"));

// Attribute contains
driver.findElement(By.cssSelector("a[href*='login']"));

// Attribute starts with
driver.findElement(By.cssSelector("input[id^='user']"));

// Nth child
driver.findElement(By.cssSelector("table tr:nth-child(2) td:first-child"));

WebDriverWait in Java

Java's WebDriverWait combined with the ExpectedConditions class provides a comprehensive set of conditions for waiting on common UI states. Using Duration.ofSeconds() for timeout specification is the modern Java approach, replacing the older constructor that accepted integer seconds — the Duration API makes the unit explicit and eliminates ambiguity.

import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));

// Wait for element to be visible AND clickable — safest for buttons
WebElement submitBtn = wait.until(
    ExpectedConditions.elementToBeClickable(By.id("submit"))
);
submitBtn.click();

// Wait for specific text inside an element
wait.until(ExpectedConditions.textToBePresentInElementLocated(
    By.id("order-status"), "Order Confirmed"
));

// Wait for URL to match a pattern
wait.until(ExpectedConditions.urlContains("/confirmation"));

// Wait for URL to match exactly
wait.until(ExpectedConditions.urlToBe("https://app.example.com/success"));

// Wait for an alert dialog to appear, then dismiss it
wait.until(ExpectedConditions.alertIsPresent());
driver.switchTo().alert().accept();

// Wait for element to become invisible (loading spinner gone)
wait.until(ExpectedConditions.invisibilityOfElementLocated(
    By.className("loading-spinner")
));

// Wait for element count
wait.until(ExpectedConditions.numberOfElementsToBe(
    By.cssSelector(".search-result"), 10
));

// Wait for element to become stale after a DOM refresh
WebElement element = driver.findElement(By.id("dynamic-section"));
// Trigger something that causes the DOM to refresh...
wait.until(ExpectedConditions.stalenessOf(element));
// Now find the element again in the refreshed DOM
WebElement freshElement = driver.findElement(By.id("dynamic-section"));

The stalenessOf condition is particularly useful on pages that use JavaScript frameworks to re-render components. After clicking a filter or sorting option, the table rows are replaced in the DOM — old references to WebElement objects become "stale" (they no longer point to anything in the current DOM). Waiting for staleness explicitly, then re-finding the element, is the correct pattern for these scenarios. Trying to interact with a stale element throws StaleElementReferenceException.

From the Field — Viasat: At Viasat, our inflight entertainment portal had a complex content catalogue that used lazy-loading and DOM virtualisation. After any search or filter operation, the entire content grid was re-rendered by the JavaScript framework, invalidating all element references. We built a custom WaitHelper utility that combined stalenessOf with a re-find, wrapping it in a retry loop. This became one of the most reused utilities across the entire 300-test suite.

Page Object Model in Java

Java's implementation of the Page Object Model benefits from a built-in mechanism not available in other languages: the @FindBy annotation and PageFactory. These features, part of the Selenium Java library itself, provide a declarative way to define locators as annotated fields, and PageFactory initialises them lazily — the element is not looked up in the DOM until you actually access the field. This gives you the clean locator declaration syntax of POM with automatic element resolution.

Maven Build
mvn test
TestNG Runner
testng.xml
WebDriver API
Page Objects
ChromeDriver
Browser bridge
Chrome Browser
Headless / visible
package pages;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;

public class LoginPage {

    private final WebDriver driver;
    private final WebDriverWait wait;

    // @FindBy annotations declare locators — PageFactory resolves them at access time
    @FindBy(id = "email")
    private WebElement emailField;

    @FindBy(id = "password")
    private WebElement passwordField;

    @FindBy(id = "loginBtn")
    private WebElement loginButton;

    @FindBy(css = ".error-message")
    private WebElement errorMessageEl;

    @FindBy(css = "h1.welcome")
    private WebElement welcomeHeading;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        // PageFactory wires the @FindBy annotations to actual element lookups
        PageFactory.initElements(driver, this);
    }

    public void open() {
        driver.get("https://app.example.com/login");
        wait.until(ExpectedConditions.visibilityOf(emailField));
    }

    public void login(String email, String password) {
        emailField.clear();
        emailField.sendKeys(email);
        passwordField.clear();
        passwordField.sendKeys(password);
        loginButton.click();
    }

    public String getErrorMessage() {
        return wait.until(ExpectedConditions.visibilityOf(errorMessageEl)).getText();
    }

    public boolean isOnDashboard() {
        try {
            wait.until(ExpectedConditions.urlContains("/dashboard"));
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public String getWelcomeMessage() {
        return wait.until(ExpectedConditions.visibilityOf(welcomeHeading)).getText();
    }
}

The @FindBy annotation accepts the same locator types you would pass to By: id, name, css, xpath, linkText, partialLinkText, className, tagName. PageFactory.initElements() creates dynamic proxies for each annotated field — when you access emailField, it calls driver.findElement(By.id("email")) at that moment. This lazy evaluation means the elements are always fresh, which avoids the most common cause of StaleElementReferenceException.

The second POM pattern diagram shows the test-to-page-to-driver relationship:

Test Class
LoginTest.java
Page Class
LoginPage.java
WebDriver
ChromeDriver instance

TestNG Data Providers

TestNG's @DataProvider annotation serves the same purpose as pytest's parametrize but with Java's type system. A DataProvider method returns a two-dimensional Object[][] where each inner array is one set of test parameters. The test method receives these parameters as method arguments, and TestNG creates one test execution for each row.

package tests;

import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import pages.LoginPage;

public class LoginDataDrivenTest extends BaseTest {

    @DataProvider(name = "loginCredentials")
    public Object[][] loginData() {
        return new Object[][] {
            // { email, password, shouldSucceed }
            {"admin@example.com",  "Admin@123",   true},
            {"user@example.com",   "User@123",    true},
            {"invalid@test.com",   "wrongpass",   false},
            {"",                   "Password123", false},
            {"user@example.com",   "",            false},
        };
    }

    @Test(dataProvider = "loginCredentials",
          description = "Validate login with various credential combinations")
    public void testLogin(String email, String password, boolean shouldSucceed) {
        LoginPage loginPage = new LoginPage(getDriver());
        loginPage.open();
        loginPage.login(email, password);

        if (shouldSucceed) {
            Assert.assertTrue(loginPage.isOnDashboard(),
                "Expected dashboard for: " + email);
        } else {
            Assert.assertFalse(loginPage.getErrorMessage().isEmpty(),
                "Expected error message for invalid credentials: " + email);
        }
    }
}

For larger datasets stored in external files, Apache POI makes it straightforward to read from Excel. This is particularly valuable in enterprise environments where business analysts maintain test data in spreadsheets:

import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.FileInputStream;

@DataProvider(name = "loginFromExcel")
public Object[][] loginDataFromExcel() throws Exception {
    FileInputStream fis = new FileInputStream("test-data/login_data.xlsx");
    XSSFWorkbook workbook = new XSSFWorkbook(fis);
    XSSFSheet sheet = workbook.getSheet("LoginData");

    int rowCount = sheet.getLastRowNum();
    Object[][] data = new Object[rowCount][3]; // 3 columns per row

    for (int i = 1; i <= rowCount; i++) { // Start at 1 to skip header row
        data[i - 1][0] = sheet.getRow(i).getCell(0).getStringCellValue(); // email
        data[i - 1][1] = sheet.getRow(i).getCell(1).getStringCellValue(); // password
        data[i - 1][2] = sheet.getRow(i).getCell(2).getBooleanCellValue(); // shouldSucceed
    }

    workbook.close();
    return data;
}

Screenshot on Test Failure

Automatic screenshots on test failure transform debugging from guesswork into a precise diagnostic exercise. In Java with TestNG, you implement the ITestListener interface and register the implementation in testng.xml. The onTestFailure method is called by TestNG whenever a test method fails, giving you a hook to capture and save the current browser state.

package utils;

import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.testng.ITestListener;
import org.testng.ITestResult;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class TestListener implements ITestListener {

    private static final DateTimeFormatter FORMATTER =
        DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");

    @Override
    public void onTestFailure(ITestResult result) {
        // Retrieve the WebDriver instance from the test class
        Object testInstance = result.getInstance();

        if (testInstance instanceof BaseTest) {
            WebDriver driver = ((BaseTest) testInstance).getDriver();
            if (driver != null) {
                try {
                    String timestamp = LocalDateTime.now().format(FORMATTER);
                    String testName = result.getMethod().getMethodName();
                    String fileName = "screenshots/" + testName + "_" + timestamp + ".png";

                    Files.createDirectories(Paths.get("screenshots"));

                    File screenshotFile = ((TakesScreenshot) driver)
                        .getScreenshotAs(OutputType.FILE);
                    Files.copy(screenshotFile.toPath(), Paths.get(fileName));

                    // Log the screenshot path for CI artifact collection
                    System.out.println("[SCREENSHOT] Saved: " + fileName);

                } catch (Exception e) {
                    System.err.println("[SCREENSHOT] Failed to capture: " + e.getMessage());
                }
            }
        }
    }

    @Override
    public void onTestStart(ITestResult result) {
        System.out.println("[START] " + result.getMethod().getMethodName());
    }

    @Override
    public void onTestSuccess(ITestResult result) {
        System.out.println("[PASS] " + result.getMethod().getMethodName());
    }
}

The BaseTest class referenced above manages the WebDriver lifecycle with ThreadLocal storage, ensuring thread safety in parallel execution. Each test thread gets its own WebDriver instance stored in a ThreadLocal variable, preventing one thread from accidentally accessing another thread's browser:

package utils;

import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;

import java.time.Duration;

public class BaseTest {

    // ThreadLocal ensures each parallel test thread has its own WebDriver
    private static final ThreadLocal<WebDriver> driverThread = new ThreadLocal<>();

    @BeforeMethod
    public void initDriver() {
        WebDriverManager.chromedriver().setup();
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless=new", "--no-sandbox",
                             "--disable-dev-shm-usage", "--window-size=1920,1080");

        WebDriver driver = new ChromeDriver(options);
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(0));
        driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));
        driverThread.set(driver);
    }

    public WebDriver getDriver() {
        return driverThread.get();
    }

    @AfterMethod
    public void quitDriver() {
        WebDriver driver = driverThread.get();
        if (driver != null) {
            driver.quit();
            driverThread.remove();
        }
    }
}
Real-World Experience

At Virtusa, we inherited a Selenium Java suite that ran sequentially and took 4+ hours to complete. By migrating to TestNG parallel execution with 8 threads and a Selenium Grid running Chrome + Firefox nodes in Docker, we reduced that to 38 minutes. The key change was refactoring the BaseTest class to use ThreadLocal<WebDriver> — without that, parallel threads were sharing the same driver instance and causing race conditions that were nearly impossible to debug. Parallel execution only works if your driver management is thread-safe from the start.

Selenium Grid 4

Selenium Grid 4 is a complete rewrite of the original Grid that allows you to run your tests across multiple machines and multiple browsers simultaneously. Unlike the old Grid's hub-and-node model with a central hub as a bottleneck, Grid 4 uses a distributed architecture where Router, Distributor, Session Queue, Session Map, and Node components communicate via events. For most teams, the Docker Compose approach to running Grid 4 locally is the simplest starting point.

version: '3'
services:
  selenium-hub:
    image: selenium/hub:4.20.0
    ports:
      - "4442:4442"
      - "4443:4443"
      - "4444:4444"

  chrome:
    image: selenium/node-chrome:4.20.0
    depends_on:
      - selenium-hub
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=2
    deploy:
      replicas: 4  # 4 nodes × 2 sessions each = 8 parallel Chrome sessions

  firefox:
    image: selenium/node-firefox:4.20.0
    depends_on:
      - selenium-hub
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
    deploy:
      replicas: 2  # 2 Firefox nodes for cross-browser coverage

Start the Grid with docker-compose up -d, then verify it is running at http://localhost:4444. The Grid UI shows all registered nodes and their status. To run your Java tests against the Grid, replace the local ChromeDriver instantiation with a RemoteWebDriver that connects to the Grid:

import org.openqa.selenium.remote.RemoteWebDriver;
import java.net.URL;

// Connect to Grid instead of local ChromeDriver
URL gridUrl = new URL("http://localhost:4444/wd/hub");
ChromeOptions options = new ChromeOptions();
options.addArguments("--no-sandbox", "--disable-dev-shm-usage");

WebDriver driver = new RemoteWebDriver(gridUrl, options);
driver.get("https://app.example.com/login");

A common pattern is to read the WebDriver type (local or grid) from a system property or environment variable, so the same test code runs locally against a personal ChromeDriver and in CI against the Grid without any code changes:

String executionMode = System.getProperty("execution.mode", "local");
WebDriver driver;

if ("grid".equals(executionMode)) {
    URL gridUrl = new URL(System.getProperty("grid.url", "http://localhost:4444/wd/hub"));
    driver = new RemoteWebDriver(gridUrl, new ChromeOptions());
} else {
    WebDriverManager.chromedriver().setup();
    driver = new ChromeDriver(new ChromeOptions());
}
// Then use: mvn test -Dexecution.mode=grid -Dgrid.url=http://ci-server:4444/wd/hub

GitHub Actions CI/CD

Running your Selenium Java tests in GitHub Actions requires setting up Java, Maven, Chrome, and then executing the Maven test lifecycle. The following workflow is production-ready — it caches Maven dependencies between runs for faster builds and publishes the TestNG report as a downloadable artifact.

name: Selenium Java Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Java 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'maven'  # Cache Maven local repository for faster builds

      - name: Set up Chrome
        uses: browser-actions/setup-chrome@latest

      - name: Run Selenium tests via Maven
        run: mvn test -Dsurefire.suiteXmlFiles=testng.xml
        env:
          APP_URL: ${{ secrets.APP_URL }}
          TEST_USER: ${{ secrets.TEST_USER }}
          TEST_PASS: ${{ secrets.TEST_PASS }}

      - name: Publish TestNG report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: testng-surefire-report
          path: target/surefire-reports/
          retention-days: 30

      - name: Publish screenshots on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: failure-screenshots
          path: screenshots/
          retention-days: 7

The cache: 'maven' option in setup-java caches the ~/.m2 Maven local repository between workflow runs. Since Maven downloads can take several minutes on a clean machine, this cache dramatically speeds up CI builds — often reducing dependency download time from 3-4 minutes to under 10 seconds on cache hits.

Best Practices

The following practices are drawn from real-world Java Selenium projects at Viasat and Virtusa, and they specifically address the challenges that arise in Java enterprise frameworks that go beyond general Selenium advice.

1. Always use PageFactory and @FindBy annotations. Java's built-in POM support is more elegant than manually constructing By locators in every method. PageFactory's lazy initialisation also means you get fresh element lookups automatically, reducing StaleElementReferenceException errors.

2. Never mix implicit and explicit waits. Set implicit wait to zero in your BaseTest and use only explicit WebDriverWait. The interaction between the two wait types causes unpredictable timeouts that are extraordinarily difficult to debug in parallel execution.

3. Use Java enums for test data constants. Instead of string literals scattered through test files, define an enum for user roles, environments, and test states. This provides IDE autocomplete and compile-time validation: TestUser.ADMIN is clearer and safer than the string "admin@example.com".

4. Use @BeforeSuite for expensive, one-time setup. Connecting to Selenium Grid, starting Appium server, loading large configuration files — these should happen once per suite, not before every test method. @BeforeSuite runs once for the entire test suite; store the results in static variables accessible to all tests.

5. Prefer composition over inheritance in page classes. Instead of having every page class extend a BasePage, inject shared utilities through constructor parameters or static utility classes. Deep inheritance hierarchies become maintenance nightmares when you need to change shared behaviour.

6. Use Allure or ExtentReports for HTML reports. TestNG's built-in reports are functional but minimal. Allure Reports produce beautiful, interactive HTML reports with test timeline, step-level logs, and attachment display. Add the Allure TestNG adapter as a dependency and annotate test steps with @Step for detailed execution trails.

7. Manage WebDriver lifecycle in BaseTest with ThreadLocal. Never store WebDriver as an instance variable on a test class when running tests in parallel — instance variables are shared across thread boundaries. ThreadLocal<WebDriver> in BaseTest guarantees each parallel thread has an isolated driver.

8. Use properties files for environment configuration. Keep base URLs, user credentials, and environment flags in config.properties files — one per environment (local, staging, production). Load the correct file based on a system property: mvn test -Denv=staging. Never hardcode URLs in test or page classes.

9. Run tests in parallel with TestNG thread-count. A test suite that runs sequentially in 60 minutes can often be reduced to 15 minutes with parallel="methods" and thread-count="4". Design your tests to be independent from the start so parallelisation is always safe to enable.

10. Use SoftAssert for multi-condition verification. When a single test needs to verify multiple aspects of a page (form has correct labels, buttons are enabled, images load), use TestNG's SoftAssert. It collects all assertion failures and reports them all at the end, rather than stopping at the first failure — giving you a complete picture of what is broken in a single test run.

import org.testng.asserts.SoftAssert;

@Test
public void testCheckoutPageCompleteness() {
    SoftAssert softAssert = new SoftAssert();

    softAssert.assertTrue(checkoutPage.isDeliveryFormVisible(), "Delivery form not visible");
    softAssert.assertTrue(checkoutPage.isPaymentSectionVisible(), "Payment section missing");
    softAssert.assertEquals(checkoutPage.getCartItemCount(), 3, "Wrong cart item count");
    softAssert.assertFalse(checkoutPage.getOrderTotal().isEmpty(), "Order total is blank");

    softAssert.assertAll(); // Reports ALL failures at once
}
From Experience — Virtusa: Leading a team of 270 testers at Virtusa, we standardised on Appium for real Android device testing and Selenium WebDriver for web regression. The biggest challenge wasn't the tooling — it was consistency across a team that size. We enforced a strict Page Object Model convention and a pre-merge locator review checklist. Within two sprints, flaky test rates dropped significantly and the team achieved a 20% efficiency gain across regression cycles. At that scale, test architecture decisions matter far more than individual test quality.