Skip to main content
SeleniumDecoded

Driver Factory and Thread Safety

Build a WebDriver factory that reads configuration, supports local, Grid and cloud targets, and hands each parallel test its own driver via ThreadLocal or fixtures without leaks.

Selenium 4 Stable Updated 9 Sept 2026 · Verified against Selenium 4.48.0

Every suite eventually needs to answer three questions in one place: which browser, where does it run, and how does each test get its own instance. Answering them ad hoc in test classes produces a suite that cannot run in parallel and cannot be pointed at a Grid without editing fifty files. A driver factory answers them once. This lesson builds one and shows the thread-safety pattern that makes parallel execution safe.

The Requirements

  1. Configuration from outside the code: browser, headless flag, Grid URL, timeouts, base URL come from environment variables, system properties or a config file so CI can override them.
  2. One creation path: local Chrome, local Firefox, remote Grid and cloud vendors are all produced by the same factory.
  3. One driver per test, never shared across threads: a WebDriver is not thread-safe. Parallel tests must each own one.
  4. Guaranteed cleanup: quit() runs even when the test throws.

Configuration

Read settings with sane defaults
Selenium 4 Stable
public final class TestConfig {
public static String browser() { return get("BROWSER", "chrome"); }
public static boolean headless() { return Boolean.parseBoolean(get("HEADLESS", "true")); }
public static String gridUrl() { return get("GRID_URL", ""); } // empty = local
public static String baseUrl() { return get("BASE_URL", "http://localhost:3000"); }
public static Duration timeout() { return Duration.ofSeconds(Long.parseLong(get("TIMEOUT_SECONDS", "10"))); }
// System property (-DBROWSER=firefox) wins over environment variable
private static String get(String key, String def) {
String sys = System.getProperty(key);
if (sys != null) return sys;
String env = System.getenv(key);
return env != null ? env : def;
}
}
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class TestConfig:
browser: str = os.getenv("BROWSER", "chrome")
headless: bool = os.getenv("HEADLESS", "true").lower() == "true"
grid_url: str = os.getenv("GRID_URL", "") # empty = local
base_url: str = os.getenv("BASE_URL", "http://localhost:3000")
timeout: int = int(os.getenv("TIMEOUT_SECONDS", "10"))
config = TestConfig()
const config = Object.freeze({
browser: process.env.BROWSER ?? 'chrome',
headless: (process.env.HEADLESS ?? 'true') === 'true',
gridUrl: process.env.GRID_URL ?? '', // empty = local
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
timeout: Number(process.env.TIMEOUT_MS ?? 10000),
});
module.exports = { config };
public static class TestConfig
{
public static string Browser => Get("BROWSER", "chrome");
public static bool Headless => bool.Parse(Get("HEADLESS", "true"));
public static string GridUrl => Get("GRID_URL", "");
public static string BaseUrl => Get("BASE_URL", "http://localhost:3000");
public static TimeSpan Timeout => TimeSpan.FromSeconds(int.Parse(Get("TIMEOUT_SECONDS", "10")));
private static string Get(string key, string def) => Environment.GetEnvironmentVariable(key) ?? def;
}

The Factory

One method that produces any driver
Selenium 4 Stable
public final class DriverFactory {
public static WebDriver create() {
Capabilities options = optionsFor(TestConfig.browser());
WebDriver driver;
if (TestConfig.gridUrl().isBlank()) {
driver = switch (TestConfig.browser()) {
case "firefox" -> new FirefoxDriver((FirefoxOptions) options);
case "edge" -> new EdgeDriver((EdgeOptions) options);
default -> new ChromeDriver((ChromeOptions) options);
};
} else {
try {
driver = new RemoteWebDriver(new URL(TestConfig.gridUrl()), options);
((RemoteWebDriver) driver).setFileDetector(new LocalFileDetector()); // uploads via Grid
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Bad GRID_URL", e);
}
}
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));
driver.manage().window().setSize(new Dimension(1366, 768));
return driver;
}
private static Capabilities optionsFor(String browser) {
return switch (browser) {
case "firefox" -> {
FirefoxOptions o = new FirefoxOptions();
if (TestConfig.headless()) o.addArguments("-headless");
yield o;
}
case "edge" -> {
EdgeOptions o = new EdgeOptions();
if (TestConfig.headless()) o.addArguments("--headless=new");
yield o;
}
default -> {
ChromeOptions o = new ChromeOptions();
if (TestConfig.headless()) o.addArguments("--headless=new");
o.addArguments("--window-size=1366,768", "--disable-gpu");
o.setCapability("webSocketUrl", true); // BiDi on by default
yield o;
}
};
}
}
from selenium import webdriver
from selenium.webdriver.remote.file_detector import LocalFileDetector
def _options():
match config.browser:
case "firefox":
o = webdriver.FirefoxOptions()
if config.headless: o.add_argument("-headless")
case "edge":
o = webdriver.EdgeOptions()
if config.headless: o.add_argument("--headless=new")
case _:
o = webdriver.ChromeOptions()
if config.headless: o.add_argument("--headless=new")
o.add_argument("--window-size=1366,768")
o.enable_bidi = True
return o
def create_driver():
options = _options()
if config.grid_url:
driver = webdriver.Remote(command_executor=config.grid_url, options=options)
driver.file_detector = LocalFileDetector() # uploads via Grid
else:
driver = {"firefox": webdriver.Firefox, "edge": webdriver.Edge}.get(
config.browser, webdriver.Chrome)(options=options)
driver.set_page_load_timeout(30)
driver.set_window_size(1366, 768)
return driver
const { Builder } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const firefox = require('selenium-webdriver/firefox');
const { config } = require('./config');
async function createDriver() {
const builder = new Builder().forBrowser(config.browser);
const chromeOpts = new chrome.Options().addArguments('--window-size=1366,768').enableBidi();
const firefoxOpts = new firefox.Options();
if (config.headless) {
chromeOpts.addArguments('--headless=new');
firefoxOpts.addArguments('-headless');
}
builder.setChromeOptions(chromeOpts).setFirefoxOptions(firefoxOpts);
if (config.gridUrl) builder.usingServer(config.gridUrl);
const driver = await builder.build();
await driver.manage().setTimeouts({ pageLoad: 30000 });
await driver.manage().window().setRect({ width: 1366, height: 768 });
return driver;
}
module.exports = { createDriver };
public static class DriverFactory
{
public static IWebDriver Create()
{
DriverOptions options = OptionsFor(TestConfig.Browser);
IWebDriver driver;
if (string.IsNullOrWhiteSpace(TestConfig.GridUrl))
{
driver = TestConfig.Browser switch
{
"firefox" => new FirefoxDriver((FirefoxOptions)options),
"edge" => new EdgeDriver((EdgeOptions)options),
_ => new ChromeDriver((ChromeOptions)options),
};
}
else
{
var remote = new RemoteWebDriver(new Uri(TestConfig.GridUrl), options);
remote.FileDetector = new LocalFileDetector();
driver = remote;
}
driver.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(30);
driver.Manage().Window.Size = new System.Drawing.Size(1366, 768);
return driver;
}
private static DriverOptions OptionsFor(string browser)
{
switch (browser)
{
case "firefox":
var f = new FirefoxOptions();
if (TestConfig.Headless) f.AddArgument("-headless");
return f;
case "edge":
var e = new EdgeOptions();
if (TestConfig.Headless) e.AddArgument("--headless=new");
return e;
default:
var c = new ChromeOptions { UseWebSocketUrl = true };
if (TestConfig.Headless) c.AddArgument("--headless=new");
c.AddArgument("--window-size=1366,768");
return c;
}
}
}

Cloud vendors are one more branch: the same RemoteWebDriver with vendor options under bstack:options or sauce:options. See Cloud Testing Platforms.

Thread Safety: One Driver Per Test

WebDriver instances are not thread-safe, and a static driver field shared by parallel tests is the most common cause of tests interfering with each other. The fix depends on your test framework’s model:

  • Java (TestNG, JUnit 5) runs tests on a thread pool; store the driver in a ThreadLocal.
  • Python (pytest-xdist) runs tests in separate processes; a function-scoped fixture is naturally isolated.
  • JavaScript (Mocha, Jest) runs files in parallel workers; create the driver in beforeEach.
  • C# (NUnit) with [Parallelizable] runs on threads; use per-test instances or ThreadLocal<T>.
Thread-safe driver access
Selenium 4 Stable
public final class DriverManager {
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
public static WebDriver get() {
WebDriver d = DRIVER.get();
if (d == null) throw new IllegalStateException("No driver for this thread; call start() first");
return d;
}
public static void start() { DRIVER.set(DriverFactory.create()); }
public static void stop() {
WebDriver d = DRIVER.get();
if (d != null) {
try { d.quit(); } finally { DRIVER.remove(); } // remove() prevents leaks in thread pools
}
}
}
// JUnit 5 extension applies it to every test automatically
public class DriverExtension implements BeforeEachCallback, AfterEachCallback {
@Override public void beforeEach(ExtensionContext ctx) { DriverManager.start(); }
@Override public void afterEach(ExtensionContext ctx) { DriverManager.stop(); }
}
@ExtendWith(DriverExtension.class)
class CheckoutTest {
@Test void placesOrder() {
WebDriver driver = DriverManager.get();
new CheckoutPage(driver).open().placeOrder();
}
}
// TestNG: @BeforeMethod / @AfterMethod in a base class, and
// <suite parallel="methods" thread-count="4"> in testng.xml
# conftest.py: pytest runs each test in its own function scope; with pytest-xdist
# (pytest -n 4) each worker is a separate process, so no ThreadLocal is needed.
import pytest
@pytest.fixture
def driver(request):
d = create_driver()
yield d
# Attach a screenshot on failure before quitting
if request.node.rep_call.failed if hasattr(request.node, "rep_call") else False:
d.save_screenshot(f"failures/{request.node.name}.png")
d.quit()
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
setattr(item, f"rep_{call.when}", outcome.get_result())
# test_checkout.py
def test_places_order(driver):
CheckoutPage(driver).open().place_order()
// Mocha: each test file runs in its own worker with --parallel; each test gets a fresh driver
const { createDriver } = require('./driver-factory');
describe('checkout', function () {
this.timeout(60000);
let driver;
beforeEach(async () => { driver = await createDriver(); });
afterEach(async function () {
if (this.currentTest.state === 'failed') {
fs.writeFileSync(`failures/${this.currentTest.title}.png`, await driver.takeScreenshot(), 'base64');
}
await driver.quit();
});
it('places an order', async () => {
await new CheckoutPage(driver).open();
await new CheckoutPage(driver).placeOrder();
});
});
// NUnit: instance fields are per-test when the fixture is [Parallelizable(ParallelScope.All)]
// because NUnit creates one fixture instance per test in that mode. For ParallelScope.Children
// use ThreadLocal.
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CheckoutTests
{
private IWebDriver _driver = null!;
[SetUp]
public void SetUp() => _driver = DriverFactory.Create();
[TearDown]
public void TearDown()
{
if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed)
((ITakesScreenshot)_driver).GetScreenshot().SaveAsFile($"failures/{TestContext.CurrentContext.Test.Name}.png");
_driver.Quit();
}
[Test]
public void PlacesOrder() => new CheckoutPage(_driver).Open().PlaceOrder();
}
// ThreadLocal variant for shared static access
public static class DriverManager
{
private static readonly ThreadLocal<IWebDriver?> Driver = new();
public static IWebDriver Get() => Driver.Value ?? throw new InvalidOperationException("Call Start() first");
public static void Start() => Driver.Value = DriverFactory.Create();
public static void Stop() { Driver.Value?.Quit(); Driver.Value = null; }
}

Rules That Keep It Safe

  • Never a static, non-ThreadLocal driver. It is the root of “tests pass alone, fail in parallel.”
  • Always remove() the ThreadLocal after quit(). Thread pools reuse threads; a stale entry means the next test reuses a dead session.
  • Page objects take the driver in their constructor rather than reaching for a global. This makes them usable with multiple actors and easier to test.
  • Do not share page objects across tests either; they hold a driver.
  • Set parallelism explicitly in the runner (thread count, -n workers) and make sure the Grid or machine has that many browser slots.

Passing the Driver to Page Objects

Constructor injection, no globals
Selenium 4 Stable
public abstract class BasePage {
protected final WebDriver driver;
protected final WebDriverWait wait;
protected BasePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, TestConfig.timeout());
}
}
public class CheckoutPage extends BasePage {
public CheckoutPage(WebDriver driver) { super(driver); }
public CheckoutPage open() { driver.get(TestConfig.baseUrl() + "/checkout"); return this; }
public OrderConfirmationPage placeOrder() {
wait.until(ExpectedConditions.elementToBeClickable(By.id("place-order"))).click();
return new OrderConfirmationPage(driver);
}
}
class BasePage:
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, config.timeout)
class CheckoutPage(BasePage):
def open(self):
self.driver.get(f"{config.base_url}/checkout")
return self
def place_order(self):
self.wait.until(EC.element_to_be_clickable((By.ID, "place-order"))).click()
return OrderConfirmationPage(self.driver)
class BasePage {
constructor(driver) { this.driver = driver; }
}
class CheckoutPage extends BasePage {
async open() { await this.driver.get(`${config.baseUrl}/checkout`); return this; }
async placeOrder() {
const btn = await this.driver.wait(until.elementLocated(By.id('place-order')), config.timeout);
await btn.click();
return new OrderConfirmationPage(this.driver);
}
}
public abstract class BasePage
{
protected readonly IWebDriver Driver;
protected readonly WebDriverWait Wait;
protected BasePage(IWebDriver driver) { Driver = driver; Wait = new WebDriverWait(driver, TestConfig.Timeout); }
}
public class CheckoutPage : BasePage
{
public CheckoutPage(IWebDriver driver) : base(driver) { }
public CheckoutPage Open() { Driver.Navigate().GoToUrl($"{TestConfig.BaseUrl}/checkout"); return this; }
public OrderConfirmationPage PlaceOrder()
{
Wait.Until(d => d.FindElement(By.Id("place-order"))).Click();
return new OrderConfirmationPage(Driver);
}
}

Summary

  • Centralise browser, target and timeout configuration; read it from the environment so CI controls it.
  • One factory method produces local, Grid and cloud drivers.
  • One driver per test, held in a ThreadLocal or a per-test fixture, quit and removed in teardown.
  • Inject the driver into page objects; never reach for a global.

Copy-paste recipes for this topic

Related lessons