Singleton Pattern for Driver Management

8 min read

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 tests
  • Test 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. The ThreadLocal prevents garbage collection of the WebDriver after 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 ThreadLocal in a @BeforeSuite method. @BeforeSuite runs in a single thread, not the test threads. The driver created there is in a different thread's ThreadLocal — test threads read null and create their own, making the @BeforeSuite driver an orphan that never gets quit.
  • Using Singleton for page objects. A LoginPage singleton 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 @BeforeMethod or the test itself.

🎯 Practice task

Implement and validate ThreadLocal driver management — 30 minutes.

  1. Implement DriverManager. Create a DriverManager class with ThreadLocal<WebDriver>. Wire it into your BaseTest @BeforeMethod and @AfterMethod. Confirm that existing tests still pass with sequential execution.
  2. Enable parallel execution. In your TestNG XML suite file, add parallel="methods" thread-count="3". Run the suite. Without ThreadLocal, you'll see NoSuchSessionException and non-deterministic failures. With ThreadLocal, the suite should pass.
  3. 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.
  4. Config singleton. Create a Config class (not using ThreadLocal — config is read-only) that reads config.properties once and caches the result. Replace any hardcoded URLs or timeouts in page objects with Config.baseUrl() and Config.timeout().
  5. Stretch — measure the speed difference. Run your 20+ test suite with new ChromeDriver() in every test (old way), then with ThreadLocal Singleton (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.

// tip to track lessons you complete and pick up where you left off across devices.