Every Selenium test needs a WebDriver. The question is: how many, created when, and owned by whom? The naive answer — create a new driver in every test method — is correct for correctness but brutal for performance. A ChromeDriver startup costs 2–4 seconds. A 200-test suite with one driver per test spends 6–13 minutes doing nothing except launching and closing browsers. The Singleton pattern is the solution: one driver instance shared across tests, created once, reused many times. But the naive Singleton is a trap in parallel test execution. This lesson covers the pattern, the trap, the fix with ThreadLocal, and when to reach for dependency injection instead.
The Singleton pattern
Singleton ensures a class has only one instance and provides a global access point to it. In Java, the classic implementation uses a private constructor and a static accessor:
public class DriverManager {
private static WebDriver driver;
// Private constructor — nobody can call new DriverManager()
private DriverManager() {}
public static WebDriver getDriver() {
if (driver == null) {
driver = new ChromeDriver();
}
return driver;
}
public static void quitDriver() {
if (driver != null) {
driver.quit();
driver = null;
}
}
}Tests access the driver via DriverManager.getDriver(). The first call creates the instance; subsequent calls return the same one. @AfterSuite calls DriverManager.quitDriver().
This works perfectly — for sequential test execution. The moment you enable parallel threads, it breaks completely.
Why the naive Singleton breaks in parallel
With multiple threads running simultaneously, driver is a shared static field. Thread A sets it. Thread B reads it. Thread A calls driver.quit(). Thread B is now operating on a closed session. The failures are spectacular: NoSuchSessionException, StaleElementReferenceException, and tests that pass or fail seemingly at random depending on thread scheduling.
The problem is not the Singleton pattern — it's sharing state across threads. The fix is ThreadLocal.
ThreadLocal — one driver per thread
ThreadLocal<T> gives each thread its own independent copy of a variable. ThreadA.get() returns Thread A's value. ThreadB.get() returns Thread B's value. No sharing, no interference:
public class DriverManager {
private static final ThreadLocal<WebDriver> driver = new ThreadLocal<>();
private DriverManager() {}
public static WebDriver getDriver() {
if (driver.get() == null) {
driver.set(new ChromeDriver());
}
return driver.get();
}
public static void quitDriver() {
if (driver.get() != null) {
driver.get().quit();
driver.remove(); // critical: remove from ThreadLocal to prevent memory leaks
}
}
}Now every thread gets its own ChromeDriver on first use. Thread A's driver is invisible to Thread B. Parallel tests run without interference. The driver.remove() call in quitDriver() is not optional — without it, the ThreadLocal holds a reference to the driver after the thread's work is done, preventing garbage collection. In long-running CI environments, this becomes a memory leak.
Naive Singleton vs ThreadLocal Singleton in parallel execution
Naive Singleton (static field)
All threads share ONE driver instance
Thread A and Thread B click different buttons simultaneously
NoSuchSessionException when Thread A quits the shared driver
Test results non-deterministic — depends on thread timing
Breaks entirely with 2+ parallel threads
ThreadLocal Singleton
Each thread gets its own isolated driver
Thread A's Chrome and Thread B's Chrome never interact
driver.remove() prevents memory leaks after test
N parallel threads → N browser instances
Works correctly at any parallelism level
Wiring it into TestNG
With ThreadLocal in DriverManager, the BaseTest class becomes:
public class BaseTest {
@BeforeMethod(alwaysRun = true)
public void setUp() {
// Each thread initialises its own driver on first call
DriverManager.getDriver().manage().window().maximize();
}
@AfterMethod(alwaysRun = true)
public void tearDown() {
// Each thread quits its own driver and removes the ThreadLocal reference
DriverManager.quitDriver();
}
}alwaysRun = true ensures teardown runs even when the test fails — zombie browser processes are how CI machines run out of memory.
Page objects receive the driver through the constructor from the test:
public class LoginTest extends BaseTest {
private LoginPage loginPage;
@BeforeMethod(dependsOnMethods = "setUp")
public void initPages() {
loginPage = new LoginPage(DriverManager.getDriver());
}
@Test
public void testValidLogin() {
loginPage.login("alice@test.com", "s3cr3t");
// ...
}
}The Singleton beyond WebDriver
The same pattern applies to any resource that should be shared — but not to everything. Apply Singleton deliberately:
Appropriate Singleton uses:
Config— environment values are the same for every test; reading a properties file once and caching it makes sense- Logger — one logger per class, created once
- Database connection pool — expensive to create; shared responsibly across tests that need DB access
Do not use Singleton for:
Page objects— create fresh instances per test. Page objects are lightweight wrappers; the cost of instantiation is negligible, and fresh instances avoid any accidental state carried between testsTest data— every test should generate or receive its own data. Shared test data is shared mutable state and is the root cause of most order-dependent test failures
Modern alternative: dependency injection
Spring, Guice, and PicoContainer (used in Cucumber-JVM) manage object lifecycles explicitly. Instead of a global static accessor, tests declare what they need:
// With PicoContainer (Cucumber) — scope is per-scenario
public class LoginSteps {
private final WebDriver driver;
private final LoginPage loginPage;
public LoginSteps(SharedDriver sharedDriver) {
this.driver = sharedDriver.getDriver();
this.loginPage = new LoginPage(driver);
}
}PicoContainer creates SharedDriver once per scenario and injects it into every step class that declares it. The lifecycle (start/stop, scope) is explicit in the DI configuration, not hidden in static fields. DI frameworks make testing the framework code itself much easier — you can inject a mock driver in a unit test without modifying global static state.
For non-Cucumber frameworks, DI adds significant dependency weight. ThreadLocal is usually the right choice until you feel the specific pain that DI solves.
⚠️ Common mistakes
- Forgetting
driver.remove()in teardown. TheThreadLocalprevents garbage collection of theWebDriverafter the thread finishes. In long-running CI jobs that recycle threads, this accumulates into a memory leak that's invisible until the build agent runs out of heap. - Initialising
ThreadLocalin a@BeforeSuitemethod.@BeforeSuiteruns in a single thread, not the test threads. The driver created there is in a different thread'sThreadLocal— test threads readnulland create their own, making the@BeforeSuitedriver an orphan that never gets quit. - Using Singleton for page objects. A
LoginPagesingleton that's reused between tests carries state from the previous test — the last URL visited, whether it's in an error state. Always construct page objects fresh in@BeforeMethodor the test itself.
🎯 Practice task
Implement and validate ThreadLocal driver management — 30 minutes.
- Implement
DriverManager. Create aDriverManagerclass withThreadLocal<WebDriver>. Wire it into yourBaseTest@BeforeMethodand@AfterMethod. Confirm that existing tests still pass with sequential execution. - Enable parallel execution. In your TestNG XML suite file, add
parallel="methods" thread-count="3". Run the suite. WithoutThreadLocal, you'll seeNoSuchSessionExceptionand non-deterministic failures. WithThreadLocal, the suite should pass. - Verify cleanup. Add a log statement to
quitDriver()that prints the thread ID:log.info("Quitting driver for thread: {}", Thread.currentThread().getId()). Run the parallel suite and check that every thread that started a driver also quit one. - Config singleton. Create a
Configclass (not usingThreadLocal— config is read-only) that readsconfig.propertiesonce and caches the result. Replace any hardcoded URLs or timeouts in page objects withConfig.baseUrl()andConfig.timeout(). - Stretch — measure the speed difference. Run your 20+ test suite with
new ChromeDriver()in every test (old way), then withThreadLocalSingleton (new way). Time both. The difference illustrates exactly what the pattern buys.
Next lesson: the Factory pattern — how to abstract driver creation and test data generation behind a clean interface that hides all the configuration complexity.