Skip to content
QA Workflow Assistant

Blog

Java Selenium Tutorial: Build Reliable UI Automation

A practical Java Selenium tutorial covering Maven setup, WebDriver, locators, waits, page objects, assertions, parallel runs, reporting, and CI packaging for QA teams.

QA Workflow Assistant9 min read
  • java
  • selenium
  • test-automation
  • maven
  • qa

Java remains a common language for enterprise UI automation, and Selenium WebDriver is still the default driver API many teams standardize on. This Java Selenium tutorial walks through a reliable baseline: project structure, a first test, locators, waits, page objects, assertions, lifecycle hooks, framework choice, parallel execution, reporting, and CI packaging.

If you want the broader strategy first, start with the automation testing guide. For API-level WebDriver concepts that apply across languages, see the Selenium WebDriver tutorial. Here the focus is Java, Maven, and habits that keep suites maintainable.

What you will build

By the end you should have:

  • A Maven (or Gradle-equivalent) test module with Selenium and a test runner
  • One happy-path UI test that opens a browser, interacts with a page, and asserts an outcome
  • Locators and waits that avoid brittle sleeps
  • A thin page object layer instead of raw driver calls in every test
  • A clear path to TestNG or JUnit, parallel runs, reports, and CI packaging

This is not a catalog of every Selenium API. It is the path most SDET teams need before they scale.

Prerequisites

  • JDK 17 or newer (LTS is fine; match what your org already ships)
  • Maven 3.9+ or Gradle 8+
  • A browser installed locally (Chrome or Firefox is enough to start)
  • Comfort reading Java and JUnit/TestNG-style tests

You do not need a Selenium Grid on day one. Local drivers are enough while you learn the patterns.

Project setup with Maven

Create a dedicated module (or repo) for UI tests. Mixing production app code and UI tests in one tangled classpath works until the first dependency conflict; separate modules stay cleaner.

Minimal pom.xml shape:

<?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
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
 
  <groupId>com.example</groupId>
  <artifactId>ui-tests</artifactId>
  <version>1.0.0-SNAPSHOT</version>
 
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <selenium.version>4.25.0</selenium.version>
  </properties>
 
  <dependencies>
    <dependency>
      <groupId>org.seleniumhq.selenium</groupId>
      <artifactId>selenium-java</artifactId>
      <version>${selenium.version}</version>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.11.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
 
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.0</version>
      </plugin>
    </plugins>
  </build>
</project>

Pin versions deliberately. Floating LATEST tags hide breakage until a pipeline fails for a reason that is hard to bisect.

Maven vs Gradle structure

Either build tool is fine. What matters is layout:

ui-tests/
  src/test/java/com/example/
    pages/
    tests/
    support/
  src/test/resources/
    config.properties
  pom.xml   # or build.gradle.kts
  • pages/ — page objects and small UI components
  • tests/ — test classes only
  • support/ — driver factory, waits, config, screenshots
  • resources/ — URLs, timeouts, credentials placeholders (never real secrets in git)

Gradle users typically mirror the same packages under src/test/java and declare Selenium plus the runner in dependencies. The Java Selenium tutorial patterns below are identical either way.

WebDriver in modern Selenium

Selenium 4+ manages browser drivers through Selenium Manager for common local setups. You still create a WebDriver instance and own its lifecycle.

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
 
public final class DriverFactory {
  private DriverFactory() {}
 
  public static WebDriver createChrome() {
    ChromeOptions options = new ChromeOptions();
    options.addArguments("--window-size=1440,900");
    // options.addArguments("--headless=new"); // enable in CI when appropriate
    return new ChromeDriver(options);
  }
}

Keep options in one place. Tests should not sprinkle Chrome flags. When you later need Firefox or remote Grid sessions, you extend the factory instead of editing every test.

First Java Selenium test

A first test should prove navigation, interaction, and assertion—not every edge case on the login form.

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 static org.junit.jupiter.api.Assertions.assertTrue;
 
class LoginSmokeTest {
  private WebDriver driver;
 
  @BeforeEach
  void setUp() {
    driver = DriverFactory.createChrome();
  }
 
  @AfterEach
  void tearDown() {
    if (driver != null) {
      driver.quit();
    }
  }
 
  @Test
  void userCanReachDashboardWithValidCredentials() {
    driver.get("https://example.com/login");
 
    driver.findElement(By.id("username")).sendKeys("demo.user");
    driver.findElement(By.id("password")).sendKeys("correct-horse");
    driver.findElement(By.cssSelector("button[type='submit']")).click();
 
    WebElement heading = driver.findElement(By.cssSelector("h1.dashboard-title"));
    assertTrue(heading.isDisplayed());
  }
}

Run with:

mvn -Dtest=LoginSmokeTest test

If this fails, fix environment and selectors before adding page objects. A green smoke path is the foundation everything else builds on.

Locators that survive UI churn

Prefer locators tied to stable contracts over visual position.

PreferenceExampleWhy
data-testid / data-qaBy.cssSelector("[data-testid='login-submit']")Explicit test hook
Accessible roles/labelsPrefer role-based queries when availableMatches how users perceive UI
id when stableBy.id("username")Fast and clear
Semantic CSSBy.cssSelector("form.login button[type='submit']")OK if structure is stable
XPath absolute paths/html/body/div[3]/div[1]/buttonAvoid; breaks on layout changes
Text-only XPath//button[text()='Submit']Fragile with i18n and whitespace

Agree with frontend on test IDs for critical flows. One attribute costs less than weeks of locator churn.

Example page fragment the team might expose:

<button type="submit" data-testid="login-submit">Sign in</button>

Then in Java:

By submit = By.cssSelector("[data-testid='login-submit']");

Waits: stop sleeping

Thread.sleep is the fastest way to make a suite both slow and flaky. Selenium's explicit waits poll until a condition is true or a timeout expires.

import java.time.Duration;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
 
public final class Waits {
  private Waits() {}
 
  public static WebElement visible(WebDriver driver, By locator) {
    return new WebDriverWait(driver, Duration.ofSeconds(10))
        .until(ExpectedConditions.visibilityOfElementLocated(locator));
  }
 
  public static void clickableAndClick(WebDriver driver, By locator) {
    new WebDriverWait(driver, Duration.ofSeconds(10))
        .until(ExpectedConditions.elementToBeClickable(locator))
        .click();
  }
}

Use explicit waits for elements and states you care about. Implicit waits mixed with explicit waits create confusing timing. Prefer one strategy: explicit waits in helpers, zero or near-zero implicit wait.

For deeper wait patterns, see Selenium explicit waits.

Page Object Model in Java

Raw driver.findElement calls in every test duplicate selectors and hide intent. A page object exposes user actions and queries.

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
 
public class LoginPage {
  private final WebDriver driver;
  private final By username = By.cssSelector("[data-testid='username']");
  private final By password = By.cssSelector("[data-testid='password']");
  private final By submit = By.cssSelector("[data-testid='login-submit']");
 
  public LoginPage(WebDriver driver) {
    this.driver = driver;
  }
 
  public LoginPage open(String baseUrl) {
    driver.get(baseUrl + "/login");
    return this;
  }
 
  public DashboardPage signIn(String user, String pass) {
    Waits.visible(driver, username).sendKeys(user);
    Waits.visible(driver, password).sendKeys(pass);
    Waits.clickableAndClick(driver, submit);
    return new DashboardPage(driver);
  }
}
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
 
public class DashboardPage {
  private final WebDriver driver;
  private final By title = By.cssSelector("[data-testid='dashboard-title']");
 
  public DashboardPage(WebDriver driver) {
    this.driver = driver;
  }
 
  public boolean isLoaded() {
    return Waits.visible(driver, title).isDisplayed();
  }
}

Test becomes readable:

@Test
void userCanReachDashboardWithValidCredentials() {
  DashboardPage dashboard = new LoginPage(driver)
      .open(Config.baseUrl())
      .signIn("demo.user", "correct-horse");
 
  assertTrue(dashboard.isLoaded());
}

Keep page objects thin: locators, navigation, actions, and simple state queries. Do not put assertions inside page objects unless your team has a deliberate fluent-assert style. For a fuller treatment, read the page object model guide.

Assertions that fail clearly

Assertions should name the business outcome, not the DOM accident.

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
assertTrue(dashboard.isLoaded(), "Dashboard should be visible after valid login");
 
assertAll(
    () -> assertEquals("Welcome, Demo", dashboard.greetingText()),
    () -> assertTrue(dashboard.navContains("Billing"))
);

assertAll (JUnit 5) gathers multiple failures in one run—useful when you want several independent checks on a single screen without stop-on-first-failure.

Soft assertions in TestNG serve a similar purpose. The framework choice matters less than writing expected results that a human can understand in a CI log.

Test lifecycle: setup and teardown

Own the driver per test (or carefully per class) and always quit it.

Recommended baseline:

  1. @BeforeEach / @BeforeMethod — create driver, optionally open base URL
  2. Test body — arrange via page objects, act, assert
  3. @AfterEach / @AfterMethod — capture screenshot on failure, then driver.quit()
@AfterEach
void tearDown(TestInfo info) {
  try {
    if (failed(info)) {
      Screenshot.take(driver, info.getDisplayName());
    }
  } finally {
    if (driver != null) {
      driver.quit();
    }
  }
}

Sharing one browser across an entire class can speed local runs, but it also leaks cookies and session state between tests. Prefer isolation until you have a measured reason to share.

TestNG vs JUnit: pick deliberately

Both runners work with Selenium. JUnit 5 is the default in many Java shops and integrates cleanly with Maven Surefire and modern IDEs. TestNG still shines when you want suite XML, flexible grouping, and mature parallel/dependency features without extra plugins.

If your team is undecided, read TestNG vs JUnit for automation testing before you invent a hybrid. Switching later is possible but expensive once annotations and suite files sprawl.

Practical rule of thumb:

  • Greenfield Java service org with JUnit everywhere → start with JUnit 5
  • Existing TestNG suites, heavy XML suites, or dependency-driven flows → stay on TestNG unless migration is funded

Parallel execution overview

Parallelism is optional on day one and valuable once the suite is stable.

Approaches:

  • JUnit 5 — enable parallel execution via junit-platform.properties (junit.jupiter.execution.parallel.enabled=true) and configure mode/strategy carefully
  • TestNG — suite XML parallel="methods" or parallel="classes" with a thread-count
  • Maven SurefireforkCount and reuseForks for JVM-level isolation

Rules that prevent chaos:

  • One WebDriver per thread (use ThreadLocal in the factory if needed)
  • No mutable static state for session cookies or test data counters
  • Unique test data per thread (emails, order IDs)
  • Start with class-level parallel, then method-level if isolation holds

Measure flakiness after enabling parallel. Speed that adds noise is not a win.

Reporting

CI needs more than a red exit code. Minimum useful reporting:

  • Surefire / Failsafe XML and HTML for pass/fail counts
  • Screenshots (and optionally page source) on failure attached as CI artifacts
  • A human-readable summary in the job log (failed test names first)

Optional upgrades:

  • Allure or Extent-style reports for richer timelines
  • Links from failed cases back to ticket IDs in titles or tags

Name tests after observable behavior (userCanReachDashboardWithValidCredentials), not implementation (testLogin1). Reports become triage tools instead of noise.

Packaging for CI

Treat the UI suite as a deployable test artifact:

  1. Build with a fixed JDK version in CI
  2. Install or rely on Selenium Manager for browsers, or use a known browser image
  3. Run headless or headed consistently with the environment you chose
  4. Upload surefire reports and screenshots as artifacts
  5. Fail the job on test failures; do not soft-pass flaky suites without quarantine

For pipeline wiring patterns, see GitHub Actions for test automation.

Example Surefire invocation in CI:

mvn -B clean test \
  -DbaseUrl="$BASE_URL" \
  -Dheadless=true

Pass environment-specific values through system properties or env vars, not hardcoded URLs in page objects.

Designing cases before you automate them

Automation multiplies whatever case design you already have. Weak steps become fragile scripts. Strong cases with clear preconditions and expected results become stable candidates.

Use a shared QA test case template before writing Java. Prioritize what belongs in UI automation versus API checks when you plan regression test cases. Automating every manual case is rarely the right goal; automating the stable, high-value paths is.

When stories land in Jira with acceptance criteria, draft structured cases first, then decide which ones deserve Selenium. QA Workflow Assistant helps design and generate structured test cases from Jira so your automation backlog starts from reviewed coverage—not from ad-hoc scripts. Setup and workflow notes live in Docs.

FAQ

Do I need Selenium Grid to start?

No. Local Chrome or Firefox is enough to learn WebDriver, waits, and page objects. Introduce Grid or a cloud grid when you need concurrent browsers or OS/browser combinations you cannot host locally.

Should every test open a fresh browser?

Prefer yes for reliability. Reuse only when you have measured startup cost and proven that shared sessions do not leak state.

Is Java still worth it for new UI automation?

If your org is already Java-heavy, Java Selenium (or Java Playwright) keeps one language across services and tests. If the team is TypeScript-first, evaluate Playwright in JS/TS before defaulting to Java for greenfield UI work. Language fit beats fashion.

How do I handle authentication?

Prefer test hooks: seeded users, magic links in non-prod, or API login that injects a session cookie before UI steps. Driving the full SSO UI in every test is slow and brittle unless SSO itself is under test.

What about flaky tests?

Treat flakes as defects. Common causes: missing waits, shared data, animations, third-party widgets, and parallel collisions. Quarantine briefly if needed, but assign an owner. Unowned flakes teach the team to ignore red builds.

Maven or Gradle?

Either. Pick the tool your platform team already supports so dependency updates and CI caches are boring.

Next steps

  1. Scaffold the Maven module and get one smoke test green locally
  2. Extract locators into page objects and centralize waits
  3. Choose JUnit 5 or TestNG deliberately (comparison)
  4. Add failure screenshots and CI packaging (GitHub Actions guide)
  5. Expand only the cases that earn their keep in regression

Reliable Java Selenium suites are less about clever APIs and more about structure: clear drivers, stable locators, explicit waits, thin page objects, and CI that surfaces failures humans can act on. Build that baseline once, then grow coverage with intent.

Stay ahead in QA

Get practical QA guides, Jira & Xray tutorials, testing checklists, AI testing insights, and occasional product updates.

No spam. Unsubscribe anytime.