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.

By The Testing AcademyUpdated July 11, 202610 min read
All field guides
In this guide11 sections
  1. Make the Lifecycle Contract Explicit
  2. Model Browser Intent Without Capability Maps
  3. Keep Each Options Type Attached to Its Factory
  4. Dispatch Without Losing the Typed Construction Path
  5. Store the Driver in the Test Invocation
  6. Align Failure Capture and Cleanup Order
  7. Bound Parallelism Outside the Driver Factory
  8. Diagnose Failures at Their Owning Layer
  9. Weigh Abstraction Against Matrix Complexity
  10. Review the Framework With a Concrete Checklist
  11. 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.

  1. 01 / junit extension

    JUnit Extension

    beforeEach begins a single test invocation without mutable extension fields.

  2. 02 / browser request

    Typed Browser Request

    An enum and immutable profile select the matching options factory.

  3. 03 / driver factory

    Driver Factory

    The generic factory builds only its concrete browser options and driver.

  4. 04 / test driver

    Test-Scoped WebDriver

    ExtensionContext.Store owns the session for one invocation.

  5. 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.

Java
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.

Java
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.

Java
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.

Java
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.
  • afterEach removes and quits the exact driver created by beforeEach.
  • 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.

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 11, 2026 / Reviewed July 11, 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

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.