Singleton Pattern

Driver Manager, Config Reader, thread-safe patterns

What is the Singleton Pattern?

Singleton ensures a class has exactly one instance and provides a global point of access to it. The class itself controls its instantiation — no one else can create a second instance.

Why It Matters in Test Automation

ComponentWhy Singleton?
Config ReaderLoad properties file once, read everywhere
LoggerOne logger instance for consistent logging
Database ConnectionAvoid opening multiple connections
Report ManagerSingle report instance collects all results

Classic Singleton Implementation

The Three Rules of Singleton

  1. Private constructor — no one can new it from outside
  2. Private static instance — the single instance lives inside the class
  3. Public static getter — everyone accesses the instance through this method
public class ConfigReader {
    // 1. Private static instance
    private static ConfigReader instance;

    // Class data
    private final Properties properties;

    // 2. Private constructor — cannot be called from outside
    private ConfigReader() {
        properties = new Properties();
        try (FileInputStream fis =
                new FileInputStream("config/config.properties")) {
            properties.load(fis);
        } catch (IOException e) {
            throw new RuntimeException("Cannot load config", e);
        }
    }

    // 3. Public static getter — the only way to get the instance
    public static ConfigReader getInstance() {
        if (instance == null) {
            instance = new ConfigReader();
        }
        return instance;
    }

    // Business methods
    public String get(String key) {
        return properties.getProperty(key);
    }

    public String get(String key, String defaultValue) {
        return properties.getProperty(key, defaultValue);
    }

    public int getInt(String key) {
        return Integer.parseInt(properties.getProperty(key));
    }

    public boolean getBoolean(String key) {
        return Boolean.parseBoolean(properties.getProperty(key));
    }
}

// Usage — anywhere in your framework
String baseUrl = ConfigReader.getInstance().get("base.url");
int timeout = ConfigReader.getInstance().getInt("implicit.wait");
boolean headless = ConfigReader.getInstance().getBoolean("headless");

Thread-Safe Singleton

Three approaches, from simplest to most robust:

Approach 1: Synchronized Method

public static synchronized ConfigReader getInstance() {
    if (instance == null) {
        instance = new ConfigReader();
    }
    return instance;
}
// Works, but synchronized on EVERY call — slower than needed
public class ConfigReader {
    private static volatile ConfigReader instance;
    private final Properties properties;

    private ConfigReader() {
        properties = new Properties();
        try (FileInputStream fis =
                new FileInputStream("config/config.properties")) {
            properties.load(fis);
        } catch (IOException e) {
            throw new RuntimeException("Cannot load config", e);
        }
    }

    public static ConfigReader getInstance() {
        if (instance == null) {                    // First check (no lock)
            synchronized (ConfigReader.class) {
                if (instance == null) {            // Second check (with lock)
                    instance = new ConfigReader();
                }
            }
        }
        return instance;                           // No lock on subsequent calls
    }

    public String get(String key) {
        return properties.getProperty(key);
    }
}

Approach 3: Enum Singleton (Simplest)

public enum ConfigReader {
    INSTANCE;

    private final Properties properties;

    ConfigReader() {
        properties = new Properties();
        try (FileInputStream fis =
                new FileInputStream("config/config.properties")) {
            properties.load(fis);
        } catch (IOException e) {
            throw new RuntimeException("Cannot load config", e);
        }
    }

    public String get(String key) {
        return properties.getProperty(key);
    }

    public int getInt(String key) {
        return Integer.parseInt(properties.getProperty(key));
    }

    public boolean getBoolean(String key) {
        return Boolean.parseBoolean(properties.getProperty(key));
    }
}

// Usage
String url = ConfigReader.INSTANCE.get("base.url");
int timeout = ConfigReader.INSTANCE.getInt("implicit.wait");
ApproachThread-Safe?PerformanceComplexity
Classic (no sync)NoFastSimple
Synchronized methodYesSlow (locks every call)Simple
Double-checked lockingYesFast (locks only first time)Medium
EnumYesFastSimplest

Config Reader Singleton — Complete

A production-ready config reader with environment support:

public class ConfigReader {
    private static volatile ConfigReader instance;
    private final Properties properties;

    private ConfigReader() {
        properties = new Properties();
        String env = System.getProperty("env", "config");
        String filePath = "config/" + env + ".properties";

        try (FileInputStream fis = new FileInputStream(filePath)) {
            properties.load(fis);
        } catch (IOException e) {
            throw new RuntimeException(
                "Cannot load config from: " + filePath, e);
        }
    }

    public static ConfigReader getInstance() {
        if (instance == null) {
            synchronized (ConfigReader.class) {
                if (instance == null) {
                    instance = new ConfigReader();
                }
            }
        }
        return instance;
    }

    // Reset for testing or environment switch
    public static void reset() {
        synchronized (ConfigReader.class) {
            instance = null;
        }
    }

    public String get(String key) {
        // System property overrides file property
        String sysValue = System.getProperty(key);
        return sysValue != null ? sysValue : properties.getProperty(key);
    }

    public String get(String key, String defaultValue) {
        String value = get(key);
        return value != null ? value : defaultValue;
    }

    public int getInt(String key, int defaultValue) {
        String value = get(key);
        return value != null ? Integer.parseInt(value) : defaultValue;
    }

    public boolean getBoolean(String key, boolean defaultValue) {
        String value = get(key);
        return value != null ? Boolean.parseBoolean(value) : defaultValue;
    }

    public String getBaseUrl() { return get("base.url"); }
    public String getBrowser() { return get("browser", "chrome"); }
    public boolean isHeadless() { return getBoolean("headless", false); }
    public int getTimeout() { return getInt("implicit.wait", 10); }
}

// Usage everywhere
ConfigReader config = ConfigReader.getInstance();
driver.get(config.getBaseUrl());
// Or with command-line override:
// mvn test -Dbase.url=https://staging.example.com

ThreadLocal Driver Manager

In parallel execution, each thread needs its own WebDriver. A classic Singleton would share one driver across threads — causing chaos. The solution: ThreadLocal + Singleton.

Without ThreadLocal (Broken in Parallel)

// DON'T DO THIS — all threads share the same driver!
public class DriverManager {
    private static WebDriver driver; // Shared = broken in parallel

    public static WebDriver getDriver() {
        if (driver == null) {
            driver = new ChromeDriver();
        }
        return driver;
    }
}

With ThreadLocal (Correct)

public class DriverManager {
    // Each thread gets its own WebDriver instance
    private static final ThreadLocal<WebDriver> driver =
        new ThreadLocal<>();

    public static void setDriver(WebDriver webDriver) {
        driver.set(webDriver);
    }

    public static WebDriver getDriver() {
        return driver.get();
    }

    public static void initDriver() {
        if (driver.get() == null) {
            WebDriver webDriver = DriverFactory.createDriver();
            driver.set(webDriver);
        }
    }

    public static void quitDriver() {
        WebDriver webDriver = driver.get();
        if (webDriver != null) {
            webDriver.quit();
            driver.remove(); // Prevent memory leak!
        }
    }
}

// Usage in Cucumber Hooks
@Before("@ui")
public void setUp() {
    DriverManager.initDriver();
}

@After("@ui")
public void tearDown(Scenario scenario) {
    if (scenario.isFailed()) {
        byte[] screenshot = ((TakesScreenshot) DriverManager.getDriver())
            .getScreenshotAs(OutputType.BYTES);
        scenario.attach(screenshot, "image/png", "failure");
    }
    DriverManager.quitDriver();
}

// Usage in step definitions
@Given("I am on the login page")
public void navigateToLogin() {
    DriverManager.getDriver().get(
        ConfigReader.getInstance().getBaseUrl() + "/login"
    );
}

Logger Singleton

A custom test logger that writes to console and file:

public class TestLogger {
    private static volatile TestLogger instance;
    private final Logger logger;

    private TestLogger() {
        logger = LogManager.getLogger("TestAutomation");
    }

    public static TestLogger getInstance() {
        if (instance == null) {
            synchronized (TestLogger.class) {
                if (instance == null) {
                    instance = new TestLogger();
                }
            }
        }
        return instance;
    }

    public void info(String message) {
        logger.info(message);
    }

    public void error(String message, Throwable throwable) {
        logger.error(message, throwable);
    }

    public void step(String stepDescription) {
        logger.info("STEP: " + stepDescription);
    }

    public void pass(String message) {
        logger.info("PASS: " + message);
    }

    public void fail(String message) {
        logger.error("FAIL: " + message);
    }
}

// Usage in step definitions
@When("I login with {string} and {string}")
public void login(String username, String password) {
    TestLogger.getInstance().step(
        "Logging in with user: " + username
    );
    loginPage.login(username, password);
    TestLogger.getInstance().pass("Login successful");
}

Singleton vs Dependency Injection

Modern frameworks often use Dependency Injection (PicoContainer, Spring) instead of Singleton. Here’s when each is appropriate:

AspectSingletonDependency Injection
ScopeApplication-wide (global)Per-scenario or per-context
TestingHard to mock/replaceEasy to mock/swap
StatePersists across scenariosFresh per scenario
Parallel safetyNeeds ThreadLocalBuilt-in isolation
Best forConfig, Logger, ConstantsDriver, TestContext, Page Objects

When to Use & Common Mistakes

Common Mistakes

MistakeProblemFix
Singleton WebDriver without ThreadLocalParallel tests share one browserUse ThreadLocal or DI
Forgetting volatilePartially constructed instance visible to other threadsAlways use volatile with double-checked locking
No remove() on ThreadLocalMemory leaks in thread poolsAlways call remove() in teardown
Making everything a SingletonHidden dependencies, hard to testOnly use for truly global resources

Summary