PRACTICAL GUIDE / selenium java tutorial

Selenium Java Tutorial: Build a Maintainable Test Suite

Selenium Java tutorial for beginners covering Maven setup, WebDriver, waits, TestNG, JUnit, page objects, debugging, CI, and reliable UI tests.

By The Testing AcademyUpdated July 10, 202610 min read
All field guides
In this guide9 sections
  1. Establish the repository boundary
  2. Build a driver factory with clear ownership
  3. Create data outside the behavior under test
  4. Put page behavior in a focused object
  5. Write one test with a durable assertion
  6. Use explicit waits for meaningful transitions
  7. Capture evidence through a JUnit extension
  8. Scale structure only when repetition appears
  9. Run the same contract in CI

What you will learn

  • Establish the repository boundary
  • Build a driver factory with clear ownership
  • Create data outside the behavior under test
  • Put page behavior in a focused object

A returns test clicks "Approve," sees no error, and passes. The next morning the return is still pending because the test never asserted the saved state. This is a common Selenium Java failure pattern: a lot of framework code surrounds a weak business check. A maintainable suite starts with a narrow behavior, deterministic data, and an assertion that would catch the regression users care about.

The project below uses Java, Maven, JUnit Jupiter, and Selenium WebDriver to test a returns console. It creates a return through an API, approves it in the browser, and verifies the persisted result. The design stays small enough to understand while leaving clear extension points.

Establish the repository boundary

Keep browser tests in the product repository or a dedicated test repository with an explicit owner. A straightforward layout is:

Example
src/test/java/
├── e2e/returns/ApproveReturnTest.java
├── pages/ReturnsPage.java
└── support/
    ├── DriverFactory.java
    ├── ReturnApi.java
    └── ScreenshotExtension.java
src/test/resources/
└── junit-platform.properties

Add selenium-java, JUnit Jupiter, and the Maven Surefire plugin through the repository's dependency management. Use reviewed current versions and commit the resolved configuration. Modern Selenium can provision compatible drivers with Selenium Manager in many environments, but the browser itself must still exist on the machine. Containerized or locked-down CI may need explicit browser provisioning.

Give developers one command:

Shell
mvn test -Dapp.url=http://127.0.0.1:8080 -Dgroups=smoke

Do not hide environment selection inside test classes. System properties or a typed configuration object make local and CI targets visible.

Fail configuration at startup. A missing app.url, blank API token, or unsupported browser should stop the suite with a direct message before any records or drivers are created. Defaults are useful for harmless local URLs, but a silent fallback from staging to localhost can make CI results meaningless.

Keep production and test-support clients separate. The test-support client may create states that public APIs cannot, so restrict it to controlled environments and a limited credential. A browser suite should never make a test-support route available in production merely for convenience.

Build a driver factory with clear ownership

Create one driver per test by default. Sharing a static driver makes test order significant and prevents safe parallel execution.

Java
package support;

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 create() {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--window-size=1440,900");

        if (Boolean.getBoolean("headless")) {
            options.addArguments("--headless=new");
        }

        return new ChromeDriver(options);
    }
}

Browser arguments differ by browser and environment. Keep Firefox or remote Grid creation in separate branches rather than feeding Chrome flags into every driver. Do not disable TLS validation as a general fix for a test certificate problem. Repair trust configuration in the test environment.

For parallel execution, the test instance should own its driver, or a carefully managed ThreadLocal should bind a driver to a worker. In either case, quit() belongs in an always-run teardown.

Create data outside the behavior under test

The browser journey evaluates approval. Creating the return through customer screens would add account, order, and shipping dependencies. A small API client can create a unique pending return before the test.

Java
package support;

public record ReturnCase(String id, String reference) {}
Java
ReturnCase returnCase = returnApi.createPendingReturn(
    "e2e-" + UUID.randomUUID(),
    "DAMAGED_IN_TRANSIT"
);

The API client should authenticate with a limited test credential, check the response status, parse a typed response, and return only the fields the UI test needs. Delete or expire the record afterward. Never reuse a fixed return ID if CI can run more than one worker.

If the API is eventually consistent, make the setup client poll a read endpoint until the pending record is available or a deadline expires. A fixed sleep makes fast runs slower and slow runs still fail.

Put page behavior in a focused object

A page object should describe how to use the returns page, not wrap every WebDriver method. Keep locators private, actions meaningful, and waits tied to observable state.

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 final class ReturnsPage {
    private final WebDriver driver;
    private final WebDriverWait wait;

    private final By search = By.cssSelector("[data-testid='return-search']");
    private final By approve = By.cssSelector("[data-testid='approve-return']");
    private final By confirmation = By.cssSelector("[role='status']");

    public ReturnsPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }

    public void open(String baseUrl) {
        driver.get(baseUrl + "/returns");
        wait.until(ExpectedConditions.visibilityOfElementLocated(search));
    }

    public void find(String reference) {
        var input = driver.findElement(search);
        input.clear();
        input.sendKeys(reference);

        By row = By.cssSelector("[data-return-reference='" + reference + "']");
        wait.until(ExpectedConditions.visibilityOfElementLocated(row));
    }

    public void approve() {
        wait.until(ExpectedConditions.elementToBeClickable(approve)).click();
        wait.until(ExpectedConditions.textToBePresentInElementLocated(
            confirmation,
            "Return approved"
        ));
    }
}

Putting raw data into a CSS selector is safe here only because the generated reference uses a restricted format. If user-controlled text can contain quotes or escape characters, locate the row by a stable record ID or filter found elements in Java.

The approve method waits for visible confirmation because clicking alone is not completion. It does not assert the final business status; that remains the test's responsibility.

Write one test with a durable assertion

JUnit lifecycle methods make ownership explicit. Cleanup attempts both browser shutdown and test-data deletion.

Java
package e2e.returns;

import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import pages.ReturnsPage;
import support.DriverFactory;
import support.ReturnApi;
import support.ReturnCase;

class ApproveReturnTest {
    private WebDriver driver;
    private ReturnApi returnApi;
    private ReturnCase returnCase;

    @BeforeEach
    void setUp() {
        returnApi = ReturnApi.fromEnvironment();
        returnCase = returnApi.createPendingReturn(
            "e2e-" + UUID.randomUUID(),
            "DAMAGED_IN_TRANSIT"
        );
        driver = DriverFactory.create();
    }

    @AfterEach
    void tearDown() {
        try {
            if (driver != null) driver.quit();
        } finally {
            if (returnCase != null) returnApi.delete(returnCase.id());
        }
    }

    @Test
    @Tag("smoke")
    void agentApprovesPendingReturn() {
        var page = new ReturnsPage(driver);
        page.open(System.getProperty("app.url"));
        page.find(returnCase.reference());
        page.approve();

        assertEquals("APPROVED", returnApi.getStatus(returnCase.id()));
    }
}

The API assertion proves persistence. If the business promise is specifically that the UI updates after approval, also locate the row's status cell and assert its text. Keep both only when they protect distinct failure modes.

Use explicit waits for meaningful transitions

Set implicit wait to zero or a documented suite-wide value and prefer explicit waits around known transitions. Mixing a large implicit wait with explicit conditions can make timeout behavior difficult to predict.

Wait for the state needed by the next action: a frame available, an overlay gone, a URL changed, a button enabled, or a row containing the new status. Presence alone is insufficient for a control that must be clicked. Clickability alone does not prove that a save finished.

Avoid catching StaleElementReferenceException around the whole test. If a React update replaces a row, keep its By locator and let the wait locate the current element. Retrying every stale interaction can conceal a genuinely unstable page or repeat a destructive click.

Custom conditions should return a useful value, not only true. A condition that waits for an approved row can return the current row element, allowing the next assertion to operate on the node that satisfied readiness. Include the return reference and last observed status in timeout messages.

Keep timeouts proportional to the transition. A local dropdown should not inherit the same minute-long timeout as a document-generation job. Define a small set of named durations, such as UI transition and background job, and justify exceptions close to the test.

Capture evidence through a JUnit extension

Failure handling belongs in one extension rather than every test. The extension can receive the driver through a small interface or extension store, then save:

  • A screenshot with test class, method, and unique timestamp.
  • Current URL and page title.
  • Browser console entries where the driver supports them.
  • The remote session ID and capabilities for Grid runs.
  • The test-owned return reference, without customer personal data.

Save the original exception before artifact collection. Screenshot failure must not replace the product failure. Limit page-source capture on sensitive screens because HTML can contain tokens and personal details.

For local debugging, run the single method in headed mode and inspect browser DevTools. For CI-only failures, compare viewport, locale, browser build, server logs, and test data IDs before adding timeouts.

Register the extension once through a base annotation or JUnit configuration so new tests cannot forget evidence capture. The extension should use the JUnit extension context for a stable test identifier and create artifact directories safely under parallel execution.

Attach structured metadata beside screenshots. A small JSON file containing test name, session ID, browser name, base URL, and return reference is easier for CI tooling to index than values embedded in filenames. Exclude tokens, customer names, and response bodies unless they have been explicitly redacted.

Scale structure only when repetition appears

As coverage grows, add component objects for shared widgets such as filters or confirmation dialogs. Do not create BasePage.click() and BasePage.type() wrappers that erase Selenium's own stack traces. A useful abstraction speaks the product language, such as approveReturn or selectReason.

Keep assertions in tests except for readiness assertions intrinsic to an action. Prefer composition over a deep page inheritance hierarchy. A dialog component can be used by several pages without making them subclasses of one large base class.

Split suites by risk and runtime. Smoke tests should cover a few release-blocking journeys. Broader regression can run later or across Grid. Quarantine is a temporary state with an owner and deadline, not a folder where unreliable tests disappear.

Use parameterized tests for genuine input partitions, such as several documented return reasons that share the same behavior. Do not turn a full business workflow into a large parameter matrix merely to increase case count. One failure should still identify the exact rule and dataset.

Keep API clients, page objects, and test assertions in separate packages because they change for different reasons. This is not a mandate for an interface around every class. Introduce an interface when there are multiple implementations or a real testing seam, not as framework decoration.

Review helper visibility. Public utility methods become an accidental framework API that future tests depend on. Package-private methods and small final classes make unsupported combinations harder to create and keep refactoring local.

Run the same contract in CI

CI must provision a browser, start or target the application, inject test API credentials, and upload evidence even after Maven fails.

YAML
- name: Run Selenium smoke tests
  run: >-
    mvn test
    -Dgroups=smoke
    -Dheadless=true
    -Dapp.url=${{ vars.TEST_APP_URL }}
  env:
    TEST_API_URL: ${{ vars.TEST_API_URL }}
    TEST_API_TOKEN: ${{ secrets.TEST_API_TOKEN }}

- name: Upload browser evidence
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: selenium-evidence
    path: target/test-evidence

Pin Java, browser, dependencies, and CI actions according to repository policy. Run tests independently and randomize order occasionally to detect hidden coupling. A maintainable Selenium Java suite is not defined by the number of utility classes. It is defined by state the test owns, waits tied to product transitions, assertions that prove durable behavior, and failure evidence another engineer can act on.

Publish JUnit XML separately from screenshots so the pipeline can show test history and preserve visual evidence. If a remote provider or Grid is used, include its session link only when access is restricted and the link contains no embedded credential.

Set a suite-level execution deadline in addition to individual waits. A dead browser transport can otherwise consume the entire agent. Teardown and cleanup should run under their own short bounds, and cleanup failure should be reported without erasing the test's original product assertion.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 10, 2026 / Reviewed July 10, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Selenium documentation

    Selenium Project

    Canonical WebDriver, Grid, waits, element, and browser automation guidance.

  2. 02
    WebDriver standard

    W3C

    The browser automation protocol specification behind WebDriver implementations.

FAQ / QUICK ANSWERS

Questions testers ask

Is Java still good for Selenium?

Yes. Java remains one of the most common Selenium languages in enterprise QA because many teams already use JVM tooling, TestNG, JUnit, Maven, Gradle, and Selenium Grid. It has more ceremony than Python or JavaScript, but it also fits large automation frameworks well.

Should beginners choose TestNG or JUnit?

Both work. TestNG is common in Selenium training because it has flexible suites, groups, parameters, and parallel settings. JUnit is widely used in modern Java projects. Choose the runner your team already understands, then keep test design focused on behavior instead of runner features.

Do I need Page Object Model in the first Selenium Java test?

No. Write one clear test first. Add Page Object Model when selectors and actions repeat across tests. If you introduce page objects too early, you may create abstractions before you understand the app behavior, which can make the framework harder to maintain.

Why are Selenium Java tests flaky?

Flakiness usually comes from weak waits, unstable locators, shared data, environment problems, and tests that check too many behaviors at once. Java itself is rarely the cause. Good synchronization, independent tests, and clear assertions reduce most failures.

Can Selenium Java run in CI?

Yes. Selenium Java tests commonly run in CI using local headless browsers, Docker images, or Selenium Grid. CI setup must install browsers, configure display or headless mode, manage test data, publish reports, and fail the pipeline only on meaningful test failures.