PRACTICAL GUIDE / immutable browser profile Selenium framework Java

Treat browser setup as data, not shared Selenium state

Design an immutable Java browser profile, build fresh Selenium options and user-data directories, and diagnose configuration leaks in parallel suites.

By The Testing AcademyUpdated August 4, 202622 min read
All field guides
In this guide7 sections
  1. Separate policy from the browser process it creates
  2. Model only settings the framework can own
  3. Attack every mutable boundary with tests
  4. Give the driver and its files one lifecycle
  5. Use evidence to distinguish three similar failures
  6. Roll the pattern out without breaking every test
  7. Know when a reusable profile is the wrong tool

What you will learn

  • Separate policy from the browser process it creates
  • Model only settings the framework can own
  • Attack every mutable boundary with tests
  • Give the driver and its files one lifecycle

A login test starts with another test's extension, startup flag, or cached browser state, but only when the suite runs in parallel. The framework reused a ChromeOptions object or pointed two Chrome processes at the same profile directory. The failure belongs to test infrastructure even though it surfaces as a product assertion or a browser startup error.

A durable fix separates three lifetimes: reusable browser policy, one session's mutable Selenium options, and one process's on-disk data. Java can represent the policy as an immutable value. The session factory must still create fresh options and fresh state for every attempt.

Separate policy from the browser process it creates

Teams use the word profile for several different things. A framework profile might mean a named configuration such as ci-chrome or local-headed. Chrome also has a user-data directory that stores browser state on disk. Selenium's ChromeOptions is a mutable builder used to describe a new session. Treating those three objects as interchangeable is how state leaks into unrelated tests.

Reusable policy answers questions that are stable across attempts. Should the browser run headless? Which page-load strategy does this test lane support? May it accept an insecure certificate in a controlled environment? Which command-line arguments has the team approved? Those values can be reviewed, versioned, and passed to many tests because no test changes them.

A user-data directory has a different owner. It can contain cookies, local storage, caches, preferences, extension data, lock files, and other state written by the browser. Its contents change while the process runs. Even when two tests start with the same desired policy, they should not accidentally share that mutable directory. Selenium's own Chrome documentation shows creating a temporary directory before adding a --user-data-dir argument, which is the safe shape for tests that need to control this path.

The options object sits between those lifetimes. Selenium documents ChromeOptions and its Chromium base class as the place to add arguments and browser-specific settings. Calls such as addArguments and setExperimentalOption mutate the builder. That is expected. The framework error is giving the builder a suite-wide lifetime instead of creating it for one New Session call.

Consider a visual test that adds a window-size argument to a singleton options object. A later responsive test expects the default window and explicitly resizes after startup. Its first screenshot is now captured at the inherited size, before the test's resize step. The report makes the application look unstable because ordering determines the initial rendering. Creating a new browser does not isolate the tests if both browsers were configured through the same mutable builder.

A second failure looks almost identical but lives on disk. Two workers independently create fresh ChromeOptions, then both add --user-data-dir=/tmp/ui-profile. One browser may fail to start, or a surviving process may leave state the next test can observe. Fresh Java objects are not enough when they point to one mutable resource. Record the actual directory alongside the test id and process id so the evidence covers both memory and filesystem ownership.

Extensions create a third ownership problem. A test that installs a packed extension needs a known file and a fresh options builder, but the extension's browser-side data still lands in that session's user-data directory. Reusing the directory can make a later test see extension state even if its own options never request the extension. Conversely, adding the extension to a shared options builder makes every later fresh directory start with it. The request snapshot tells those causes apart: an unexpected extension in the options points to builder leakage, while clean options plus residual extension state points to directory reuse or incomplete cleanup.

Avoid solving that example by putting extension files into every reusable profile. Many suites have only a few extension scenarios, and loading the extension everywhere increases startup work and changes production-like coverage. Let the specific lane decorate its one-use options object, or define a separate named policy when extension coverage is intentional for the whole lane. The cost and scope then remain visible in CI.

Remote execution changes the boundary. A local Java Path refers to the machine running the test client, while the browser may run on a Grid node or provider host. Do not pass a client-side temporary path to a remote browser and assume both ends see the same filesystem. For remote sessions, let the remote service manage browser storage unless its official contract provides a supported, isolated mechanism. An immutable local profile still helps with arguments and standard capabilities, but local directory allocation may be irrelevant or wrong.

The near-miss is intentional state reuse. A test may be explicitly verifying an upgrade from an old browser profile, an installed extension, or persistence across a browser restart. Isolation would erase the behavior under test. Such a scenario should own its fixture directory, run with controlled concurrency, and state why reuse is required. It should not borrow the framework's default profile or leave the directory available to unrelated tests.

Model only settings the framework can own

An immutable Java type should narrow the configuration surface rather than wrap an arbitrary mutable map. Strings, booleans, enums, and copied lists are easier to validate than untyped nested objects. The profile below reserves ownership of headless mode, page-load strategy, certificate behavior, and the user-data directory. Extra arguments are allowed, but callers cannot smuggle in a second value for a setting the factory controls.

Java
package example.browser;

import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import org.openqa.selenium.PageLoadStrategy;
import org.openqa.selenium.chrome.ChromeOptions;

public record BrowserProfile(
    boolean headless,
    boolean acceptInsecureCerts,
    PageLoadStrategy pageLoadStrategy,
    List<String> extraArguments) {

  public BrowserProfile {
    pageLoadStrategy = Objects.requireNonNull(pageLoadStrategy, "pageLoadStrategy");
    Objects.requireNonNull(extraArguments, "extraArguments");

    if (extraArguments.stream().anyMatch(argument ->
        argument == null || argument.isBlank())) {
      throw new IllegalArgumentException("extraArguments must not contain nulls or blanks");
    }
    if (extraArguments.stream().anyMatch(BrowserProfile::isOwnedArgument)) {
      throw new IllegalArgumentException(
          "headless and user-data-dir are controlled by BrowserProfile");
    }
    extraArguments = List.copyOf(extraArguments);
  }

  public ChromeOptions newOptions(Path userDataDirectory) {
    Path directory = Objects.requireNonNull(
        userDataDirectory, "userDataDirectory").toAbsolutePath().normalize();

    ChromeOptions options = new ChromeOptions();
    options.setAcceptInsecureCerts(acceptInsecureCerts);
    options.setPageLoadStrategy(pageLoadStrategy);
    if (headless) {
      options.addArguments("--headless=new");
    }
    options.addArguments("--user-data-dir=" + directory);
    options.addArguments(extraArguments);
    return options;
  }

  private static boolean isOwnedArgument(String argument) {
    String normalized = argument.startsWith("--") ? argument : "--" + argument;
    return normalized.equals("--headless")
        || normalized.startsWith("--headless=")
        || normalized.equals("--user-data-dir")
        || normalized.startsWith("--user-data-dir=");
  }
}

The code uses Selenium APIs documented for Chromium options: a list can be passed to addArguments, and standard options such as page-load strategy and insecure certificate handling are set through the options class. The --headless=new and --user-data-dir arguments are also listed in Selenium's Chrome documentation. The profile does not guess at experimental preference names.

This record is shallow by design because every component is itself a stable value or a copied list of strings. If you add a Map<String, Object> for convenience, you must decide what every nested value may contain and copy it accordingly. A map copied with Map.copyOf can still refer to a mutable nested list. Typed records cost more code but make invalid combinations visible before a browser is launched.

The factory owns two arguments and rejects duplicates. Without that check, an extra argument could specify a second user-data directory or a different headless form. Browser behavior with duplicated switches is not a sound framework contract. Failing during configuration loading gives the engineer the actual bad input instead of a session whose effective command line is unclear.

The profile intentionally does not store a Path. A path identifies one attempt's mutable workspace, not reusable policy. Keeping it out of the record also prevents code from creating an allegedly immutable singleton that points every test at the same directory. The session factory supplies the path when it builds the one-use options object.

Binary location and extensions need similar judgment. A stable, verified browser binary path can be part of environment configuration, but it may not exist on a remote node. Extension files can change and may contain sensitive or privileged code. Add those fields only if the framework validates their existence and execution boundary. A generic Object customOptions component sacrifices the very guarantees this type is meant to provide.

The design has a maintenance cost. Adding a supported setting requires a field, validation, mapping, and tests. That is appropriate for global startup behavior, where a typo affects an entire suite. Per-test variations should remain explicit at the scenario boundary. If dozens of tests each need arbitrary startup flags, the real design question is whether they belong in separate CI lanes with named profiles.

Attack every mutable boundary with tests

Useful immutability tests change the values supplied by the caller and the objects returned by the factory. They do not merely assert that constructor literals can be read back. The following tests require no browser. They fail if a future refactor stores the caller's list, permits a caller to override an owned argument, or caches one ChromeOptions object.

Java
package example.browser;

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.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.PageLoadStrategy;
import org.openqa.selenium.chrome.ChromeOptions;

class BrowserProfileTest {
  @Test
  void copiesArgumentsAndBuildsOneUseOptions() {
    ArrayList<String> supplied = new ArrayList<>(List.of("--window-size=1440,900"));
    BrowserProfile profile = new BrowserProfile(
        true, false, PageLoadStrategy.NORMAL, supplied);

    supplied.add("--incognito");
    assertEquals(List.of("--window-size=1440,900"), profile.extraArguments());
    assertThrows(
        UnsupportedOperationException.class,
        () -> profile.extraArguments().add("--start-maximized"));

    ChromeOptions first = profile.newOptions(Path.of("build/profiles/first"));
    ChromeOptions second = profile.newOptions(Path.of("build/profiles/second"));
    first.addArguments("--disable-extensions");

    assertNotSame(first, second);
    assertFalse(second.asMap().toString().contains("--disable-extensions"));
    assertFalse(second.asMap().toString().contains("profiles/first"));
  }

  @Test
  void rejectsAnArgumentOwnedByTheFactory() {
    IllegalArgumentException error = assertThrows(
        IllegalArgumentException.class,
        () -> new BrowserProfile(
            false,
            false,
            PageLoadStrategy.NORMAL,
            List.of("--user-data-dir=/tmp/shared")));

    assertEquals(
        "headless and user-data-dir are controlled by BrowserProfile",
        error.getMessage());
  }
}

Each assertion has a plausible regression behind it. Removing the defensive copy makes the added incognito argument appear in the accessor. Returning a cached options object makes assertNotSame fail. Ignoring the supplied path or retaining the first one makes the second map contain profiles/first. Removing the owned-argument validation makes the second test stop throwing. These tests protect behavior that production code can actually break.

String inspection is deliberately narrow. It checks for unique values injected by the test and does not promise a complete serialization format for Chrome options. If the framework has a structured request logger, test that layer with the same mutations. Do not compare the entire options map with a golden file unless every key is part of your supported contract. Selenium and browser updates can add legitimate details that turn broad snapshots into noise.

Add a filesystem ownership test around the session workspace without starting a browser. Call the allocator twice and assert the paths differ, both live under the configured root, and cleanup removes exactly the path it was given. A duplicate-path test must compare two independent allocations. Running find over one directory tree and checking for duplicate pathnames cannot detect anything because a filesystem path is unique by construction.

Deletion deserves adversarial coverage. Place a nested file in the temporary directory, close the workspace, and assert both file and directory are gone. Then arrange for cleanup to fail in a controlled test filesystem if your project has such an abstraction, and verify the failure is reported rather than swallowed. Silent cleanup failures create later browser collisions that look disconnected from the original test.

Do not make a unit test depend on two real Chrome processes racing for one directory. It is slow, platform-sensitive, and may leave processes behind. The ownership rules can be proved deterministically. Keep one browser integration test for the complete lifecycle, and retain its driver log when it fails. That test answers whether the current browser accepts the generated options, not whether Java collection copying works.

Give the driver and its files one lifecycle

The session factory should allocate the directory, create the options, start the driver, and return an object that closes both resources. Splitting those responsibilities across a base test and several hooks makes exceptional paths easy to miss. If browser construction fails, there is no driver for an @AfterEach method to quit, but the directory still needs cleanup.

This AutoCloseable wrapper keeps the resources together. It creates the directory before starting Chrome, deletes it if construction fails, and attempts deletion after quit(). The deletion helper walks children before their parent. Production code should send cleanup failures to the test report or structured logger rather than hiding them.

Java
package example.browser;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
import org.openqa.selenium.chrome.ChromeDriver;

public final class BrowserSession implements AutoCloseable {
  private final ChromeDriver driver;
  private final Path userDataDirectory;

  private BrowserSession(ChromeDriver driver, Path userDataDirectory) {
    this.driver = driver;
    this.userDataDirectory = userDataDirectory;
  }

  public static BrowserSession open(BrowserProfile profile, Path workspaceRoot)
      throws IOException {
    Files.createDirectories(workspaceRoot);
    Path directory = Files.createTempDirectory(workspaceRoot, "chrome-");

    try {
      ChromeDriver driver = new ChromeDriver(profile.newOptions(directory));
      return new BrowserSession(driver, directory);
    } catch (RuntimeException startupFailure) {
      try {
        deleteTree(directory);
      } catch (IOException cleanupFailure) {
        startupFailure.addSuppressed(cleanupFailure);
      }
      throw startupFailure;
    }
  }

  public ChromeDriver driver() {
    return driver;
  }

  public Path userDataDirectory() {
    return userDataDirectory;
  }

  @Override
  public void close() throws IOException {
    RuntimeException quitFailure = null;
    try {
      driver.quit();
    } catch (RuntimeException error) {
      quitFailure = error;
    }

    try {
      deleteTree(userDataDirectory);
    } catch (IOException cleanupFailure) {
      if (quitFailure != null) {
        quitFailure.addSuppressed(cleanupFailure);
      } else {
        throw cleanupFailure;
      }
    }

    if (quitFailure != null) {
      throw quitFailure;
    }
  }

  private static void deleteTree(Path root) throws IOException {
    if (Files.notExists(root)) {
      return;
    }
    try (Stream<Path> paths = Files.walk(root)) {
      try {
        paths.sorted(Comparator.reverseOrder()).forEach(path -> {
          try {
            Files.deleteIfExists(path);
          } catch (IOException error) {
            throw new UncheckedIOException(error);
          }
        });
      } catch (UncheckedIOException error) {
        throw error.getCause();
      }
    }
  }
}

A test uses the wrapper with try-with-resources. If the test body throws and close() also throws, Java retains the close failure as a suppressed exception on the original failure. The wrapper also preserves a cleanup failure on a driver quit failure. That evidence matters: reporting only the assertion can conceal the resource leak that affects the next run.

There is a trade-off in deleting the profile immediately. Browser data can be useful during an investigation. A practical policy keeps the directory on failure only when the environment can protect and expire it, then records its location as an artifact. Browser profiles may contain authentication state and personal data. Uploading the entire directory by default creates a security and storage problem. Prefer driver logs, sanitized options, screenshots, and targeted application evidence unless the profile contents are essential.

Local disk pressure is another cost. Parallel sessions each create caches and other browser files. Unique directories exchange collision risk for more temporary storage. Put them under a run-specific root with an enforced cleanup policy and enough capacity. Do not respond by returning to one shared directory. If disk use is unacceptable, let ChromeDriver manage its default temporary state or reduce concurrency while preserving ownership.

Use evidence to distinguish three similar failures

Start by logging a profile identifier, test id, process id, options snapshot, and allocated directory before constructing the driver. The profile identifier should describe policy, such as ci-headless, not a secret or raw serialized object. The directory and test id prove resource ownership. The options snapshot proves what the Java client prepared.

If two attempts show the same directory, the allocator or configuration is wrong. If the directories differ but one options snapshot contains an unexpected argument, a shared options object or decorator leaked state. If both fields are correct and the browser still fails to start, inspect the driver log and host process state. A stale browser process, incompatible binary, permissions problem, or exhausted host can resemble a profile collision without any shared Java state.

The following shell diagnostic checks a real event log produced by the suite. Unlike a check over filesystem pathnames, it can find the same directory recorded for more than one attempted owner, because the log carries the owner and the filesystem does not.

The phase filter is the load-bearing part, and it is exactly what an earlier version of this guard left out. The lifecycle described below writes three events per directory, allocated, started, and deleted, and all three carry the same profileDir. A script that extracts that field from every line and pipes it through uniq -d therefore reports every healthy directory as a duplicate: a clean two-session run exits nonzero and names both of its perfectly isolated directories as collisions. Only phase=allocated states an ownership claim, so only those lines belong in the comparison.

Shell
#!/usr/bin/env bash
set -euo pipefail

events="${1:-build/browser-profile-events.log}"

# Only phase=allocated states an ownership claim. The started and deleted
# events repeat the same profileDir by design, so a guard that reads every
# line reports a duplicate for every healthy directory.
awk '
  /(^| )phase=allocated( |$)/ {
    directory = ""; owner = "unknown"
    for (i = 1; i <= NF; i++) {
      if ($i ~ /^profileDir=/) directory = substr($i, 12)
      if ($i ~ /^testId=/)     owner     = substr($i, 8)
    }
    if (directory == "") next
    allocations++
    if (directory in claimedBy) {
      printf "collision: %s claimed by %s and %s\n",
             directory, claimedBy[directory], owner > "/dev/stderr"
      collisions++
    }
    claimedBy[directory] = owner
  }
  END {
    if (allocations == 0) {
      print "no phase=allocated events found" > "/dev/stderr"
      exit 1
    }
    if (collisions > 0) exit 1
    printf "%d allocations, no directory claimed twice\n", allocations
  }
' "$events"

Test the guard the way you would test a product assertion, because a guard is one. Run it over a clean log from a two-session run and require exit 0. Run it over a log whose two allocated lines share a directory and require exit 1 with both owners named in the output. The third case is the one teams forget: a log containing only started and deleted lines, which the script rejects rather than passing. A check that reports success because it found nothing to check is the same defect wearing a different costume.

The diagnostic needs one event per allocation, including attempts that fail before a driver exists. Log after the directory is allocated and before browser construction. If logging happens only after success, the very collisions that prevent Chrome from starting disappear from the record. Include a run id so two CI jobs writing to a shared log store cannot be mistaken for one suite.

An event should be understandable without the test report being open. A line such as runId=pr-842 testId=CheckoutTest#guest pid=7312 profile=ci-headless profileDir=/tmp/run-842/chrome-1842 phase=allocated is output your own framework can produce deterministically. A later phase=started event can add the WebDriver session id, and phase=deleted can confirm cleanup. If allocated exists without started, inspect startup. If started exists without deleted, inspect teardown. These are lifecycle facts, not invented performance measurements.

Do not include raw cookies, preferences, tokens, or the whole profile directory in that record. The option snapshot should use an allowlist of settings the framework owns. Test the sanitizer with a deliberately sensitive input and assert the emitted value is absent. Logging a fixed safe fixture proves only formatting; it cannot show that redaction survives dangerous data.

An application-state leak can look similar in the assertion report. Two tests may see the same account, cart, or feature flag even with distinct browser directories. Check session ids, paths, and a clean initial storage state. If those are unique but the backend entity id repeats, isolate test data instead of adding more browser flags. Browser isolation cannot separate a shared server-side account.

Headless-only differences are another near-miss. A test may fail only under the headless profile because viewport, permissions, graphics, or browser execution differs. Confirm that every attempt using the named profile has the same options snapshot. Consistent input plus consistent mode-specific failure is not mutation. Investigate the page and browser evidence under that mode, and keep a headed reproduction as a comparison rather than changing the profile factory.

For a successful session, record returned capabilities separately from the requested options. They describe the created session and may contain details chosen by the driver. Do not write them back into the reusable BrowserProfile. Doing so turns observations from one process into configuration for the next and recreates the lifetime error under a more official-looking type.

Roll the pattern out without breaking every test

Inventory the creation paths first. Search for new ChromeOptions, new ChromeDriver, new RemoteWebDriver, --user-data-dir, setExperimentalOption, and fields typed as Capabilities. A helper may hide mutable state behind an interface, so reading only the obvious factory is not enough. Record which test packages use each path and whether they run concurrently.

Create the immutable value and ownership tests before moving browser tests. The tests run without Chrome and give reviewers a stable contract. Then route one low-risk group through BrowserSession. Compare its request snapshots and cleanup events with the old path. Do not claim a flakiness reduction from a handful of green runs. Confirm only what the evidence supports: options are fresh, directories are unique, and cleanup outcomes are visible.

Move configuration parsing next. Convert strings from environment variables into validated enums and booleans at startup. Reject unknown names rather than defaulting silently. A misspelled profile that falls back to local headed behavior can pass on a developer machine and fail in CI for reasons unrelated to immutability.

During migration, prevent new shared builders from entering the suite. A source scan can flag direct construction outside approved packages, but it is a temporary guard, not a semantic proof. Dependency injection scopes and factory caches may not contain an obvious static declaration. Code review should still ask who owns every returned options object and every path.

YAML
name: browser-profile-contract

on:
  pull_request:

jobs:
  profile-unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - name: Test immutable policy and workspace ownership
        run: ./mvnw -B -Dtest=BrowserProfileTest,BrowserWorkspaceTest test
      - name: Check recorded profile ownership
        if: always()
        run: |
          if [[ -f build/browser-profile-events.log ]]; then
            bash scripts/check-profile-ownership.sh build/browser-profile-events.log
          fi

The conditional acknowledges that a pure unit job may not create an event log. In a browser integration job, require the file instead of skipping it. Keep the ownership check close to the job that launches sessions, and upload only sanitized logs. The YAML assumes the project has the named tests and script; those files are part of the framework contract, not magic Selenium configuration keys.

Run one lifecycle integration test on pull requests if browser startup behavior changes frequently. Run the broader profile matrix on a schedule or before release if capacity is limited. Every extra matrix entry consumes startup time, disk, and possibly remote-provider quota. Unit tests cover Java ownership cheaply; integration tests cover the actual browser contract at a higher cost.

Give each CI run its own workspace root even though every child directory is unique. The run boundary makes cleanup and artifact retention safer because one job never scans another job's files. Record the normalized root at startup, reject child paths that escape it, and delete only children allocated by the current process. This is operational code, so conservative deletion is more important than reclaiming every last stale directory automatically.

Remove the old path once its last caller moves. Leaving a compatibility singleton available encourages new tests to use it. If an exceptional test needs persistent state, give it a clearly named fixture in a separate package and exclude it from parallel scheduling. The exception should advertise its different ownership model instead of weakening the default for the whole suite.

Know when a reusable profile is the wrong tool

Do not create an explicit user-data directory when the driver or remote provider already supplies isolated temporary state and the tests do not need the path. Extra filesystem management introduces cleanup failures and security obligations. A fresh options object may be the complete solution.

Do not use a browser profile to share an authenticated session as a shortcut around login time. Reusing cookies and storage couples tests to order, expiry, account state, and browser files. If authentication setup is the bottleneck, use a supported application or API setup path, or create isolated saved state with an explicit validity contract. Measure the real cost before accepting weaker isolation.

Do not put browser actions into the immutable value. Navigation, window resizing after startup, permission prompts, cookie changes, and storage cleanup operate on a live session. Keeping those operations in page or fixture code makes the boundary clear. The profile describes how a session starts, not everything a test will do.

Do not force Chrome-specific arguments through a cross-browser profile. Firefox and Safari have different option classes and supported settings. A common interface can produce Selenium Capabilities, but each browser should validate its own policy. A single bag of strings passed to every browser is neither portable nor safe.

Do not delete a directory you did not allocate. The session wrapper should accept a run-specific root and retain the exact child path created by Files.createTempDirectory. Before recursive cleanup, production code can also verify that the child remains under the expected normalized root. This guard matters when paths come from configuration. Cleanup code with an unresolved or overly broad target is more dangerous than the leak it tries to prevent.

Do not interpret every startup exception as a directory collision. Compare paths first. A correct unique directory plus a missing browser executable, incompatible driver, or host resource failure needs an environment fix. Adding retries can make an intermittent host issue less visible while leaving orphan processes and directories behind.

An immutable browser profile costs types, validation, per-session allocations, temporary disk, and more explicit lifecycle code. Use it when a suite has several startup policies, parallel execution, or a history of leaked browser configuration. For a small local suite, a short method that returns a new ChromeOptions may be enough. The non-negotiable rule is ownership: share stable values, not mutable builders or browser state.

// 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

What should an immutable Selenium browser profile contain?

Keep reusable startup policy such as headless mode, page-load strategy, certificate handling, and approved browser arguments. Leave session ids, test names, temporary directories, cookies, and other attempt-specific state outside it.

Can two ChromeDriver sessions use the same user-data directory?

Avoid designing a test framework around that assumption. Give each concurrent session a unique temporary directory, record its owner, and remove it after the driver quits.

Is a Java record automatically deeply immutable?

No. Record components can still refer to mutable collections or objects. Copy collections in the compact constructor and prefer strings, enums, booleans, and other stable value types.

How do I prove browser options are leaking between tests?

Compare the validated profile with a snapshot of each fresh options object and its allocated profile path. Repeated paths or arguments that appear only after another test ran are direct evidence of shared client state.

Should every Selenium test launch with a custom Chrome profile directory?

Not necessarily. Default temporary browser state is often enough, especially with a remote provider that owns the filesystem. Add explicit directories only when the execution environment and cleanup contract require them.