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.
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
- 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.
- One creation path: local Chrome, local Firefox, remote Grid and cloud vendors are all produced by the same factory.
- One driver per test, never shared across threads: a
WebDriveris not thread-safe. Parallel tests must each own one. - Guaranteed cleanup:
quit()runs even when the test throws.
Configuration
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 osfrom 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 = localbaseUrl: 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
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 webdriverfrom 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 driverconst { 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 orThreadLocal<T>.
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 automaticallypublic 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.fixturedef 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.pydef 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 driverconst { 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 accesspublic 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 afterquit(). 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,
-nworkers) and make sure the Grid or machine has that many browser slots.
Passing the Driver to Page Objects
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
- Pick the Browser, Headless Mode and Grid From Environment Variables
Run the same suite on Chrome, Firefox or Edge, locally or on a Grid, headed or headless, by changing environment variables instead of code.