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.
In this guide9 sections
- 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
- Write one test with a durable assertion
- Use explicit waits for meaningful transitions
- Capture evidence through a JUnit extension
- Scale structure only when repetition appears
- 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:
src/test/java/
├── e2e/returns/ApproveReturnTest.java
├── pages/ReturnsPage.java
└── support/
├── DriverFactory.java
├── ReturnApi.java
└── ScreenshotExtension.java
src/test/resources/
└── junit-platform.propertiesAdd 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:
mvn test -Dapp.url=http://127.0.0.1:8080 -Dgroups=smokeDo 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.
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.
package support;
public record ReturnCase(String id, String reference) {}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.
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.
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.
- 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-evidencePin 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.
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.
- 01Selenium documentation
Selenium Project
Canonical WebDriver, Grid, waits, element, and browser automation guidance.
- 02WebDriver 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.
RELATED GUIDES
Continue the learning route
GUIDE 01
Selenium Wait Commands: Implicit, Explicit, and Fluent Waits
Selenium wait commands explained with implicit, explicit, and fluent waits, practical examples, timing mistakes, flake fixes, and stable patterns.
GUIDE 02
Selenium Grid Tutorial: Run Tests Across Browsers
Selenium Grid tutorial explaining architecture, setup, remote WebDriver, browser capabilities, parallel execution, Docker, CI, and debugging tips.
GUIDE 03
Page Object Model: A Pattern for Maintainable Tests
Learn the page object model for maintainable UI automation tests, with Playwright examples, best practices, and common POM mistakes to avoid.
GUIDE 04
Selenium vs Playwright vs Cypress in 2026
Compare Selenium vs Playwright vs Cypress in 2026 for speed, browsers, CI, flakiness, ecosystem, and which web automation framework beginners should choose.