PRACTICAL GUIDE / Java sealed interface Selenium browser factory

Make unsupported browsers fail at compile time

Use a sealed Java hierarchy to model supported Selenium browsers, keep option translation exhaustive, and catch missing factory branches before CI.

By The Testing AcademyUpdated August 4, 202622 min read
All field guides
In this guide6 sections
  1. Let the compiler expose an unfinished browser change
  2. Model legal browser intent before touching Selenium
  3. Translate each subtype into fresh Selenium options
  4. Prove the model and inspect the runtime boundary
  5. Migrate without creating a second universal factory
  6. Know when a closed hierarchy is the wrong contract

What you will learn

  • Let the compiler expose an unfinished browser change
  • Model legal browser intent before touching Selenium
  • Translate each subtype into fresh Selenium options
  • Prove the model and inspect the runtime boundary

A new Edge profile lands in configuration, but the factory's default branch quietly starts Chrome. The test runs and reports a pass against the wrong browser. The missing branch should have stopped compilation, not changed coverage in silence.

Sealed types are useful here because the browser choices belong to one codebase and should change through review. They let the model say which variants exist, while an exhaustive switch makes the translation decision visible. They do not make Selenium configuration valid by themselves, and they do not turn runtime infrastructure into a compile-time problem.

Let the compiler expose an unfinished browser change

Many browser factories begin with a string and a forgiving fallback. The configuration says chrome, firefox, or perhaps something misspelled. A switch maps known strings to drivers, and default returns Chrome because that keeps local development convenient. That fallback is dangerous in a coverage system. A typo does not fail fast; it runs a different test than the one requested.

An enum improves the browser name, but configuration often grows variant-specific data. Chrome may allow a binary path or Chromium arguments. Firefox may carry preferences that have no meaning to Chrome. A single class with every field optional permits nonsense such as Firefox plus Chrome-only arguments. Validation becomes a web of conditions, and each caller can create another illegal combination.

A sealed interface makes the closed set explicit. Its permitted implementations can expose different components. A pattern switch over the sealed selector can be exhaustive without a default arm. When a developer adds a new permitted subtype, every exhaustive switch that has not handled it becomes a compiler problem. That is exactly the pressure a browser factory needs: adding a name is not complete until translation, diagnostics, and tests make a conscious decision.

The guarantee is narrower than teams sometimes assume. Sealing controls which classes can implement the interface. It does not prove that --headless=new is supported by the installed Chrome. It does not prove a Firefox preference has the desired effect. It does not guarantee Grid has a slot or that the browser binary exists. Those remain runtime contracts at the Selenium and infrastructure boundaries.

Package and module placement matters to the Java language rules. The permitted classes must be in the allowed relationship with the sealed type, and each direct permitted subtype must declare whether it is final, sealed, or non-sealed unless its form already supplies finality, as records do. Keep the hierarchy in one small package. If browser plugins need to arrive from unrelated modules, a closed hierarchy conflicts with the extension model rather than helping it.

The absence of default is important. A default arm tells the compiler that every future subtype has already been handled, even when the arm throws an “unsupported” exception. That shifts discovery from compilation to whichever test eventually creates the new type. For a code-owned closed set, let compilation point at the unfinished switch.

This approach also prevents the factory from guessing. A ChromeSpec cannot accidentally fall into a branch chosen through lowercased strings. A FirefoxSpec reaches the Firefox arm because of its type. External strings still need parsing, but parsing becomes a separate boundary that either produces a permitted value or rejects the input.

Keep the sealed model free of live WebDriver objects. It should carry intent that is cheap to construct, validate, compare, and log safely. Selenium's mutable options belong in the translation layer, not in the configuration type.

This model uses records as final permitted implementations. The compact constructors validate dimensions and make defensive copies of collections. Chrome arguments and Firefox preferences remain separate, so a caller cannot attach one browser's configuration to the other by mistake. Certificate policy is declared on the interface because it is a standard WebDriver capability that both browsers honor, and because leaving it unstated does not leave it unset.

Java
package example.browser;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public sealed interface BrowserSpec permits BrowserSpec.Chrome, BrowserSpec.Firefox {
  boolean headless();
  boolean acceptInsecureCerts();
  Viewport viewport();

  record Viewport(int width, int height) {
    public Viewport {
      if (width < 320 || height < 240) {
        throw new IllegalArgumentException("Viewport is too small for this suite");
      }
    }
  }

  record Chrome(
      boolean headless,
      boolean acceptInsecureCerts,
      Viewport viewport,
      Optional<Path> binary,
      List<String> extraArguments) implements BrowserSpec {
    public Chrome {
      Objects.requireNonNull(viewport, "viewport");
      binary = Objects.requireNonNull(binary, "binary");
      extraArguments = List.copyOf(extraArguments);
      if (extraArguments.stream().anyMatch(String::isBlank)) {
        throw new IllegalArgumentException("Chrome arguments must not be blank");
      }
    }
  }

  record Firefox(
      boolean headless,
      boolean acceptInsecureCerts,
      Viewport viewport,
      Optional<Path> binary,
      Map<String, Object> preferences) implements BrowserSpec {
    public Firefox {
      Objects.requireNonNull(viewport, "viewport");
      binary = Objects.requireNonNull(binary, "binary");
      preferences = Map.copyOf(preferences);
      if (preferences.keySet().stream().anyMatch(String::isBlank)) {
        throw new IllegalArgumentException("Firefox preference names must not be blank");
      }
      if (preferences.values().stream().anyMatch(value ->
          !(value instanceof String
              || value instanceof Boolean
              || value instanceof Integer))) {
        throw new IllegalArgumentException(
            "Firefox preferences must be strings, booleans, or integers");
      }
    }
  }
}

List.copyOf() and Map.copyOf() matter because records are only shallowly final. A record component cannot be reassigned after construction, but a mutable list referenced by that component could otherwise change later. A sealed interface does nothing to prevent that mutation. The defensive copy gives the factory stable input even if the caller clears its original collection.

The declared preference value type is Object because that is what Selenium's Java method accepts, but the constructor narrows this framework's contract to strings, booleans, and integers. That also excludes nested mutable values. If you relax the rule to allow lists or maps, remember that Map.copyOf() does not recursively freeze them. Better still, expose typed fields for the small set of preferences the suite actually uses.

Optional<Path> makes absence explicit in this bounded model. Do not put null inside the optional. The compact constructor rejects a null Optional, and callers use Optional.empty() when normal browser discovery should apply. A binary path for a remote session refers to the machine that launches the browser, so deployment configuration must supply a path meaningful on the node, not merely on the test client.

The acceptInsecureCerts component deserves an explanation, because an earlier version of this model omitted it and that omission was a security defect rather than a simplification. Selenium's FirefoxOptions seeds acceptInsecureCerts to true in its constructor. ChromeOptions does not. Build both from a spec that never mentions certificates and the serialized requests diverge: the Chrome request carries no certificate capability at all, while the Firefox request carries acceptInsecureCerts=true. One sealed hierarchy, one exhaustive switch, one apparently uniform intent, and two different TLS policies on the wire.

That asymmetry is invisible to a test that only checks which options class came back. assertInstanceOf(FirefoxOptions.class, ...) passes whether or not the browser will accept a forged certificate. Silence in configuration is not neutrality; it is a delegation to whichever default the vendor chose, and the vendors chose differently. Declare the policy on the interface, set it in every arm, and let the default be false in the external parser so that relaxing it is a visible decision in a reviewed file.

The model deliberately excludes browserVersion and platformName from browser-specific records. Those matching concerns can be represented in a separate execution request because they apply across browsers. Otherwise each permitted subtype repeats transport and Grid fields, and adding a new scheduling concern changes every record.

External configuration still arrives as strings. Parse it into the sealed type at one edge. Reject unknown values there rather than manufacturing a CustomBrowserSpec containing a raw map. A non-sealed escape hatch makes the switch's compile-time value disappear and lets unreviewed capabilities enter the factory.

Translate each subtype into fresh Selenium options

The factory has two layers. optionsFor() converts the sealed value to one fresh browser options object. createLocal() and createRemote() decide where a session starts. Keeping transport separate avoids four types named LocalChrome, RemoteChrome, LocalFirefox, and RemoteFirefox when the browser intent is otherwise identical.

Selenium 4 expects browser option classes for session creation. The factory uses ChromeOptions for Chrome and FirefoxOptions for Firefox. The browser-specific methods stay inside the corresponding switch arm. No caller receives an options instance to mutate after the request is built.

Java
package example.browser;

import java.net.URL;
import java.util.Map;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.Dimension;
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 final class SeleniumBrowserFactory {
  private SeleniumBrowserFactory() {}

  public static Capabilities optionsFor(BrowserSpec spec) {
    return switch (spec) {
      case BrowserSpec.Chrome chrome -> chromeOptions(chrome);
      case BrowserSpec.Firefox firefox -> firefoxOptions(firefox);
    };
  }

  public static WebDriver createLocal(BrowserSpec spec) {
    WebDriver driver = switch (spec) {
      case BrowserSpec.Chrome chrome -> new ChromeDriver(chromeOptions(chrome));
      case BrowserSpec.Firefox firefox -> new FirefoxDriver(firefoxOptions(firefox));
    };
    return resize(driver, spec.viewport());
  }

  public static WebDriver createRemote(URL gridUrl, BrowserSpec spec) {
    WebDriver driver = new RemoteWebDriver(gridUrl, optionsFor(spec));
    return resize(driver, spec.viewport());
  }

  private static ChromeOptions chromeOptions(BrowserSpec.Chrome spec) {
    ChromeOptions options = new ChromeOptions();
    options.setAcceptInsecureCerts(spec.acceptInsecureCerts());
    if (spec.headless()) options.addArguments("--headless=new");
    spec.binary().ifPresent(path -> options.setBinary(path.toFile()));
    options.addArguments(spec.extraArguments());
    return options;
  }

  private static FirefoxOptions firefoxOptions(BrowserSpec.Firefox spec) {
    FirefoxOptions options = new FirefoxOptions();
    options.setAcceptInsecureCerts(spec.acceptInsecureCerts());
    if (spec.headless()) options.addArguments("-headless");
    spec.binary().ifPresent(options::setBinary);
    for (Map.Entry<String, Object> entry : spec.preferences().entrySet()) {
      options.addPreference(entry.getKey(), entry.getValue());
    }
    return options;
  }

  private static WebDriver resize(WebDriver driver, BrowserSpec.Viewport viewport) {
    try {
      driver.manage().window().setSize(
          new Dimension(viewport.width(), viewport.height()));
      return driver;
    } catch (RuntimeException resizeFailure) {
      try {
        driver.quit();
      } catch (RuntimeException quitFailure) {
        resizeFailure.addSuppressed(quitFailure);
      }
      throw resizeFailure;
    }
  }
}

The pattern switch requires a Java release that supports the syntax without preview flags. Compile the project with the release selected by the team and configure the same release in CI. Do not add --enable-preview from an old example unless the project's actual Java level requires preview behavior. Java 21 supports the pattern switch used here as a permanent language feature.

optionsFor() returns Capabilities because both options classes implement that interface. It still creates the concrete type internally, and callers can inspect common capabilities without gaining a generic mutation path. The local factory needs another exhaustive switch because local driver constructors take their matching option types.

The remote path does not switch on browser again. The options object already includes the browser identity supplied by its concrete options class, and RemoteWebDriver sends it to the endpoint. This is a valid place to reduce duplication because the transport behavior is genuinely common.

Viewport size is applied with WebDriver after the session exists rather than guessed through browser command-line flags. If resizing fails, the helper quits the partially configured session and preserves a quit failure as suppressed evidence. The cost is one extra WebDriver command at startup; the gain is one cross-browser mechanism with an observable returned window state.

Do not add a catch block that sees any session creation failure and falls back to Chrome. That changes requested coverage after runtime evidence says the requested session could not start. Preserve the requested subtype, endpoint identity, and exception. Grid capacity or a missing browser should fail as infrastructure, not become a pass on another browser.

Each method creates new mutable Selenium options. Caching optionsFor(spec) by record equality would reintroduce shared state. Two equal specs express the same intent, but their resulting options still belong to different session requests. Value equality of configuration is not permission to share mutable output.

Prove the model and inspect the runtime boundary

Compile-time exhaustiveness is easiest to see during a change. Add BrowserSpec.Edge to the permits list without editing the switches. Compilation points to the switch expressions that do not cover the new subtype. Do not assert an exact compiler sentence in documentation because wording can differ by JDK release and compiler. The actionable evidence is the source location of the non-exhaustive switch.

Unit tests should cover translation behavior that can regress without opening browsers. The tests below prove that the Chrome subtype becomes Chrome options, Firefox becomes Firefox options, both arms emit the same certificate policy, calls return fresh objects, mutation of a caller-owned list cannot alter the spec, and each constructor guard actually rejects the input it claims to reject. Every assertion would fail under a plausible factory, defensive-copy, or validation defect.

Java
package example.browser;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.CapabilityType;

final class SeleniumBrowserFactoryTest {
  private static final BrowserSpec.Viewport VIEWPORT =
      new BrowserSpec.Viewport(1440, 900);

  @Test
  void translatesEachPermittedBrowserToItsSeleniumOptionsType() {
    BrowserSpec chrome =
        new BrowserSpec.Chrome(true, false, VIEWPORT, Optional.empty(), List.of());
    BrowserSpec firefox =
        new BrowserSpec.Firefox(true, false, VIEWPORT, Optional.empty(), Map.of());

    assertInstanceOf(ChromeOptions.class, SeleniumBrowserFactory.optionsFor(chrome));
    assertInstanceOf(FirefoxOptions.class, SeleniumBrowserFactory.optionsFor(firefox));
  }

  @Test
  void requestsTheSameCertificatePolicyInEveryArm() {
    BrowserSpec strictChrome =
        new BrowserSpec.Chrome(true, false, VIEWPORT, Optional.empty(), List.of());
    BrowserSpec strictFirefox =
        new BrowserSpec.Firefox(true, false, VIEWPORT, Optional.empty(), Map.of());
    BrowserSpec relaxedFirefox =
        new BrowserSpec.Firefox(true, true, VIEWPORT, Optional.empty(), Map.of());

    assertEquals(
        Boolean.FALSE,
        SeleniumBrowserFactory.optionsFor(strictChrome)
            .getCapability(CapabilityType.ACCEPT_INSECURE_CERTS));
    assertEquals(
        Boolean.FALSE,
        SeleniumBrowserFactory.optionsFor(strictFirefox)
            .getCapability(CapabilityType.ACCEPT_INSECURE_CERTS));
    assertEquals(
        Boolean.TRUE,
        SeleniumBrowserFactory.optionsFor(relaxedFirefox)
            .getCapability(CapabilityType.ACCEPT_INSECURE_CERTS));
  }

  @Test
  void returnsFreshMutableOptionsForEqualIntent() {
    BrowserSpec spec =
        new BrowserSpec.Chrome(false, false, VIEWPORT, Optional.empty(), List.of());

    assertNotSame(
        SeleniumBrowserFactory.optionsFor(spec),
        SeleniumBrowserFactory.optionsFor(spec));
  }

  @Test
  void copiesCallerOwnedCollections() {
    ArrayList<String> arguments = new ArrayList<>();
    arguments.add("--disable-notifications");
    BrowserSpec.Chrome spec =
        new BrowserSpec.Chrome(false, false, VIEWPORT, Optional.empty(), arguments);

    arguments.clear();

    assertEquals(List.of("--disable-notifications"), spec.extraArguments());
  }

  @Test
  void rejectsABlankChromeArgument() {
    IllegalArgumentException error = assertThrows(
        IllegalArgumentException.class,
        () -> new BrowserSpec.Chrome(
            false, false, VIEWPORT, Optional.empty(), List.of("   ")));

    assertEquals("Chrome arguments must not be blank", error.getMessage());
  }

  @Test
  void rejectsAFirefoxPreferenceValueOutsideTheSupportedTypes() {
    IllegalArgumentException error = assertThrows(
        IllegalArgumentException.class,
        () -> new BrowserSpec.Firefox(
            false,
            false,
            VIEWPORT,
            Optional.empty(),
            Map.<String, Object>of("browser.download.dir", List.of("/tmp"))));

    assertEquals(
        "Firefox preferences must be strings, booleans, or integers",
        error.getMessage());
  }

  @Test
  void rejectsAViewportBelowTheSuiteMinimum() {
    IllegalArgumentException error = assertThrows(
        IllegalArgumentException.class,
        () -> new BrowserSpec.Viewport(320, 200));

    assertEquals("Viewport is too small for this suite", error.getMessage());
  }
}

The three guard tests exist because validation without coverage is decoration. Replace the blank-argument check with a no-op and rejectsABlankChromeArgument fails. Widen the preference type check to accept any Object and rejectsAFirefoxPreferenceValueOutsideTheSupportedTypes fails, which matters because a nested List is exactly the mutable value the earlier discussion of shallow copying warns about. Drop the height bound from the viewport and rejectsAViewportBelowTheSuiteMinimum fails. Before those cases existed, all three guards could have been deleted without turning the suite red, which means a paragraph of the article was justifying code that nothing tested.

The certificate test is worth reading as a mutation exercise too. Remove setAcceptInsecureCerts from the Firefox arm and the second assertion sees true, because that is the Selenium default rather than an absent value. Remove it from the Chrome arm and the first assertion sees null. One deletion produces a silently relaxed browser and the other produces an unstated one; the test distinguishes both from the requested policy.

Those tests do not prove that a real browser accepts every argument or preference. A small smoke test must create one local or remote session per supported subtype, read returned standard capabilities, navigate to a controlled page, assert a product-visible condition, and quit in finally. Keep session IDs and the sanitized spec together in the report.

If a smoke test returns browserName=chrome for a Chrome spec, it confirms the endpoint reports a Chrome session. It does not prove every command-line argument took effect. Browser-specific startup settings need browser-specific evidence when they matter. For headless mode, the real test is usually whether the intended CI environment starts and renders the application correctly, not whether a request object contains a string.

Three failures often get mislabeled as a sealed-hierarchy defect. First, the external parser may map edge to Chrome before the sealed type is created. Log the raw normalized input and resulting subtype at that boundary. Second, the factory may produce correct Firefox options while Grid has no Firefox slot. Compare the request with Grid status and node configuration. Third, the browser can start correctly and the application assertion can fail. Once navigation and DOM commands run, investigate the product and test synchronization rather than adding another permitted type.

Work through the Edge change as a concrete review exercise. A developer first adds an Edge record to the permits clause. The two factory switches stop compiling, which exposes local translation and common option translation. The external parser may also use an exhaustive switch over an enum, or it may still be an if chain over strings. The sealed hierarchy cannot inspect that earlier code. Add a parser test that supplies edge and asserts that the result is the Edge subtype, otherwise the new factory arm can be complete while configuration never reaches it.

Now consider the opposite defect. The parser returns Firefox, but a copy-and-paste error in optionsFor() calls the Chrome translator. The hierarchy is still exhaustive because every subtype has a syntactic arm. The unit test using assertInstanceOf(FirefoxOptions.class, ...) fails and identifies the translation boundary. This shows why compile-time coverage and behavioral tests are complementary. Exhaustiveness proves a decision exists, not that the decision is correct.

A third worked example begins after correct translation. The Firefox smoke job asks Grid for a session, but the endpoint has only Chrome capacity. The subtype log says Firefox, the unit test produces FirefoxOptions, and no session ID is created. Editing permits or adding a default arm cannot help. Grid status, node stereotypes, and the session request are the relevant evidence. If policy allows the Firefox job to be optional, encode that policy in CI rather than silently falling back in Java.

Returned capabilities should be compared with the request at the standard fields the endpoint reports. Record browserName, browserVersion, and platformName from RemoteWebDriver.getCapabilities() after creation. A returned concrete version when no version was requested is useful inventory, not a mismatch. If the requested browser name and returned browser name conflict, preserve both plus the session ID before deciding whether parsing, translation, endpoint matching, or reporting is wrong.

The factory's local branch and remote branch also fail differently. A local ChromeDriver call uses browser and driver discovery on the test host. A remote call sends options to the Grid URL and the node launches the browser. A binary path that works locally can be meaningless on the node. Keep transport=local or a sanitized endpoint label beside the subtype in evidence so a filesystem error is investigated on the correct machine.

Argument order can be relevant to browser startup, while set-like assertions can hide it. If your suite depends on an ordered list, preserve order in the record and test the translated list only for arguments your framework owns. Do not sort before translation merely to make snapshots stable. A stable snapshot that changes semantics is worse than a focused assertion.

The collection copy test covers mutation after construction, but it does not prove nested values are immutable. Suppose a Firefox preference value is a mutable list and a caller edits it after building the record. Map.copyOf() protects the key mapping, not the list contents. Either reject nested collections, normalize them into immutable copies, or replace the generic preference map with named typed components. Document which shapes are accepted so a caller does not mistake shallow copying for deep immutability.

Validation errors should name the configuration field and rejected category without exposing secrets. “Firefox preference name is blank” is actionable. Printing the entire preference map may leak proxy or profile data. Constructor failures occur before Selenium options exist, so the incident record should identify configuration.validation rather than webdriver.session.failed.

Compile checks can expose another problem during modularization. Moving a permitted subtype to a location that violates the sealed hierarchy's package or module constraints is a Java model error, not a Selenium error. Keep the compiler output with the change and correct the ownership boundary. Marking the subtype non-sealed merely to make the move compile opens the hierarchy to arbitrary descendants and should be a conscious architectural decision.

Configuration snapshots should avoid toString() on entire option objects as a long-term contract. Internal formatting can change, and provider extensions may include sensitive values. Log a deliberate view: subtype name, headless boolean, viewport, whether a custom binary is set, and approved preference or argument names. Redact values that may contain paths, credentials, or proxy details.

Migrate without creating a second universal factory

Inventory current creation paths before introducing the hierarchy. Search for new ChromeDriver, new FirefoxDriver, new RemoteWebDriver, browser strings, and helpers returning Capabilities. The goal is not to force every specialized test through one giant class. It is to identify which creation paths share the same supported browser policy.

Define the sealed model beside the old factory and adapt one representative smoke suite. Reject unknown external values, but keep the old path available for callers not yet migrated. Add unit tests for the permitted variants before moving session creation. This sequence lets compilation and translation tests catch model mistakes without consuming Grid sessions.

Next, move local and remote session creation behind the new factory. Record which call sites still bypass it. A static analysis rule, package visibility, or dependency boundary can prevent new direct driver constructors outside approved infrastructure code. Do not leave the old string factory as the easiest public method, or new tests will keep extending it.

Use a compiler gate and a browser smoke matrix in CI. The matrix below assumes a Maven project whose integration test reads browser and selenium.remote.url system properties. The service image is pinned so a browser image update is a deliberate dependency change rather than an untracked input.

YAML
name: sealed-browser-contract
on: [pull_request]

jobs:
  compile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - run: mvn -B -DskipTests compile
      - run: mvn -B -Dtest=SeleniumBrowserFactoryTest test

  smoke:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        browser: [chrome, firefox]
    services:
      selenium:
        image: selenium/standalone-${{ matrix.browser }}:4.44.0-20260505
        ports:
          - 4444:4444
        options: >-
          --shm-size=2g
          --health-cmd "/opt/bin/check-grid.sh --host 0.0.0.0 --port 4444"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 20
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - run: >-
          mvn -B -Dtest=BrowserSessionSmokeTest
          -Dbrowser=${{ matrix.browser }}
          -Dselenium.remote.url=http://localhost:4444 test

Compilation is the fast proof that switches cover the current permitted set. Unit tests check fresh translation and defensive copies. Smoke tests verify that the installed Selenium binding, endpoint, driver, and browser can negotiate a session. Running only the smoke matrix misses unfinished branches that no configuration selects. Running only compilation misses runtime capability and infrastructure defects.

Adding a browser now has an intentional checklist. Add the permitted subtype, address every compile failure, define external parsing, add translation assertions, add a qualified Grid image or node, and add a smoke entry. This is more work than adding a string. The extra work represents real coverage obligations that the string version hid.

During migration, watch for factories that return WebDriver from unrelated packages. They may encode download preferences, certificates, proxies, or mobile emulation that the first sealed model does not cover. Do not route them through a generic extraCapabilities map to claim completion. Either extend a named supported subtype with validated fields or leave the specialized path separate until its requirements are understood.

Compatibility needs a deliberate policy too. Changing the permits list and adding a new subtype can affect consumers that compile exhaustive switches against your model. Inside one repository, that is the benefit. Across independently released libraries, it can be a source-breaking change for downstream code. Version the contract accordingly or keep the sealed type internal to the framework module.

Code review should ask what observable change would make each new test fail. The fresh-options assertion fails if caching is introduced. The subtype assertion fails if translation selects the wrong options class. The defensive-copy assertion fails if the constructor retains the caller's list. The certificate assertion fails if either arm stops stating the policy, in one direction as a null and in the other as an inherited true. Each guard assertion fails if its validation is removed. A test that constructs new Chrome(...) and then asserts spec instanceof Chrome only repeats Java's construction semantics and offers no protection against a factory regression.

Remove the old default behavior as soon as its last caller migrates. Leaving it available “for compatibility” keeps the most dangerous semantics in production: unknown input becomes plausible but incorrect coverage. If compatibility truly requires accepting old names, map each known alias explicitly and emit a deprecation record. Unknown values must still stop before driver creation.

The migration cost includes temporary duplication and a Java baseline requirement. Teams still compiling on a release before their chosen sealed-type and pattern-switch syntax must upgrade or use a less expressive design. Do not raise the production project's Java baseline solely for an aesthetic factory if organizational constraints make the upgrade larger than the test benefit.

Know when a closed hierarchy is the wrong contract

An enum is simpler when each browser has the same data and translation consists of choosing a driver. Use enum Browser { CHROME, FIREFOX } and an exhaustive switch. A sealed interface earns its cost when variants carry different valid fields or behavior.

An open plugin system needs an open extension point. If external modules register device farms, embedded browsers, or organization-specific drivers, the central module cannot know every subtype at compile time. Use a registration API with explicit names, validation, collision handling, and startup diagnostics. Pretending that an open ecosystem is closed leads to a non-sealed CustomSpec that defeats the original design.

Do not model every capability combination as another permitted class. Headless versus headed, local versus remote, and two viewport sizes can multiply into a hierarchy nobody can scan. Seal the dimension that is genuinely closed, usually the browser-specific option shape, and compose orthogonal validated values around it.

Avoid putting WebDriver methods on the configuration subtype. A ChromeSpec.createDriver() record mixes stable intent with session ownership, transport, and cleanup. It also makes translation harder to test without starting a browser. Keep the data model, option translator, and lifecycle coordinator as separate small units.

Finally, sealing cannot replace operational evidence. A perfectly exhaustive switch can request a browser that is absent, crash during startup, or return a session that later fails against the application. Use the compiler to catch unfinished code changes, then use sanitized request logs, returned capabilities, Grid evidence, and product assertions at their respective boundaries. Each proof answers a different question.

// 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 selenium.dev reference

    selenium.dev

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

  2. 02
    Official selenium.dev reference

    selenium.dev

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

  3. 03
    Official selenium.dev reference

    selenium.dev

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

  4. 04
    Official selenium.dev reference

    selenium.dev

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

FAQ / QUICK ANSWERS

Questions testers ask

Why use a sealed interface for a Selenium browser factory?

Sealing defines the browser configuration variants that are allowed to reach the factory. An exhaustive pattern switch then forces a compile-time decision when a permitted variant is added.

Does a sealed browser type validate Selenium capabilities?

Compile-time coverage is narrower than runtime validation. Java can prove which subtype reached a switch, but Selenium and the remote endpoint still validate capability values and browser availability. Keep constructor checks and real session smoke tests.

Should the factory switch include a default branch?

Usually not when the selector is your own sealed hierarchy. Omitting default lets the compiler flag a switch that no longer handles every permitted subtype after the model changes.

Can plugins add another browser implementation to a sealed interface?

Implementation is limited to classes in the permits relationship, subject to Java's package or module rules. If third-party extension is a real requirement, an open interface plus registration or service discovery is a better boundary.

Are Java records required for sealed Selenium configurations?

Records are convenient final carriers for immutable-looking configuration values, but they are not required. Final classes work as permitted implementations when they need custom construction or behavior.