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
| Component | Why Singleton? |
|---|---|
| Config Reader | Load properties file once, read everywhere |
| Logger | One logger instance for consistent logging |
| Database Connection | Avoid opening multiple connections |
| Report Manager | Single report instance collects all results |
Classic Singleton Implementation
The Three Rules of Singleton
- Private constructor — no one can
newit from outside - Private static instance — the single instance lives inside the class
- 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
Approach 2: Double-Checked Locking (Recommended)
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");
| Approach | Thread-Safe? | Performance | Complexity |
|---|---|---|---|
| Classic (no sync) | No | Fast | Simple |
| Synchronized method | Yes | Slow (locks every call) | Simple |
| Double-checked locking | Yes | Fast (locks only first time) | Medium |
| Enum | Yes | Fast | Simplest |
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:
| Aspect | Singleton | Dependency Injection |
|---|---|---|
| Scope | Application-wide (global) | Per-scenario or per-context |
| Testing | Hard to mock/replace | Easy to mock/swap |
| State | Persists across scenarios | Fresh per scenario |
| Parallel safety | Needs ThreadLocal | Built-in isolation |
| Best for | Config, Logger, Constants | Driver, TestContext, Page Objects |
When to Use & Common Mistakes
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Singleton WebDriver without ThreadLocal | Parallel tests share one browser | Use ThreadLocal or DI |
Forgetting volatile | Partially constructed instance visible to other threads | Always use volatile with double-checked locking |
No remove() on ThreadLocal | Memory leaks in thread pools | Always call remove() in teardown |
| Making everything a Singleton | Hidden dependencies, hard to test | Only use for truly global resources |