PRACTICAL GUIDE / composed JUnit annotation Selenium extensions

Stop Selenium lifecycle drift with one JUnit annotation

Learn to package JUnit discovery, Selenium session setup, driver injection, failure evidence, and reliable teardown behind one clear test marker.

By The Testing AcademyUpdated August 4, 202624 min read
All field guides
In this guide6 sections
  1. Why a composed annotation changes more than spelling
  2. Build one annotation around one session owner
  3. Prove the lifecycle before blaming Selenium
  4. Work through failures that look alike
  5. Roll the marker out without losing tests
  6. Accept the costs and know when not to compose

What you will learn

  • Why a composed annotation changes more than spelling
  • Build one annotation around one session owner
  • Prove the lifecycle before blaming Selenium
  • Work through failures that look alike

One browser opens, the test fails before its first click, and teardown reports that the session is already gone. Another class using the same helper passes, so the WebDriver code looks innocent. The difference is often hidden in JUnit metadata: one method assembled its test marker, extension registration, and lifecycle policy from several annotations.

Copying @Test, @Tag, @ExtendWith, and a home-grown setup annotation onto every browser method creates more than visual noise. It lets two tests that appear equivalent run under different contracts. A composed annotation is useful when it makes that contract explicit, but only if the extension behind it has one session owner, observable lifecycle boundaries, and a design that survives parallel execution.

Why a composed annotation changes more than spelling

JUnit Jupiter treats its annotations as meta-annotations. A custom annotation can therefore carry the semantics of @Test, @Tag, and @ExtendWith. When JUnit discovers a method bearing that custom marker, it sees the test role and the registered extensions through the marker. This is framework behavior, not a Java macro and not source-code generation. The JUnit annotation guide describes that composition model directly.

Two Java details decide whether the model works. The custom annotation needs RetentionPolicy.RUNTIME because the runner must inspect it at runtime. It also needs a target that matches its intended use. A method-only test marker should target ElementType.METHOD; allowing fields, types, and parameters without a reason makes the contract harder to explain. @Documented is optional for execution, but it helps generated API documentation show why a method is special.

The most important design choice is whether the marker replaces @Test or merely adds Selenium behavior. A marker that carries @Test is a complete test annotation. A developer writes @SeleniumTest, not @Test @SeleniumTest. A marker that carries only @ExtendWith can sit beside @Test, @ParameterizedTest, or another test template. Both designs are valid, but they answer different questions. This article uses the complete marker because it gives ordinary browser tests one unmistakable entry point.

Composition does not copy mutable state into the annotation. Java annotation values are metadata. The browser still has to live somewhere during execution, and that location determines whether the suite behaves under concurrency. A static WebDriver field belongs to the process or class loader, not to one test invocation. If two tests run at once, the second assignment can replace the first driver's reference. Either test can then close the session currently held in that field. The resulting NoSuchSessionException points at Selenium, while the ownership bug sits in the extension.

ExtensionContext.Store is the better boundary. JUnit gives callbacks an ExtensionContext, and a store is bound to a context lifecycle. A namespace separates one extension's keys from unrelated extensions. Storing a driver in the method or invocation context means two concurrent invocations use different stores. The extension below builds its namespace from the extension type and JUnit unique ID, so a lookup for one invocation cannot fall through to a value kept under an ancestor's namespace. JUnit's state management guide documents the store hierarchy, namespaces, and resource cleanup behavior.

Registration rules matter when a suite is halfway through a migration. JUnit can register extensions declaratively with @ExtendWith, programmatically with @RegisterExtension, or automatically through ServiceLoader. A custom composed annotation is one declarative registration site. According to the extension registration guide, the same extension implementation is registered only once for a context and its parent contexts. Putting @ExtendWith(SeleniumExtension.class) on a class and reaching the same implementation through @SeleniumTest does not, by itself, create two sessions.

That duplicate rule is easy to overgeneralize. It compares extension implementations, not business responsibilities. LocalChromeExtension and GridDriverExtension are different implementations even if both create a WebDriver. Manual @BeforeEach code is not an extension registration at all. Those combinations can still open two sessions. During migration, search for every creation site rather than assuming JUnit will recognize conceptual duplicates.

Ordering is another separate contract. Declarative extensions on classes, methods, and parameters follow their declaration order. Lifecycle callbacks also have wrapping behavior: if extension A is registered before extension B, A's before callback runs before B's corresponding before callback, while A's after callback runs after B's corresponding after callback. That is useful when an outer session owner must remain alive while an inner evidence collector finishes. Field-based registrations have additional ordering rules and can use @Order; do not infer field order from names. JUnit's lifecycle callback documentation is the reference when two extensions depend on one another.

Build one annotation around one session owner

Start with a marker whose name says what the method receives. It represents a normal Jupiter test, adds a browser tag for filtering, and installs two extensions. SeleniumExtension owns the session. FailureEvidenceExtension can observe a test-method failure while that session is still active. Each public type belongs in the named file shown in the comment.

Java
// SeleniumTest.java
package example.selenium;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Test
@Tag("browser")
@ExtendWith({SeleniumExtension.class, FailureEvidenceExtension.class})
public @interface SeleniumTest {
}

The retention and @Test lines affect discovery. @ExtendWith affects execution. The tag affects selection by a compatible launcher or build tool, but it does not start a browser. Keeping those jobs distinct makes a failure easier to classify. If a method vanishes from the test report, inspect discovery metadata. If the method appears but browser creation fails, inspect the extension and the Selenium exception.

The session owner implements three extension points. BeforeEachCallback creates one Chrome session. ParameterResolver supplies that session only to parameters whose declared type is exactly WebDriver. AfterEachCallback calls quit() and removes the reference. Selenium's driver session documentation distinguishes quit() from close() and recommends quit() to end a session. Closing one window is not equivalent cleanup for a session that may own several windows.

Java
// SeleniumExtension.java
package example.selenium;

import java.util.Map;
import org.junit.jupiter.api.extension.AfterEachCallback;
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.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;

public final class SeleniumExtension
    implements BeforeEachCallback, ParameterResolver, AfterEachCallback {

  private static final String DRIVER_KEY = "driver";

  @Override
  public void beforeEach(ExtensionContext context) {
    ChromeOptions options = new ChromeOptions();
    if (Boolean.getBoolean("selenium.headless")) {
      options.addArguments("--headless=new");
    }

    WebDriver driver = new ChromeDriver(options);
    store(context).put(DRIVER_KEY, driver);
    publishSessionEvent(context, "created", driver);
  }

  @Override
  public boolean supportsParameter(
      ParameterContext parameterContext, ExtensionContext context) {
    return parameterContext.getParameter().getType().equals(WebDriver.class);
  }

  @Override
  public WebDriver resolveParameter(
      ParameterContext parameterContext, ExtensionContext context) {
    WebDriver driver = findDriver(context);
    if (driver == null) {
      throw new ParameterResolutionException(
          "No WebDriver exists for " + context.getUniqueId());
    }
    return driver;
  }

  @Override
  public void afterEach(ExtensionContext context) {
    WebDriver driver = findDriver(context);
    if (driver == null) {
      return;
    }

    try {
      publishSessionEvent(context, "quitting", driver);
      driver.quit();
    } finally {
      store(context).remove(DRIVER_KEY);
    }
  }

  static WebDriver findDriver(ExtensionContext context) {
    return store(context).get(DRIVER_KEY, WebDriver.class);
  }

  private static ExtensionContext.Store store(ExtensionContext context) {
    ExtensionContext.Namespace namespace = ExtensionContext.Namespace.create(
        SeleniumExtension.class, context.getUniqueId());
    return context.getStore(namespace);
  }

  static String sessionId(WebDriver driver) {
    if (driver instanceof RemoteWebDriver) {
      return String.valueOf(((RemoteWebDriver) driver).getSessionId());
    }
    return "not-exposed";
  }

  private static void publishSessionEvent(
      ExtensionContext context, String event, WebDriver driver) {
    try {
      context.publishReportEntry(Map.of(
          "webdriver.event", event,
          "webdriver.sessionId", sessionId(driver)));
    } catch (RuntimeException reportError) {
      System.err.printf(
          "webdriver-report id=%s event=%s error=%s%n",
          context.getUniqueId(), event, reportError.getClass().getName());
    }
  }
}

There is deliberately no mutable extension field. JUnit may reuse an extension instance, and an instance field would merely move the race away from a static field. The store provides the ownership boundary. It cannot detect a browser hidden in another extension's namespace or in manual setup, so code review still has to identify all session creators.

The cleanup is explicit rather than accidental. Newer JUnit versions can close stored AutoCloseable values when their store closes, but a raw WebDriver does not implement AutoCloseable. Wrapping it could work, yet an explicit AfterEachCallback makes the moment of quit() visible and keeps the example compatible with established Jupiter extension APIs. Notice that manual removal happens after the quit attempt. The Store contract says a manually removed closeable value will not receive automatic closing, so code must never remove a wrapper and expect JUnit to close it later.

The Chrome headless switch is controlled by a JVM system property, not guessed from an environment name. Local runs remain headed unless they pass -Dselenium.headless=true. The Selenium Chrome documentation shows ChromeOptions and the --headless=new argument. A team supporting Firefox or a remote Grid should inject a tested driver factory into a programmatic extension or create a separate configuration annotation; changing this extension by reading a dozen unrelated environment variables would hide its contract.

A test using the marker needs no field and no teardown method. Its assertion still checks product behavior. Driver injection is plumbing, not the oracle.

Java
// CheckoutTest.java
package example.selenium;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;

final class CheckoutTest {

  @SeleniumTest
  void submitsTheWebForm(WebDriver driver) {
    driver.get("https://www.selenium.dev/selenium/web/web-form.html");
    driver.findElement(By.name("my-text")).sendKeys("JUnit extension");
    driver.findElement(By.cssSelector("button")).click();

    assertEquals("Received!", driver.findElement(By.id("message")).getText());
  }
}

This public demo page keeps the snippet runnable, but a production CI suite should test an application the team controls. An external demonstration site adds DNS, network, and content ownership to the failure surface. Replace the URL with a versioned test environment and keep the same lifecycle boundary. If the page response changes, the assertion above fails for a real reason; it does not merely assert that the driver object exists.

Prove the lifecycle before blaming Selenium

An extension failure can happen during discovery, browser creation, parameter resolution, the test method, user teardown, or extension teardown. A screenshot alone cannot identify all six boundaries. Add a small probe temporarily and log the JUnit unique ID, thread, session ID, phase, and current exception type. Those fields connect an invocation to a browser without relying on display names, which are intended for people and need not be unique.

BeforeTestExecutionCallback runs immediately before the test method. AfterTestExecutionCallback runs immediately after that method and before user @AfterEach methods. The probe below therefore answers a narrow question: did the invocation reach the test body with the expected stored session? It does not claim to observe browser creation before BeforeEachCallback finishes or failures raised later by teardown.

Java
// LifecycleProbe.java
package example.selenium;

import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.openqa.selenium.WebDriver;

public final class LifecycleProbe
    implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

  @Override
  public void beforeTestExecution(ExtensionContext context) {
    write("before-test", context);
  }

  @Override
  public void afterTestExecution(ExtensionContext context) {
    write("after-test", context);
  }

  private static void write(String phase, ExtensionContext context) {
    WebDriver driver = SeleniumExtension.findDriver(context);
    String session = driver == null
        ? "missing"
        : SeleniumExtension.sessionId(driver);
    String failure = context.getExecutionException()
        .map(error -> error.getClass().getName())
        .orElse("none");

    System.err.printf(
        "junit-extension phase=%s id=%s thread=%s session=%s failure=%s%n",
        phase,
        context.getUniqueId(),
        Thread.currentThread().getName(),
        session,
        failure);
  }
}

Register the probe temporarily with @ExtendWith(LifecycleProbe.class) on the suspect method or class. Do not add it to the permanent marker until the team decides that the log volume and session identifiers are acceptable. A before-test line with a non-missing session proves that JUnit completed the session owner's beforeEach callback and reached the boundary immediately before the test method. An after-test line naming AssertionFailedError shows which execution exception JUnit exposed at that boundary, but it does not by itself place the failure in the test body. A test-level extension or surrounding lifecycle step can also supply that exception. The exact unique ID and thread name come from the active runner, so do not hard-code either in an assertion.

Absence is evidence only when interpreted with the runner report. If the method is not present in the report at all, investigate discovery. If the method is present and its stack trace ends in ChromeDriver construction with SessionNotCreatedException or another Selenium startup exception, execution stopped inside beforeEach, before the probe's first phase. If the probe reports session=missing and JUnit later reports a parameter-resolution failure, the test was discovered but the session owner did not place a driver where the resolver expected it. These paths can all produce a test with no first click, but their fixes are unrelated.

Failure screenshots need another precise boundary. A collector using AfterTestExecutionCallback can inspect the execution exception visible before @AfterEach and the session owner's AfterEachCallback run. The code below captures whenever getExecutionException() is present at that point. That exception can come from parameter resolution or an invocation extension even when the test body did not run. The collector catches its own evidence errors so a screenshot problem does not replace the primary failure in the report.

Java
// FailureEvidenceExtension.java
package example.selenium;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebDriverException;

public final class FailureEvidenceExtension
    implements AfterTestExecutionCallback {

  @Override
  public void afterTestExecution(ExtensionContext context) {
    if (context.getExecutionException().isEmpty()) {
      return;
    }

    WebDriver driver = SeleniumExtension.findDriver(context);
    if (!(driver instanceof TakesScreenshot)) {
      System.err.printf(
          "failure-evidence id=%s screenshot=unavailable%n",
          context.getUniqueId());
      return;
    }

    try {
      Path directory = Path.of("target", "failure-evidence");
      Files.createDirectories(directory);
      String prefix = "failure-"
          + Integer.toHexString(context.getUniqueId().hashCode()) + "-";
      Path file = Files.createTempFile(directory, prefix, ".png");
      byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
      Files.write(file, png);
      System.err.printf(
          "failure-evidence id=%s screenshot=%s%n",
          context.getUniqueId(), file.toAbsolutePath());
    } catch (IOException | WebDriverException evidenceError) {
      System.err.printf(
          "failure-evidence id=%s screenshot-error=%s%n",
          context.getUniqueId(), evidenceError.getClass().getName());
    }
  }
}

This collector has known blind spots. It cannot photograph a browser that never started. It does not capture a failure raised by a user's @AfterEach, because its callback already ran. It also cannot promise useful pixels after a crash or disconnected session. Those are not reasons to move cleanup later with an arbitrary sleep. They are reasons to collect ChromeDriver logs, Grid records, or container diagnostics at the component that still owns them.

Published report entries from ExtensionContext.publishReportEntry are another correlation channel. JUnit sends them to its reporting infrastructure, but the selected build tool decides how they appear. Confirm their location in your actual Surefire, Gradle, IDE, or CI output before making them an incident-response dependency. System.err is less structured but often easier to find during a short diagnostic run. Use both only if their different consumers justify the noise.

Work through failures that look alike

The quickest way to trust a lifecycle abstraction is to force each boundary to fail once. Do this in a small framework test module, not by breaking a product test on the main branch. Each case below begins with the same surface report, no useful browser interaction occurred, yet each leaves different evidence.

The marker does not discover a test. A developer creates @SeleniumTest with @ExtendWith and @Tag, then forgets @Test. The Java code compiles because every annotation type is legal, but JUnit has no test semantic to discover on the method. No SeleniumExtension.beforeEach callback runs. No parameter resolution occurs. Depending on the build configuration, the job may report no selected tests, or the class may simply contribute no test case to the XML report.

Check the report before reading browser logs. If a neighboring ordinary @Test in the same class appears and the marked method does not, the runner is working. Inspect the custom annotation's compiled source or bytecode for @Test and runtime retention. A missing Chrome binary cannot erase a method from discovery, because browser construction happens later. Adding WebDriver waits, driver flags, or a retry would address a phase the run never reached.

A related near-miss keeps @Test but changes retention to CLASS. The annotation remains in the class file, which can fool a source review, yet it is not available for runtime reflection. JUnit cannot apply its meta-annotations from a marker it cannot observe. Keep a framework-level smoke test that uses the marker and assert a real page outcome. If a future edit breaks discovery, the smoke test itself may disappear, so also compare the expected test inventory in CI during annotation changes.

The method is discovered but WebDriver cannot be resolved. Another developer leaves @Test on the marker but removes @ExtendWith, perhaps while splitting a large extension. The report now contains the method because discovery succeeds. When JUnit prepares to invoke a method requiring WebDriver, no registered ParameterResolver claims that parameter. The failure is a JUnit parameter-resolution error, and its stack is rooted in invocation infrastructure rather than new ChromeDriver().

The probe makes this case clearer if it is registered independently. It may show session=missing, followed by the resolution failure, because no Selenium callback populated the store. In contrast, an incompatible browser and driver normally fail inside the session owner's beforeEach; the exception chain contains Selenium startup classes, and the test body is never approached. Both cases lack a screenshot, but one needs corrected JUnit metadata while the other needs a valid browser runtime.

Do not weaken supportsParameter to return true for every type as a quick fix. That would make this extension compete for strings, page objects, test data, and parameters owned by other extensions. Exact WebDriver matching is intentionally conservative. If a suite wants ChromeDriver or a custom wrapper, specify and test that contract separately. Broad claims create ambiguous resolver errors that are harder to diagnose than a rejected parameter.

The browser is alive for the assertion but gone for evidence. Consider two extensions that both implement AfterEachCallback: one quits the driver and one takes a screenshot. With lifecycle wrapping, registration order determines which after callback runs first. If the evidence extension is registered first and the session owner second, the session owner's after callback runs first. The evidence callback then receives a driver reference whose remote session has already ended and may report NoSuchSessionException.

The implementation in this article avoids that particular dependency by taking failure screenshots in AfterTestExecutionCallback and quitting in AfterEachCallback. JUnit invokes the first boundary after the test method but before user teardown, then reaches extension teardown later. That timing is confirmed by the callback contract, not by annotation name or intuition. If evidence must include changes made by @AfterEach, both components may need after-each callbacks with a deliberately tested registration order. The cost is tighter coupling between extensions.

Distinguish this ordering bug from a browser crash. The lifecycle log for an ordering problem shows a valid session around test execution, then a successful quit event before the evidence error. A browser crash loses the session before the owner deliberately calls quit(), often while the test command is in flight. ChromeDriver or Grid diagnostics matter in the latter case. Reordering callbacks cannot repair a renderer crash, and increasing a timeout cannot resurrect a deleted session.

A duplicate-looking registration opens two browsers. JUnit's duplicate suppression sometimes sends teams in the wrong direction. If both the class and marker register the exact SeleniumExtension implementation, JUnit ignores the duplicate for that context hierarchy. Seeing two windows means the cause is elsewhere. Look for a second extension class, a @RegisterExtension instance of another type, a base-class @BeforeEach, or a factory called by the test itself.

Session IDs settle this faster than window counting. Record an event at every creation site using the same JUnit unique ID. Two distinct session IDs tied to one invocation prove that two sessions were created. A second visible window can also belong to one session because WebDriver supports multiple windows, so window count alone is not a session count. Conversely, two processes are not automatically two successful sessions if one driver failed during startup. Treat the session ID returned by the active driver as the correlation value and keep the full startup exception for failed attempts.

Parallel execution exposes a static driver. This near-miss often appears only after the annotation rollout because the new marker makes more tests eligible for the same CI group. Test A creates session A and assigns it to a static field. Test B creates session B and overwrites the field. Test A's teardown reads the field and quits session B. Test B then fails on its next command, while session A remains leaked. The failure is reported against Test B even though Test A performed the damaging cleanup.

Compare JUnit unique IDs, thread names, and session IDs across create, use, and quit events. The broken pattern shows one test quitting a session created for another ID. A slow application can produce timeouts, but it does not make ownership identifiers cross. Replacing the static field with a ThreadLocal reduces one collision shape, yet it still ties cleanup to worker threads and becomes fragile when execution hops boundaries or pools reuse threads. The context store expresses the framework lifecycle directly and does not require every caller to remember remove() on a thread-local value.

Roll the marker out without losing tests

Changing annotations across a mature suite changes discovery and infrastructure at the same time. Treat it as a migration, not a search-and-replace. First inventory the existing ways a browser starts and stops. Search for new ChromeDriver, new RemoteWebDriver, driver factories, @BeforeEach, @AfterEach, @RegisterExtension, base classes, and current @ExtendWith declarations. The goal is a map of ownership, not a count of matching strings. A factory call hidden behind browser() matters just as much as a constructor.

Choose one small class with ordinary @Test methods and no parameterized invocations. Convert one method to @SeleniumTest, replace its driver field with a WebDriver parameter, and remove manual cleanup only for that method's old owner. Run it both headed and headless. Confirm one create event, one session ID during the assertion, one quit event, and no surviving browser process attributable to the test. Then force the product assertion to fail locally and verify that the original assertion remains primary while the screenshot path is reported.

Keep an inventory of discovered test identities before the bulk edit. A raw total is a useful alarm but a weak oracle: one removed test and one accidental new test leave the same number. Compare class and method names from the runner reports for the affected package. Review intentional renames separately. This catches the most dangerous annotation migration failure, a test silently disappearing while the build stays green.

Next, convert classes that already have one driver per method. Leave class-scoped reuse, test templates, dynamic tests, and parameterized tests for separate decisions. Remove old setup and teardown in the same review as each conversion, because an overlap period with two owners is harder to observe than either design alone. If a gradual merge requires both paths to coexist, add an explicit guard at the legacy factory that fails when the composed marker is present rather than letting both sessions start.

CI should expose its runtime choices. The following job follows GitHub's Java with Maven workflow, prints Java and Chrome versions, enables the extension's headless branch with a JVM property, runs the concrete smoke class, and uploads screenshots only after failure. It assumes the project's existing Maven configuration already supplies compatible JUnit Jupiter and Selenium Java dependencies. Selenium Manager is used by current Selenium bindings when a driver has not otherwise been supplied, but the browser itself still has to be available.

YAML
name: selenium-extension-smoke

on:
  pull_request:
  workflow_dispatch:

jobs:
  chrome:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
      - name: Record runtime versions
        run: |
          java -version
          google-chrome --version
      - name: Run the composed-annotation smoke test
        run: >-
          mvn --batch-mode --no-transfer-progress
          -Dselenium.headless=true
          -Dtest=CheckoutTest
          test
      - name: Upload failure evidence
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: selenium-failure-evidence
          path: target/failure-evidence/
          if-no-files-found: ignore

Pin dependency versions in the build rather than adding version resolution to the annotation. A browser extension should not download arbitrary Java libraries or alter the test runner. For driver resolution, capture Selenium Manager output when startup is the suspected boundary and retain the selected browser and driver versions with the job. Do not infer a version mismatch merely from SessionNotCreatedException; read the exception cause and manager or driver logs because startup can also fail on permissions, binary paths, profile locks, or unavailable display support.

Run the smoke test on every change to the marker or either extension. Broader browser coverage can remain in scheduled or pre-release jobs if its cost is too high for every pull request. The smoke test's purpose is not to certify the whole product. It protects discovery, injection, a real WebDriver command, a real assertion, evidence capture on failure, and session teardown as one framework contract.

During the final migration, remove unused base-class fields and helper methods only after all callers are gone. A stale static field is dangerous even if most tests use parameters, because one old subclass can still mutate it. Keep temporary lifecycle logging until parallel runs show that every create and quit pair shares a JUnit unique ID and session ID. Then reduce the logging to the fields your incident workflow genuinely consumes. Permanent debug noise that nobody reads increases storage cost and makes the useful exception harder to spot.

Accept the costs and know when not to compose

A composed marker improves consistency by reducing the choices available at each test method. That constraint is also its cost. The annotation in this article always creates Chrome, always uses a per-method session, always tags the test as browser, and always installs the same evidence collector. A test cannot ask for Firefox or a remote endpoint through a constructor argument because declarative extension registration creates the extension for JUnit. If configuration varies substantially per class, @RegisterExtension with a tested builder may be clearer than encoding a miniature configuration language in annotation attributes.

Per-method sessions cost startup time. They also provide strong isolation and simple cleanup ownership. Reusing one driver per class can reduce startup overhead, but cookies, local storage, open windows, permissions, downloads, and application state can leak across methods. A class-scoped design needs a different context, a reset protocol with meaningful assertions, and a failure policy for a dead shared session. Do not change the store key or callback from method scope to class scope and call it an optimization. That is a different testing contract.

Failure screenshots consume disk and may contain personal or confidential information displayed by the application. The collector above writes only after test-method failures and uses unique temporary filenames, but it does not redact the page. Teams testing production-like data need retention limits, access controls, and a decision about whether screenshots are allowed at all. If privacy rules prohibit pixels, record the page URL only when safe, the session identifier, the failure type, and server-side correlation IDs approved for logs.

Report entries and lifecycle probes add diagnostic value at the price of log volume. Parallel suites can produce many interleaved lines. Unique IDs and session IDs make the stream searchable, but they do not make it small. Keep the permanent events to creation, failure, and quit, or route structured entries to a reporter that preserves fields. Do not print capabilities wholesale because they can contain remote endpoints or vendor-specific values that should not leave the CI boundary.

Avoid the complete @SeleniumTest marker when the method is a parameterized test, repeated test, test template, or dynamic-test factory. Those are different JUnit test kinds. Stacking @Test from the marker with @ParameterizedTest does not express a clean dual identity. Create a separate composed marker for the specific test kind, or move only @ExtendWith(SeleniumExtension.class) to a class-level annotation that does not carry @Test. That keeps discovery semantics visible.

Do not use this pattern to hide a large service locator. Injecting WebDriver is narrow and recognizable. Injecting page objects, API clients, users, database handles, feature flags, and arbitrary strings from one resolver makes a test's inputs invisible. Prefer explicit constructors, fixtures, or focused resolvers with non-overlapping parameter types. A resolver conflict is a framework design problem, not a reason to make every resolver claim fewer errors at runtime.

Skip composition when a suite has only a handful of stable browser tests and a plain class-level @ExtendWith already communicates the lifecycle. A custom annotation earns its maintenance burden when it removes repeated metadata, prevents lifecycle drift, or creates a useful selection boundary. It is not automatically cleaner because it uses fewer characters at the call site. Readers must be able to find its definition and understand what it installs.

Keep manual control for tests that deliberately manage several simultaneous sessions. A collaboration scenario with two users may need two drivers with separate identities and teardown rules. The single-parameter extension above correctly rejects that use case instead of guessing which browser a WebDriver parameter means. Build a named fixture such as a two-party session object with explicit ownership, or keep the setup in the test while the workflow is still exceptional.

Finally, do not use annotation composition to conceal an unstable environment. If Chrome cannot start reliably on a worker, wrapping the constructor in a marker changes no behavior. If an application is slow, lifecycle abstraction does not prove readiness. If tests disappear because the build filters the browser tag, the tag selection configuration is the boundary to inspect. The marker should make those decisions easier to locate. Once it begins swallowing exceptions, retrying creation, selecting browsers implicitly, or sharing sessions, it has stopped being a small JUnit extension and become an unreviewed test platform.

// 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 25, 2026 / Reviewed August 4, 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
    Official docs.junit.org reference

    docs.junit.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official docs.junit.org reference

    docs.junit.org

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official docs.junit.org reference

    docs.junit.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official docs.junit.org reference

    docs.junit.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why does JUnit not discover my custom Selenium test annotation?

Check that the annotation has runtime retention and is meta-annotated with a JUnit test annotation such as `@Test`. If discovery still finds no test, inspect the compiled annotation and the runner's test report before debugging WebDriver.

Can I register the same JUnit extension on both a class and a composed annotation?

JUnit ignores a duplicate registration of the same extension implementation for a context and its parent contexts. That does not protect you from two different extension classes that both create a browser, or from an extension combined with manual setup.

Should a Selenium JUnit extension keep WebDriver in a static field?

No. A mutable static driver lets concurrent tests replace or quit one another's sessions. Keep each session in the current `ExtensionContext.Store`, and make one callback responsible for calling `quit()`.

Why does failure screenshot capture report an invalid Selenium session?

That usually means teardown ran before the evidence callback. Capture immediately after the test method with `AfterTestExecutionCallback`, or verify the wrapping order if both components use `AfterEachCallback`.

Can a composed Selenium annotation also be a parameterized test?

A marker meta-annotated with `@Test` defines a regular Jupiter test, not a parameterized one. Create a separate marker based on `@ParameterizedTest`, or keep extension registration at the class level when several test kinds need the same driver lifecycle.