PRACTICAL GUIDE / Selenium Java driver factory JUnit extension
Advanced Selenium Java: Typed Driver Factories and JUnit Extension Lifecycles
Build typed Selenium Java driver factories with test-scoped JUnit extensions, parallel-safe stores, failure capture, and guaranteed WebDriver cleanup.
In this guide11 sections
- Make the Lifecycle Contract Explicit
- Model Browser Intent Without Capability Maps
- Keep Each Options Type Attached to Its Factory
- Dispatch Without Losing the Typed Construction Path
- Store the Driver in the Test Invocation
- Align Failure Capture and Cleanup Order
- Bound Parallelism Outside the Driver Factory
- Diagnose Failures at Their Owning Layer
- Weigh Abstraction Against Matrix Complexity
- Review the Framework With a Concrete Checklist
- Close the Ownership Loop
What you will learn
- Make the Lifecycle Contract Explicit
- Model Browser Intent Without Capability Maps
- Keep Each Options Type Attached to Its Factory
- Dispatch Without Losing the Typed Construction Path
A Selenium Java framework becomes fragile when browser construction and JUnit lifecycle are separate conventions. A test asks for Chrome through a string, a factory returns an unstructured capability bag, an extension keeps the driver in a mutable field, and parallel execution eventually crosses two test invocations. The robust design is typed at construction and scoped at execution.
Use one typed factory per browser-options class, convert an immutable browser request into those options, and let a stateless JUnit extension own exactly one driver in each test context. The extension must create before parameter resolution, capture failure evidence before teardown, and quit even when artifact code fails.
Make the Lifecycle Contract Explicit
JUnit callbacks are not just convenient hooks. Together they define who owns the session and when it is valid. beforeEach acquires a browser, parameter resolution exposes that exact instance to the test, afterTestExecution observes the test outcome while the browser is alive, and afterEach releases it. No page object or test class should extend that lifetime.
The Selenium Java API exposes browser-specific option types and the common WebDriver contract. Preserve both sides: use concrete option types inside construction, then return WebDriver to callers that only need standard commands.
Animated field map
JUnit-Owned WebDriver Lifecycle
A test callback resolves a typed browser request, stores one driver in the invocation context, and guarantees cleanup after result capture.
01 / junit extension
JUnit Extension
beforeEach begins a single test invocation without mutable extension fields.
02 / browser request
Typed Browser Request
An enum and immutable profile select the matching options factory.
03 / driver factory
Driver Factory
The generic factory builds only its concrete browser options and driver.
04 / test driver
Test-Scoped WebDriver
ExtensionContext.Store owns the session for one invocation.
05 / quit callback
Guaranteed Quit Callback
Failure evidence is attempted first; afterEach always removes and quits.
Model Browser Intent Without Capability Maps
Start with a small request model that contains only suite-level decisions. Keep raw arguments and vendor options inside the relevant factory. That boundary stops a test from requesting a Firefox preference through a Chrome path or depending on an implementation-only key.
package academy.browser;
public enum BrowserKind {
CHROME,
FIREFOX
}
public record BrowserRequest(
BrowserKind kind,
boolean headless,
String locale) {
public BrowserRequest {
if (kind == null) throw new IllegalArgumentException("kind is required");
if (locale == null || locale.isBlank()) {
throw new IllegalArgumentException("locale is required");
}
}
}This request is deliberately smaller than Capabilities. It expresses policy inputs that the framework supports. Grid URL, credentials, download directories, tracing, or proxy configuration can be supplied through dedicated configuration objects rather than an open Map<String, Object> shared by every test.
Keep Each Options Type Attached to Its Factory
A generic interface makes the browser-options relationship compile-time visible. The implementation can build a local or remote driver from the same concrete options type. The dispatch layer chooses a complete factory; it does not cast an options object from one browser to another.
package academy.browser;
import java.net.URL;
import org.openqa.selenium.MutableCapabilities;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
public interface DriverFactory<O extends MutableCapabilities> {
O optionsFor(BrowserRequest request);
WebDriver createLocal(O options);
default WebDriver createRemote(URL grid, O options) {
return new RemoteWebDriver(grid, options);
}
}
final class ChromeFactory implements DriverFactory<ChromeOptions> {
public ChromeOptions optionsFor(BrowserRequest request) {
ChromeOptions options = new ChromeOptions();
options.addArguments("--lang=" + request.locale());
if (request.headless()) options.addArguments("--headless");
return options;
}
public WebDriver createLocal(ChromeOptions options) {
return new ChromeDriver(options);
}
}
final class FirefoxFactory implements DriverFactory<FirefoxOptions> {
public FirefoxOptions optionsFor(BrowserRequest request) {
FirefoxOptions options = new FirefoxOptions();
options.addPreference("intl.accept_languages", request.locale());
if (request.headless()) options.addArguments("-headless");
return options;
}
public WebDriver createLocal(FirefoxOptions options) {
return new FirefoxDriver(options);
}
}Parse and validate the configured Grid URL at startup rather than in every test callback. The important design property is that ChromeOptions never enters FirefoxFactory, and the browser-specific details do not leak into tests.
Dispatch Without Losing the Typed Construction Path
Java cannot put differently parameterized factories in a collection and retrieve their exact generic type from a runtime enum without an abstraction boundary. Do not hide that fact with unchecked casts. A small dispatcher can keep each branch typed and return only the common WebDriver result.
package academy.browser;
import java.net.URL;
import org.openqa.selenium.WebDriver;
public final class BrowserLauncher {
private final URL grid;
public BrowserLauncher(URL grid) {
this.grid = grid;
}
public WebDriver launch(BrowserRequest request) {
return switch (request.kind()) {
case CHROME -> launch(new ChromeFactory(), request);
case FIREFOX -> launch(new FirefoxFactory(), request);
};
}
private <O extends org.openqa.selenium.MutableCapabilities> WebDriver launch(
DriverFactory<O> factory,
BrowserRequest request) {
O options = factory.optionsFor(request);
return grid == null ? factory.createLocal(options) : factory.createRemote(grid, options);
}
}The generic helper preserves the relationship for the duration of each branch. Adding a browser means adding its factory and one exhaustive enum branch. If your browser matrix is plug-in driven at runtime, use a non-generic launcher interface whose implementation privately retains the typed factory instead of publishing unchecked generic casts.
Store the Driver in the Test Invocation
The extension must remain stateless because a registered extension object can participate in concurrent invocations. ExtensionContext.Store is the ownership boundary. Use the current test context, not the root context, or the driver will accidentally become suite-scoped.
package academy.junit;
import academy.browser.BrowserKind;
import academy.browser.BrowserLauncher;
import academy.browser.BrowserRequest;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
public final class WebDriverExtension implements BeforeEachCallback,
AfterTestExecutionCallback, AfterEachCallback, ParameterResolver {
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create(WebDriverExtension.class);
private static final String DRIVER = "driver";
@Override
public void beforeEach(ExtensionContext context) {
BrowserKind kind = BrowserKind.valueOf(
System.getProperty("browser", "CHROME").toUpperCase());
BrowserRequest request = new BrowserRequest(kind, true, "en-US");
WebDriver driver = new BrowserLauncher(null).launch(request);
context.getStore(NS).put(DRIVER, driver);
}
@Override
public boolean supportsParameter(
ParameterContext parameter, ExtensionContext context) {
return parameter.getParameter().getType().equals(WebDriver.class);
}
@Override
public Object resolveParameter(
ParameterContext parameter, ExtensionContext context) {
WebDriver driver = context.getStore(NS).get(DRIVER, WebDriver.class);
if (driver == null) {
throw new ParameterResolutionException("WebDriver was not created for this test");
}
return driver;
}
@Override
public void afterTestExecution(ExtensionContext context) throws Exception {
if (context.getExecutionException().isEmpty()) return;
WebDriver driver = context.getStore(NS).get(DRIVER, WebDriver.class);
if (driver instanceof TakesScreenshot camera) {
byte[] png = camera.getScreenshotAs(OutputType.BYTES);
Path directory = Path.of("build", "failures");
Files.createDirectories(directory);
Files.write(directory.resolve(context.getUniqueId().hashCode() + ".png"), png);
}
}
@Override
public void afterEach(ExtensionContext context) {
WebDriver driver = context.getStore(NS).remove(DRIVER, WebDriver.class);
if (driver != null) driver.quit();
}
}For stronger cleanup, catch artifact exceptions so they do not prevent afterEach, and aggregate cleanup errors with the original test failure in your reporting layer. If driver construction succeeds but a later setup step fails, put the driver into the store immediately or guard that setup with a local try/finally.
Align Failure Capture and Cleanup Order
Failure evidence must be collected before quit() invalidates the session. That is why capture belongs in afterTestExecution and release belongs in afterEach. Additional test teardown methods may change the page before the extension observes it, so define whether your framework wants the state at assertion failure or after user teardown, and register callbacks accordingly.
Never let screenshot failure suppress browser cleanup. Remote sessions can disappear, alert handling can block, and the page may no longer be reachable. Artifact capture is best effort; quit() is still required. Selenium's guidance to use a fresh browser per test keeps this release boundary simple and limits state leakage.
Bound Parallelism Outside the Driver Factory
A driver factory should create one requested session; it should not own a static semaphore, thread pool, or global driver cache. Set JUnit's parallel execution limit from environment capacity. For Grid, the relevant limit is the matching browser slots and acceptable queue behavior, not merely the machine's processor count.
The Selenium recommendation to avoid sharing state applies to page objects, accounts, download folders, and artifact paths as well as drivers. Constructor-inject the resolved driver into page objects. Generate per-test directories and test data identifiers. A test-scoped driver cannot compensate for two invocations editing the same account.
Do not use ThreadLocal as a substitute for the JUnit context in this extension design. JUnit may schedule lifecycle callbacks in ways that make implicit thread ownership harder to reason about, while ExtensionContext.Store directly names the test invocation. If separate asynchronous work uses the driver, keep commands serialized and complete that work before teardown.
Diagnose Failures at Their Owning Layer
A ParameterResolutionException means lifecycle ordering or registration is wrong; it is not a browser failure. A SessionNotCreatedException during beforeEach belongs to factory configuration, driver availability, or Grid admission. An invalid-session error during the test often points to premature cleanup or shared ownership. A quit failure belongs to infrastructure cleanup and should retain the session ID and original result.
If the wrong browser starts, inspect request construction and the enum branch before debugging capabilities. If parallel tests see each other's windows, search for static drivers, root-context stores, singleton page objects, and reused test data. If only failure screenshots are missing, verify callback order and make capture errors visible without replacing the primary assertion failure.
Weigh Abstraction Against Matrix Complexity
Typed factories add classes and explicit dispatch, but they make unsupported combinations visible in code review and compiler errors. A raw capability map is shorter for a tiny suite, yet its string keys become a hidden public API as the matrix grows. Use the typed boundary when browser-specific policy is meaningful; do not create generic layers that only rename constructors.
A fresh driver per test costs startup time and Grid capacity. Reuse can be justified for a separately classified journey or expensive setup, but then state reset, failure blast radius, and serialization must be explicit. Keep the default lifecycle isolated and make reuse the exceptional path.
Review the Framework With a Concrete Checklist
- Browser intent is represented by immutable typed data, not arbitrary maps in tests.
- Each concrete factory accepts and creates exactly one browser-options type.
- Runtime dispatch has no unchecked cast between browser option classes.
- The JUnit extension has no mutable driver instance field.
- Each invocation stores one driver in its own
ExtensionContext.Store. - Failure artifacts are attempted while the session is alive and cannot skip cleanup.
afterEachremoves and quits the exact driver created bybeforeEach.- JUnit concurrency is bounded to matching environment capacity.
- Page objects, accounts, downloads, and artifacts remain test-scoped.
Close the Ownership Loop
The framework is correct when a browser request follows one typed construction path and produces one driver owned by one JUnit invocation. Make that path observable, keep the extension stateless, and guarantee release after every outcome. With those boundaries in place, adding browsers or parallel workers expands the matrix without turning lifecycle behavior into guesswork.
// 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
Why should a Selenium Java factory be generic over browser options?
A generic factory keeps ChromeOptions with Chrome construction and FirefoxOptions with Firefox construction. It prevents loosely typed maps from spreading browser-specific settings across tests.
Can a JUnit extension store WebDriver in an instance field?
Not safely for parallel execution. Extension instances may serve multiple test invocations, so mutable driver fields can cross lifecycles. Store each driver in the invocation's ExtensionContext.Store.
Which JUnit callback should capture failure screenshots?
Capture them after test execution, while the driver is still alive, then quit in afterEach. Cleanup should remain guarded because screenshot capture can itself fail.
Should a JUnit ParameterResolver create the driver lazily?
Usually no. Create it in beforeEach so setup failure has one visible lifecycle and resolve only the already-owned driver. Lazy creation makes cleanup and error attribution more complex.
How does a fresh driver per test affect parallel Selenium capacity?
It improves isolation but increases simultaneous session creation. Bound JUnit parallelism to local or Grid capacity and treat session admission as a planned resource limit.
RELATED GUIDES
Continue the learning route
GUIDE 01
ThreadGuard and ThreadLocal Driver Ownership for Parallel Java Tests
Build parallel Selenium Java tests with ThreadLocal driver ownership, ThreadGuard misuse detection, deterministic cleanup, and isolated test state.
GUIDE 02
Designing a Cross-Browser Options Matrix in Selenium WebDriver 4
Design a Selenium browser options matrix that separates W3C capabilities, vendor settings, environments, and verified cross-browser expectations.
GUIDE 03
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.
GUIDE 04
Instrument Selenium Commands with WebDriver Listeners
Instrument Selenium commands with WebDriverListener, structured timing events, failure screenshots, safe redaction, and useful execution timelines.