PRACTICAL GUIDE / immutable WebDriver capability profile Java
Stop capability drift before it reaches Selenium Grid
Build immutable Java capability profiles, prove requests stay stable under parallel load, and separate desired settings from session facts in CI.
In this guide6 sections
What you will learn
- Why capability requests drift under parallel execution
- Build values once and options once per session
- Prove the profile can resist real changes
- Read request evidence before blaming Grid
Two parallel tests ask Grid for the same browser, yet only one request contains the expected platform and startup arguments. Both pass when run alone. That pattern usually points to request state changing inside the test client, not a capricious Grid.
An immutable capability profile gives the suite one stable description of what it wants and creates a new Selenium options object for each session. The distinction sounds small. It is the difference between diagnosing the request that left your process and guessing from whichever mutable object happens to be in memory after the failure.
Why capability requests drift under parallel execution
Selenium's Java option classes are builders. ChromeOptions exposes methods that add arguments and set capabilities, and its inheritance chain includes MutableCapabilities. That design is useful when one test constructs one session in a few lines. It becomes dangerous when a framework stores an options instance in a static field, dependency injection singleton, suite-level extension, or shared factory and then lets tests add their own settings.
Imagine a smoke test that adds --headless=new to the shared object. A download test later adds a browser preference. A third test sets a platform because it must run on a Linux Grid node. Those method calls change the same object. Parallel scheduling decides which additions are present when each RemoteWebDriver constructor reads it. Serial execution can hide the defect because the mutation order stays consistent. A retry can hide it again by moving the test to a quieter point in the run.
Certificate handling creates a less obvious version of the same bug. One test targets an internal environment with a temporary certificate and enables insecure certificates on a shared options object. A separate security check is supposed to prove the browser rejects an invalid certificate. If it inherits the first test's setting, the security check can reach the application and report the wrong outcome. The browser did exactly what the final request asked it to do. The framework lost the boundary between two tests with opposing requirements.
That example also shows why a screenshot is weak evidence. Both tests may display the same page after navigation, but the screenshot cannot reveal which certificate policy was requested when the session started. The pre-session options snapshot can. Capability defects must be investigated at session creation, because later browser state may be a legitimate consequence of an already-corrupted request.
The first useful distinction is between configuration and a Selenium request builder. Configuration is a value: browser version, platform, certificate policy, page-load strategy, and a list of command-line arguments. A request builder is a mutable object assembled for one attempt. Keeping a builder in a field because constructing it feels repetitive trades a few allocations for nondeterministic ownership. That is a poor bargain in a test suite.
The second distinction is between requested and returned capabilities. The WebDriver protocol uses capabilities in both directions, but they answer different questions. The New Session request tells the remote end what the client requires or prefers. The successful response identifies the session and describes the capabilities supplied by the remote end. The W3C specification explicitly allows a returned platform name to be more specific than the requested value. Selenium also exposes the response through RemoteWebDriver.getCapabilities().
Do not merge that response back into the reusable profile. A returned browser version is an observation about one allocated browser. A browser binary path, driver version, or vendor-specific field may describe one node. Reusing those facts as future requirements can make the next request needlessly narrow or invalid. It also destroys the audit trail: reviewers can no longer tell which values came from source-controlled configuration and which appeared only after session creation.
A real failure often arrives as SessionNotCreatedException, but that exception does not prove request drift. Grid can return the same exception family when no suitable slot exists, when a node cannot start the browser, or when a requested capability is invalid. The evidence for drift is a mismatch between the configuration value and the serialized options snapshot before the New Session call. If the snapshot is correct, move the investigation across the network boundary. If it is already wrong, fix ownership in the client first.
One near-miss deserves special attention. Environment parsing can produce two different profiles without any mutation at all. A blank variable, trailing space, or inconsistent default may make CI request a different platform from a developer laptop. That defect is deterministic for a given process. Shared-object mutation changes with ordering and concurrency. Print the parsed immutable profile once at process startup, then print the fresh request snapshot per test. If the startup value is wrong, inspect parsing. If the startup value is right but a request differs, inspect the builder path.
Build values once and options once per session
A good profile accepts only values the framework is prepared to validate and support. It should not expose a generic Map<String, Object> merely because Selenium capabilities eventually become JSON. An unrestricted map moves spelling mistakes and type mistakes to session creation, where the resulting Grid error is farther from the bad configuration. Named components make the supported contract visible in code review.
This Java record copies the argument list, validates its scalar values, and returns a new ChromeOptions on every call. Empty version and platform strings mean that the suite does not add those optional constraints. The code does not claim the resulting ChromeOptions is immutable. Its short lifetime and single owner are the safety mechanism.
package example.webdriver;
import java.util.List;
import java.util.Objects;
import org.openqa.selenium.PageLoadStrategy;
import org.openqa.selenium.chrome.ChromeOptions;
public record CapabilityProfile(
String browserVersion,
String platformName,
boolean acceptInsecureCerts,
PageLoadStrategy pageLoadStrategy,
List<String> chromeArguments) {
public CapabilityProfile {
browserVersion = normalize(browserVersion, "browserVersion");
platformName = normalize(platformName, "platformName");
pageLoadStrategy = Objects.requireNonNull(pageLoadStrategy, "pageLoadStrategy");
chromeArguments = List.copyOf(
Objects.requireNonNull(chromeArguments, "chromeArguments"));
if (chromeArguments.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException("chromeArguments must not contain blanks");
}
}
public ChromeOptions newOptions() {
ChromeOptions options = new ChromeOptions();
if (!browserVersion.isEmpty()) {
options.setBrowserVersion(browserVersion);
}
if (!platformName.isEmpty()) {
options.setPlatformName(platformName);
}
options.setAcceptInsecureCerts(acceptInsecureCerts);
options.setPageLoadStrategy(pageLoadStrategy);
for (String argument : chromeArguments) {
options.addArguments(argument);
}
return options;
}
private static String normalize(String value, String name) {
Objects.requireNonNull(value, name);
String normalized = value.trim();
if (!value.isEmpty() && normalized.isEmpty()) {
throw new IllegalArgumentException(name + " must not contain only whitespace");
}
return normalized;
}
}The record is intentionally browser-specific. A profile that sometimes builds Chrome and sometimes builds Firefox tends to accumulate fields that are meaningful for only one branch. The resulting constructor permits contradictory combinations, and tests discover those contradictions only after reaching a remote server. Separate ChromeProfile and FirefoxProfile values are often easier to reason about. A small common interface can expose Capabilities newOptions() if the session factory genuinely needs polymorphism.
There is a cost. Every supported setting requires a named component, validation rule, and mapping line. Adding a provider option is more work than dropping an entry into a map. That friction is useful for settings that affect every session. It forces the team to decide whether a value belongs in the stable profile, in per-test metadata, or nowhere at all.
Per-test metadata usually does not belong in this record. Test names, build identifiers, retry numbers, and observability labels change for each attempt. Add them to the fresh options object after newOptions() and before constructing the driver. The caller owns that object, so the mutation remains local. If a cloud provider accepts a namespaced options object, build a fresh provider map for the attempt and use the exact key and value schema from that provider's current documentation. A generic article cannot safely invent that contract.
Make the handoff one-way. Once the caller passes an options object to the driver constructor, no helper should retain a reference and decorate it for another purpose. That rule is easier to review when the options variable is local to a short session-factory method. It is much harder to enforce when an options object travels through several services that each add a capability. If several decorators are necessary, apply them in a fixed sequence to the fresh object, snapshot the result, and let only the final step create the driver.
Composition has another cost: two decorators may believe they own the same setting. Immutability does not resolve that policy conflict. Give each capability one owner, and reject duplicate inputs while building the profile rather than silently choosing whichever decorator runs last. A clear exception during configuration loading is cheaper than a valid but unintended browser session.
Secrets do not belong here either. A Grid URL may contain credentials, and proxy configuration may contain sensitive endpoints. Keep authentication in the transport or secret provider appropriate to your environment. If a capability really must carry a sensitive value, sanitize the request snapshot before writing it to CI logs. Immutability prevents accidental mutation; it does not prevent accidental disclosure.
Configuration parsing should finish before tests begin. Read environment variables or a configuration file once, validate the allowed values, and create the profile. Do not let page objects read System.getenv() during a test. That hides the source of a request and makes local reproduction depend on ambient process state. Passing a profile into a session factory is slightly more verbose, but a failing test can then show exactly which value object it used.
Prove the profile can resist real changes
An immutability test must attack the boundaries where a future refactor could reintroduce sharing. Checking that a hard-coded expected string equals the same hard-coded constructor input proves nothing about ownership. A meaningful test mutates the caller's original collection, attempts to mutate the record's accessor, changes one generated options object, and verifies a second generated object stays clean. Each assertion has a production change that would make it fail.
package example.webdriver;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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 org.junit.jupiter.api.Test;
import org.openqa.selenium.PageLoadStrategy;
import org.openqa.selenium.chrome.ChromeOptions;
class CapabilityProfileTest {
@Test
void copiesInputsAndBuildsIndependentOptions() {
ArrayList<String> supplied = new ArrayList<>(List.of("--headless=new"));
CapabilityProfile profile = new CapabilityProfile(
"", "linux", false, PageLoadStrategy.NORMAL, supplied);
supplied.add("--incognito");
assertEquals(List.of("--headless=new"), profile.chromeArguments());
assertThrows(
UnsupportedOperationException.class,
() -> profile.chromeArguments().add("--disable-gpu"));
ChromeOptions first = profile.newOptions();
ChromeOptions second = profile.newOptions();
first.addArguments("--window-size=800,600");
assertNotSame(first, second);
assertFalse(second.asMap().toString().contains("--window-size=800,600"));
}
@Test
void rejectsBlankArgumentsBeforeSessionCreation() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> new CapabilityProfile(
"", "linux", false, PageLoadStrategy.NORMAL, List.of(" ")));
assertEquals("chromeArguments must not contain blanks", error.getMessage());
}
@Test
void separatesAnAbsentMatchingValueFromAWhitespaceOnlyOne() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> new CapabilityProfile(
" ", "linux", false, PageLoadStrategy.NORMAL, List.of()));
assertEquals(
"browserVersion must not contain only whitespace", error.getMessage());
assertEquals(
"",
new CapabilityProfile("", "linux", false, PageLoadStrategy.NORMAL, List.of())
.browserVersion());
}
}The first test catches three distinct regressions. Removing List.copyOf lets the caller's later add affect the profile. Returning a mutable list from the accessor makes the assertThrows fail. Caching a ChromeOptions inside newOptions() makes assertNotSame fail and allows the window-size argument to appear in the second object. That is an oracle with teeth.
The third test exists because normalize() draws a distinction that is easy to erase by accident. An empty string means the suite is not constraining that capability, so newOptions() skips setBrowserVersion entirely. A whitespace-only string means somebody's configuration produced a value that looks set and is not, which is exactly the parsing near-miss described earlier in this article, so it is rejected by name. Both halves of the assertion carry weight in opposite directions: delete the guard and the first assertion stops throwing, while dropping the !value.isEmpty() condition makes the empty case throw and the second assertion fail. Without this test, a branch the article spends a paragraph justifying could be removed with the suite still green.
The string inspection of asMap() is acceptable here because the assertion looks for the literal argument that the test injected. It does not try to reproduce Selenium's entire serialized request format. If your framework has a JSON serializer at its HTTP boundary, assert against that serializer instead. The closer the test gets to the object actually sent, the more likely it is to catch an accidental merge or decorator.
A second worked example concerns Java records themselves. Declaring record Profile(Map<String, Object> values) does not make the map immutable. The component reference cannot be reassigned after construction, but the referenced map can still change unless the constructor copies it. Map.copyOf is only a shallow copy. If a value is a mutable list or another mutable map, code holding that nested reference can still change the apparent profile. Prefer typed nested records and immutable scalar values. If a provider forces a nested JSON shape, copy each supported level and test mutation at each level.
Concurrency tests can add value, but they should not be the only proof. A race may fail to appear on a fast workstation. Deterministic ownership tests run quickly and fail every time the factory returns a cached object. Add a concurrent test only when the session factory has coordination logic worth exercising, such as a bounded pool or a metadata decorator. Do not add sleeps in the hope of making a race visible. Use latches to coordinate known points, then assert on the independent request snapshots.
The test profile should avoid launching a browser. Its job is to prove the Java value and builder boundary. Browser startup belongs in a smaller integration test that verifies the chosen settings are accepted by the environment. Keeping those two layers separate gives a useful failure classification: a unit failure means client construction changed, while an integration failure means the request crossed into WebDriver or infrastructure and needs different evidence.
Read request evidence before blaming Grid
When a session fails, capture the request before calling the constructor. Once construction throws, there is no driver from which to read a session id or returned capabilities. Logging only driver.getCapabilities() therefore leaves a blind spot around the failures you care about most. A sanitized preflight snapshot closes that gap.
The following factory prints a stable test identifier and request map, then records the returned values only after the session exists. It does not label the request snapshot as JSON because Java's Map.toString() is not JSON. Production teams can pass both maps to their structured logger instead. The important part is keeping the fields distinct.
package example.webdriver;
import java.net.URL;
import java.util.LinkedHashMap;
import java.util.Map;
import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
public final class DriverFactory {
private DriverFactory() {}
public static RemoteWebDriver start(
URL gridUrl, CapabilityProfile profile, String testId) {
ChromeOptions requested = profile.newOptions();
Map<String, Object> requestSnapshot =
new LinkedHashMap<>(requested.asMap());
System.out.printf("testId=%s phase=request capabilities=%s%n",
testId, requestSnapshot);
try {
RemoteWebDriver driver = new RemoteWebDriver(gridUrl, requested);
System.out.printf(
"testId=%s phase=created sessionId=%s capabilities=%s%n",
testId,
driver.getSessionId(),
driver.getCapabilities().asMap());
return driver;
} catch (SessionNotCreatedException error) {
System.err.printf(
"testId=%s phase=rejected exception=%s request=%s%n",
testId,
error.getClass().getName(),
requestSnapshot);
throw error;
}
}
}Suppose a test expects Linux and the request line already shows platformName=windows. The browser never had a chance to cause that. Compare the startup profile with this per-attempt snapshot. A difference indicates a mapper, decorator, or shared builder changed the value. If both show Windows, configuration parsing or CI wiring supplied the wrong value. Neither case warrants changing Grid slots.
Now consider a request that correctly shows Chrome on Linux but Grid has no free matching slot. Selenium Grid's documented flow places new session requests in the New Session Queue and lets the Distributor look for a suitable Node slot. Waiting or eventual rejection can therefore be infrastructure capacity, not mutation. Check Grid status and logs using the same time window and request attributes. An immutable profile makes that comparison reliable, but it does not decide the outcome for you.
A third look-alike is browser startup failure on a matched node. In that case, Grid may have selected the correct slot before the driver process or browser exited. The request snapshot still matches the profile. Node logs point to executable, sandbox, filesystem, or process errors rather than a mismatch. Removing a required browser argument because the exception says session creation failed may make the symptom move without fixing the environment.
Returned capabilities provide confirmation, not proof of the original request. Record them when a session succeeds and compare the subset that matters to the test. A test requiring a specific platform can assert the returned platform before exercising the application. A general regression test usually should not assert every returned key because drivers add implementation details that change across browser releases. Broad map equality creates maintenance noise and encourages engineers to pin irrelevant fields.
Be equally restrained with requested values. browserVersion is a constraint with environment-specific matching semantics, not a place to store the version observed last Tuesday. Use the value your Grid or provider documents, and validate it in a real session. Avoid examples that claim a particular label must fail everywhere. Grid implementations and providers can treat labels specially, so a made-up mismatch demonstration can teach the opposite of actual behavior.
Sanitization deserves a test of its own. Browser arguments can contain URLs, proxy credentials, extensions, or feature values. Vendor options can contain build metadata and tokens. Create a logger that allowlists safe keys rather than printing arbitrary nested values. Then feed it a fixture containing a known secret and assert the emitted record does not contain that secret. Redaction that merely checks a hard-coded safe fixture cannot fail when sensitive input appears, so it is not evidence.
Move an existing suite without hiding failures
Start the rollout at the construction boundary. Find every place that creates ChromeOptions, FirefoxOptions, MutableCapabilities, or RemoteWebDriver. Also search for fields typed as Capabilities; the interface type can hide a mutable implementation. Document which caller owns each object and whether another test can reach it. Static fields and singleton bindings are the first candidates, but suite-scoped JUnit extensions can share state too.
Introduce the immutable profile without changing all tests at once. Let the current configuration loader create the profile, then route one test group through a new factory that calls newOptions(). Capture request and response evidence for that group. Do not enable retries during this comparison if retries would discard the first attempt's logs. A stable failure with complete evidence is more useful than a green aggregate result with missing history.
Next, move per-attempt metadata out of the shared layer. Test names, build labels, and retry indices should be applied to the fresh options instance in one decorator. Keep that decorator stateless. If it takes a mutable map from a test, copy the map before attaching it. Add a test in which two different test ids produce two different option maps without changing the base profile.
Then remove access to the old singleton. Mark the field deprecated, migrate callers by package, and make the old accessor return a fresh object during the transition if its signature allows that. A silent dual path is risky: new tests look isolated while old helpers still mutate the original. Add a temporary CI check that searches the test source for direct construction outside the approved factory. A text check is not a substitute for Java tests, but it can stop new exceptions while the architecture is in motion.
name: capability-profile-check
on:
pull_request:
jobs:
immutable-profile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Prove profile ownership without starting a browser
run: ./mvnw -B -Dtest=CapabilityProfileTest test
- name: Reject new shared option fields
run: |
if grep -R -nE 'static[[:space:]]+(final[[:space:]]+)?(ChromeOptions|FirefoxOptions|MutableCapabilities)' src/test/java; then
echo "Shared mutable Selenium options are not allowed" >&2
exit 1
fiThe grep gate has an explicit limitation. It detects only straightforward declarations, not singleton bindings, fields typed as Capabilities, or factories that cache objects. Keep it temporary and treat review plus unit tests as the real controls. If a legitimate static fixture matches, narrow the path or pattern rather than training the team to ignore a permanently noisy gate.
Run the browser integration separately from the fast ownership test. A small matrix can create one session for each supported profile and assert a business-neutral page loads. That test costs Grid capacity and browser startup time, so running every combination on every pull request may be wasteful. Put the highest-risk profile on pull requests and run the full supported matrix on a scheduled or pre-release job. The profile unit tests should remain on every change because they need no browser and pinpoint client regressions.
Track the migration with evidence rather than a promised date. Count construction sites still bypassing the factory, shared mutable fields still present, and test packages moved. These are inventory counts from the repository, not performance measurements. Do not claim the change reduced flakiness unless the team defines a comparison window and retains attempt-level results. The design removes one known source of nondeterminism; it does not justify attributing every improved run to that source.
Know when immutability is the wrong fix
Do not introduce a large capability abstraction for a suite that starts one local browser with one fixed configuration. A local helper returning a fresh ChromeOptions may express the ownership rule more clearly than a record, interface hierarchy, and factory. The goal is a stable value boundary, not an architecture trophy.
Do not use an immutable profile to encode test behavior. Window resizing, cookie setup, navigation, and application state occur after the session exists. Treating them as capabilities either fails protocol validation or couples every session to one scenario. Keep session requirements in the profile and test steps in fixtures or helpers that own the driver.
Do not freeze values that should remain a deliberate matrix dimension. If CI supports Chrome and Firefox, platform variants, or several page-load strategies, create a profile per matrix entry. One global profile with conditionals scattered through tests is technically immutable but operationally opaque. The job log should identify which profile created each attempt.
Do not assume defensive copies make arbitrary objects safe. List.copyOf protects the list structure, not mutable objects stored inside it. This example accepts strings and enums because those values have clear semantics. If your profile includes a Proxy, certificate object, or provider SDK type, check whether that type is mutable. Store an immutable description and create the Selenium object per attempt when possible.
Do not confuse immutability with thread safety of the rest of the framework. A fresh options object cannot protect a static driver, shared download directory, reused user account, or report file written by several threads. If the request snapshots are stable but tests still interfere, follow the resource that changed. Session ids, filesystem paths, backend entity ids, and test ids will usually reveal a different owner.
Finally, do not respond to a correct rejected request by weakening it until some node accepts it. Removing a platform or version constraint may increase available slots, but it also changes coverage. That can be a valid decision when the constraint was unnecessary. Make it explicitly, record the returned environment, and update the test's supported matrix. A passing test on the wrong browser is not a repaired capability profile.
The pattern has concrete costs: more configuration types, stricter validation, fresh builder allocation, and separate request and response records. In return, the suite gains a request whose origin can be explained and reproduced. Use that precision where parallelism, remote execution, or provider metadata makes shared state expensive. For simpler suites, keep the same ownership rule with less machinery: values may be shared, mutable Selenium builders may not.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Should ChromeOptions be cached and reused across tests?
No. Selenium option objects are mutable request builders, so sharing one lets a later test inherit changes made by an earlier test. Cache immutable configuration values and create a fresh options object for every session instead.
Are returned WebDriver capabilities identical to the request?
Treat them as different records. The request expresses what the client asks for, while the New Session response describes the session the remote end created and may contain more specific or additional values.
Does a Java record make nested lists and maps immutable?
A record only makes its component references final. Its constructor must defensively copy mutable collections, and nested mutable values need their own copy or a narrower value type.
What should I log when Selenium Grid rejects a session?
Capture the test id, a sanitized snapshot of the requested options, the Grid URL label, and the exception type before retrying. Once a session exists, record its session id and returned capabilities separately.
Can immutable capability profiles fix every parallel Selenium failure?
Immutability prevents client-side request drift, but it cannot add a missing Grid slot, repair a browser startup failure, or isolate application test data. Those causes need different evidence and different fixes.
RELATED GUIDES
Continue the learning route
GUIDE 01
WebDriver Capability Negotiation with alwaysMatch and firstMatch
Understand WebDriver capability negotiation with alwaysMatch, firstMatch, Selenium RemoteWebDriver, conflict rules, and session evidence.
GUIDE 02
WebDriver Capability Merge Without Silent Overrides
Master WebDriver capability merge with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 03
Set WebDriver Timeouts at Session Creation
Set the WebDriver timeouts capability at session creation with correct implicit, pageLoad, and script values, Java examples, and Grid verification steps.
GUIDE 04
20 WebDriver Protocol and Capability Negotiation Interview Scenarios
Practice 20 senior WebDriver protocol and capability scenarios covering session payloads, matching rules, remote errors, and negotiated outcomes.
GUIDE 05
Selenium executeAsyncScript in Java
Selenium executeAsyncScript Java uses a final callback argument and the script timeout. Learn return conversion, error handling, and tested Java patterns.