Design Patterns for Automation — Factory, Strategy, Builder and Singleton

Design patterns are proven solutions to recurring problems. In test automation, four patterns appear again and again: Factory (creating objects), Strategy (swapping algorithms), Builder (constructing complex objects step by step), and Singleton (ensuring one instance). Understanding these patterns elevates your framework from a collection of scripts to a maintainable software system.

Four Essential Design Patterns for Test Automation

Each pattern solves a specific problem that every growing framework encounters.

# ── PATTERN 1: Factory — creating the right object from configuration ──

class DriverFactory:
    _creators = {
        "chrome": lambda opts: webdriver.Chrome(options=opts),
        "firefox": lambda opts: webdriver.Firefox(options=opts),
        "edge": lambda opts: webdriver.Edge(options=opts),
    }

    @classmethod
    def register(cls, name, creator):
        cls._creators[name] = creator

    @classmethod
    def create(cls, browser_name, **kwargs):
        creator = cls._creators.get(browser_name.lower())
        if not creator:
            raise ValueError(f"Unknown browser: {browser_name}")
        return creator(kwargs.get("options"))

# Extend without modifying: DriverFactory.register("safari", create_safari)


# ── PATTERN 2: Strategy — swapping behaviour at runtime ──

class WaitStrategy:
    def wait_for(self, driver, locator, timeout):
        raise NotImplementedError

class ExplicitWaitStrategy(WaitStrategy):
    def wait_for(self, driver, locator, timeout):
        return WebDriverWait(driver, timeout).until(
            EC.visibility_of_element_located(locator)
        )

class PollingWaitStrategy(WaitStrategy):
    def wait_for(self, driver, locator, timeout):
        end = time.time() + timeout
        while time.time() < end:
            elements = driver.find_elements(*locator)
            if elements and elements[0].is_displayed():
                return elements[0]
            time.sleep(0.25)
        raise TimeoutError(f"Element not found: {locator}")

class BasePage:
    def __init__(self, driver, wait_strategy=None):
        self.driver = driver
        self.wait = wait_strategy or ExplicitWaitStrategy()

    def find(self, locator, timeout=10):
        return self.wait.wait_for(self.driver, locator, timeout)

# Swap strategy without changing BasePage or any PageObject:
# page = LoginPage(driver, wait_strategy=PollingWaitStrategy())


# ── PATTERN 3: Builder — constructing complex test data step by step ──

class UserBuilder:
    def __init__(self):
        self._data = {
            "name": f"User_{int(time.time())}",
            "email": f"user_{int(time.time())}@test.com",
            "password": "TestPass1!",
            "role": "customer",
            "verified": True,
        }

    def with_name(self, name):
        self._data["name"] = name
        return self

    def with_role(self, role):
        self._data["role"] = role
        return self

    def unverified(self):
        self._data["verified"] = False
        return self

    def build(self):
        return dict(self._data)

# Usage — readable and flexible:
# admin = UserBuilder().with_name("Admin").with_role("admin").build()
# unverified = UserBuilder().unverified().build()
# default = UserBuilder().build()  # sensible defaults


# ── PATTERN 4: Singleton — ensuring one shared instance ──

class ConfigManager:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._load_config()
        return cls._instance

    def _load_config(self):
        self.base_url = os.getenv("BASE_URL", "https://staging.app.com")
        self.timeout = int(os.getenv("TIMEOUT", "10"))
        self.browser = os.getenv("BROWSER", "chrome")
        self.headless = os.getenv("HEADLESS", "true") == "true"

# Every call returns the SAME instance:
# config1 = ConfigManager()
# config2 = ConfigManager()
# config1 is config2  → True


# Pattern summary
PATTERNS = [
    ("Factory",   "Create the right object from a name/config",   "DriverFactory, PageObjectFactory"),
    ("Strategy",  "Swap an algorithm without changing the caller", "WaitStrategy, ReportStrategy, DataStrategy"),
    ("Builder",   "Construct complex objects step by step",        "UserBuilder, OrderBuilder, AddressBuilder"),
    ("Singleton", "Ensure exactly one instance of a class",        "ConfigManager, DatabaseConnection, Logger"),
]

import os, time
print("Design Patterns for Test Automation")
print("=" * 70)
for name, purpose, examples in PATTERNS:
    print(f"\n  {name}")
    print(f"    Purpose:  {purpose}")
    print(f"    Examples: {examples}")
Note: The Builder pattern is the most underused pattern in test automation, yet it solves one of the most common problems: creating test data that is readable, flexible, and has sensible defaults. UserBuilder().with_role("admin").build() is dramatically more readable than {"name": "User_123", "email": "...", "password": "...", "role": "admin", "verified": True}. The builder provides defaults for every field, so each test specifies only the fields that matter for that scenario — making test intent crystal clear.
Tip: The Factory pattern with a registration mechanism (DriverFactory.register("safari", creator)) follows the Open/Closed Principle — new browsers are added by registering a creator function, not by modifying the factory's if/elif chain. This extensibility is what makes the pattern production-grade: teams can add custom browsers, mock drivers for unit testing, or specialised configurations without touching the factory's core logic.
Warning: The Singleton pattern is convenient for configuration and logging but dangerous for mutable shared state. A Singleton TestDataManager that tracks created test data across tests introduces hidden coupling — one test's data setup affects another test's expectations. Use Singletons only for read-only, immutable state (configuration, constants). For mutable state (test data, browser sessions), use per-test instances created by fixtures.

Common Mistakes

Mistake 1 — Using inheritance when Strategy pattern is appropriate

❌ Wrong: ChromeBasePage, FirefoxBasePage, GridBasePage — duplicating BasePage for each variation.

✅ Correct: One BasePage that accepts a WaitStrategy or DriverFactory — behaviour varies by composition, not inheritance.

Mistake 2 — Building test data with raw dictionaries instead of Builders

❌ Wrong: Every test constructs a 10-field dictionary with all user attributes, even when only the role matters.

✅ Correct: UserBuilder().with_role("admin").build() — defaults handle the 9 irrelevant fields; the test expresses only what matters.

🧠 Test Yourself

A test needs an admin user with an unverified email. Using the Builder pattern, which code creates this user?