Factory Pattern for Test Data and Drivers

9 min read

Tests shouldn't care how objects are constructed. A test that says "give me a Chrome driver configured for staging" should not contain a single line about ChromeOptions, WebDriverManager.chromedriver().setup(), or how staging environment credentials are passed. A test that says "give me an admin user" should not contain an email format decision, a UUID strategy, or a builder call chain. The Factory pattern is the design decision that separates object creation from object use. You've already used factories without naming them — DriverFactory.create("chrome") and UserFactory.admin() are factories. This lesson makes the pattern explicit, shows why it matters, and builds the two factories that every production framework needs.

What the Factory pattern does

A factory is a method or class whose single job is to create and return objects. The caller says what it wants; the factory decides how to build it. The caller never knows the construction details.

Without a factory — creation logic bleeds into every test:

@BeforeMethod
public void setUp() {
    // Every test that needs Chrome has to know all of this
    WebDriverManager.chromedriver().setup();
    ChromeOptions options = new ChromeOptions();
    options.addArguments("--no-sandbox", "--disable-dev-shm-usage");
    if (System.getenv("CI") != null) {
        options.addArguments("--headless=new");
    }
    driver = new ChromeDriver(options);
    driver.manage().window().maximize();
}

With a factory — the test says what it wants, nothing more:

@BeforeMethod
public void setUp() {
    driver = DriverFactory.create(Config.browser());
}

When CI requirements change (add a new headless flag, swap WebDriverManager for Selenium Manager), one file changes. Zero tests change.

Driver Factory

A driver factory encapsulates browser creation, capability configuration, and environment-specific options:

public class DriverFactory {
 
    public static WebDriver create(String browser) {
        return switch (browser.toLowerCase()) {
            case "chrome"  -> buildChrome();
            case "firefox" -> buildFirefox();
            case "edge"    -> buildEdge();
            default -> throw new IllegalArgumentException("Unsupported browser: " + browser);
        };
    }
 
    private static WebDriver buildChrome() {
        WebDriverManager.chromedriver().setup();
        ChromeOptions opts = new ChromeOptions();
        opts.addArguments("--no-sandbox", "--disable-dev-shm-usage");
        if (isCI()) opts.addArguments("--headless=new", "--window-size=1920,1080");
        return new ChromeDriver(opts);
    }
 
    private static WebDriver buildFirefox() {
        WebDriverManager.firefoxdriver().setup();
        FirefoxOptions opts = new FirefoxOptions();
        if (isCI()) opts.addArguments("--headless");
        return new FirefoxDriver(opts);
    }
 
    private static WebDriver buildEdge() {
        WebDriverManager.edgedriver().setup();
        EdgeOptions opts = new EdgeOptions();
        if (isCI()) opts.addArguments("--headless=new");
        return new EdgeDriver(opts);
    }
 
    private static boolean isCI() {
        return System.getenv("CI") != null;
    }
}

The same pattern works in Python and TypeScript:

# Python — Playwright browser factory
def create_browser(browser_name: str, headless: bool = True):
    playwright = sync_playwright().start()
    match browser_name.lower():
        case "chromium": return playwright.chromium.launch(headless=headless)
        case "firefox":  return playwright.firefox.launch(headless=headless)
        case "webkit":   return playwright.webkit.launch(headless=headless)
        case _: raise ValueError(f"Unknown browser: {browser_name}")
// TypeScript — Playwright browser factory (in fixture)
async function createBrowser(browserName: string): Promise<Browser> {
  const pw = await chromium.launch ? { chromium, firefox, webkit } : null;
  const browsers: Record<string, BrowserType> = { chromium, firefox, webkit };
  const type = browsers[browserName.toLowerCase()];
  if (!type) throw new Error(`Unknown browser: ${browserName}`);
  return type.launch({ headless: process.env.CI === "true" });
}

Test Data Factory

Test data factories generate fresh, valid test data for each test — avoiding hardcoded values and eliminating collision between parallel tests.

public class UserFactory {
 
    public static User randomUser() {
        String suffix = UUID.randomUUID().toString().substring(0, 8);
        return User.builder()
            .name("TestUser-" + suffix)
            .email("user-" + suffix + "@test.example.com")
            .role("tester")
            .active(true)
            .build();
    }
 
    public static User admin() {
        return randomUser().toBuilder()
            .name("AdminUser")
            .role("admin")
            .build();
    }
 
    public static User withInvalidEmail() {
        return randomUser().toBuilder()
            .email("not-an-email-address")
            .build();
    }
 
    public static User inactive() {
        return randomUser().toBuilder()
            .active(false)
            .build();
    }
}

Tests express intent clearly:

@Test public void adminCanPublishPost() {
    User admin = UserFactory.admin();            // "I need an admin user"
    // ...
}
 
@Test public void inactiveUserCannotLogin() {
    User inactive = UserFactory.inactive();      // "I need an inactive user"
    // ...
}
 
@Test public void registrationRejectsInvalidEmail() {
    User badEmail = UserFactory.withInvalidEmail();  // "I need invalid data"
    // ...
}

Each test gets a unique email (UUID-based) — no collision when the same test runs on three threads simultaneously. When User gains a new required field, one factory method update covers every test.

Combining factories: the TestContext factory

For complex test setups, a TestContextFactory creates everything a test needs in one call:

@dataclass
class TestContext:
    driver: WebDriver
    login_page: LoginPage
    user: User
    admin_token: str
 
def build_context(browser: str = "chrome", role: str = "tester") -> TestContext:
    driver = DriverFactory.create(browser)
    user = UserFactory.for_role(role)
    token = ApiClient.create_user_and_get_token(user)
    return TestContext(
        driver=driver,
        login_page=LoginPage(driver),
        user=user,
        admin_token=token
    )

A test that needs a fully-configured context with a real user, a browser, and a pre-seeded API token calls build_context(role="admin") and receives everything ready. The alternative — five lines of setup in every test — is what the factory replaces.

When to use vs when not to use

Use a factory when:

  • Construction requires conditional logic (different options for CI vs local, different browser configs)
  • Multiple tests request the same type of object with slight variations
  • Construction is expensive or involves external state (API calls, database seeding)

Don't use a factory when:

  • The object is trivially constructed: new LoginPage(driver) is not a factory candidate
  • There is only one variation and it never changes: a factory with one case is just a wrapper
  • The caller legitimately needs to control the construction details

The Factory pattern earns its place when the question "how is this built?" has a non-trivial, possibly variable answer that no test should need to know.

⚠️ Common mistakes

  • Factory methods that leak implementation details. A factory method named createChromeDriverWithHeadlessAndNoSandboxAndWindowSize1920x1080() is not a factory — it's just a renamed constructor that forces callers to know the details. Factory names should express intent: createForCI(), createForLocal(), createHeadless().
  • Test data factories that return shared mutable objects. UserFactory.DEFAULT_ADMIN as a shared static field means every test that modifies it affects other tests. Factories should return new objects on every call. Mutability and sharing are the root of order-dependent test failures.
  • Factories that make network calls in unexpected places. A UserFactory.admin() that silently calls the user API to create a real user in the database couples the factory to a running environment. Make network-calling factories explicit (UserApiFactory.createAdminViaApi()), and keep in-memory data builders separate from live data creators.

🎯 Practice task

Build both factories for your project — 40 minutes.

  1. Driver factory. Create DriverFactory.create(browser) that handles Chrome, Firefox, and at least one other browser. Add headless support when the CI environment variable is set. Replace every new ChromeDriver() call in your project with DriverFactory.create(Config.browser()). Run the suite — all tests should pass.
  2. Data factory. Create UserFactory with at least four named methods: randomUser(), admin(), inactive(), and withInvalidEmail(). Replace every hardcoded user object in your tests with a factory call. Verify the emails are unique across 10 factory calls (print them; each should differ).
  3. Parallel collision test. Run three copies of the same test simultaneously — TestNG @Test(invocationCount=3, threadPoolSize=3) or pytest-xdist with -n3. Verify all three pass without UniqueConstraintViolation errors on the email field. This confirms the UUID-based factory generates collision-free data.
  4. Factory naming exercise. Review your test files and list every object that gets constructed inline in test methods. For each, decide: is this a factory candidate? Does the construction have conditional logic or variation? Write the factory method signature (not the implementation) for each candidate.
  5. Stretch — TestContext factory. Build a TestContextFactory that returns a context object containing a driver, a page object, and a test user. Rewrite three of your tests to call TestContextFactory.build() and use the returned context. Measure how many lines of setup code you eliminated.

Next lesson: the Builder pattern — how to construct complex objects with many optional fields without making constructors with 12 parameters.

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