← Back to DevBytes

Selenium Architecture: Design Patterns and Project Structure

Selenium Architecture: Design Patterns and Project Structure

Building a Selenium test automation suite that scales beyond a handful of scripts requires more than just WebDriver commands. Without a thoughtful architecture, test suites quickly become brittle, hard to maintain, and impossible to extend. This tutorial walks through the foundational design patterns and project structures that professional teams use to build robust Selenium frameworks.

What Is Selenium Architecture?

Selenium architecture refers to the way you organize your test automation code, abstractions, utilities, and configuration so that the suite remains maintainable as it grows. While Selenium WebDriver itself is a library that drives browsers, the architecture around it determines whether your tests survive UI changes, run in parallel, and integrate cleanly with CI/CD pipelines.

A well-designed Selenium architecture typically separates concerns into layers: test logic, business workflows, page interactions, and infrastructure. This separation lets you change one layer without rewriting the others.

Why It Matters

Core Design Patterns

1. Page Object Model (POM)

The Page Object Model is the cornerstone of Selenium architecture. Each page (or component) in your application is represented by a class that encapsulates its locators and actions. Tests interact with these objects instead of touching WebDriver directly.

package pages;

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;

public class LoginPage {

    private final WebDriver driver;
    private final WebDriverWait wait;

    @FindBy(id = "username")
    private WebElement usernameField;

    @FindBy(id = "password")
    private WebElement passwordField;

    @FindBy(css = "button[type='submit']")
    private WebElement loginButton;

    @FindBy(css = ".error-message")
    private WebElement errorMessage;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        PageFactory.initElements(driver, this);
    }

    public LoginPage open() {
        driver.get("https://example.com/login");
        return this;
    }

    public LoginPage enterUsername(String username) {
        wait.until(ExpectedConditions.visibilityOf(usernameField));
        usernameField.clear();
        usernameField.sendKeys(username);
        return this;
    }

    public LoginPage enterPassword(String password) {
        passwordField.clear();
        passwordField.sendKeys(password);
        return this;
    }

    public DashboardPage submit() {
        loginButton.click();
        return new DashboardPage(driver);
    }

    public String getErrorMessage() {
        wait.until(ExpectedConditions.visibilityOf(errorMessage));
        return errorMessage.getText();
    }
}

2. Page Factory and Fluent Interfaces

Notice the example above uses method chaining by returning this from action methods. This fluent style keeps tests concise and expressive.

@Test
public void userCanLoginWithValidCredentials() {
    DashboardPage dashboard = new LoginPage(driver)
        .open()
        .enterUsername("admin")
        .enterPassword("secret")
        .submit();

    Assert.assertTrue(dashboard.isWelcomeMessageDisplayed());
}

3. Singleton WebDriver Manager

Managing WebDriver lifecycle across tests is a common pain point. A singleton or thread-local manager ensures each test thread gets its own driver instance while keeping creation and teardown centralized.

package core;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;

import java.util.HashMap;
import java.util.Map;

public class DriverManager {

    private static final ThreadLocal<WebDriver> driverThread = new ThreadLocal<>();

    public static WebDriver getDriver() {
        if (driverThread.get() == null) {
            driverThread.set(createDriver());
        }
        return driverThread.get();
    }

    private static WebDriver createDriver() {
        ChromeOptions options = new ChromeOptions();
        Map<String, Object> prefs = new HashMap<>();
        prefs.put("download.default_directory", System.getProperty("user.dir") + "/downloads");
        options.setExperimentalOption("prefs", prefs);

        if (Boolean.parseBoolean(ConfigReader.get("headless"))) {
            options.addArguments("--headless=new");
        }

        return new ChromeDriver(options);
    }

    public static void quitDriver() {
        if (driverThread.get() != null) {
            driverThread.get().quit();
            driverThread.remove();
        }
    }
}

4. Factory Pattern for Browser Selection

When you need to support multiple browsers, a factory keeps instantiation logic in one place.

package core;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.edge.EdgeDriver;
import org.openqa.selenium.edge.EdgeOptions;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

public class BrowserFactory {

    public static WebDriver create(String browser) {
        return switch (browser.toLowerCase()) {
            case "firefox" -> new FirefoxDriver(new FirefoxOptions());
            case "edge" -> new EdgeDriver(new EdgeOptions());
            default -> new ChromeDriver(new ChromeOptions());
        };
    }
}

5. Builder Pattern for Test Data

Test data objects with many optional fields benefit from the builder pattern, keeping test setup readable.

package model;

public class User {

    public final String username;
    public final String password;
    public final String email;
    public final String role;

    private User(Builder builder) {
        this.username = builder.username;
        this.password = builder.password;
        this.email = builder.email;
        this.role = builder.role;
    }

    public static class Builder {
        private String username;
        private String password;
        private String email;
        private String role = "user";

        public Builder username(String username) {
            this.username = username;
            return this;
        }

        public Builder password(String password) {
            this.password = password;
            return this;
        }

        public Builder email(String email) {
            this.email = email;
            return this;
        }

        public Builder role(String role) {
            this.role = role;
            return this;
        }

        public User build() {
            return new User(this);
        }
    }
}

Recommended Project Structure

A clean directory layout makes the architecture tangible. Below is a Maven-based structure that scales well for medium to large projects.

selenium-framework/
├── pom.xml
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/example/automation/
│   │   │       ├── core/
│   │   │       │   ├── DriverManager.java
│   │   │       │   ├── BrowserFactory.java
│   │   │       │   ├── BasePage.java
│   │   │       │   └── BaseTest.java
│   │   │       ├── config/
│   │   │       │   ├── ConfigReader.java
│   │   │       │   └── Environment.java
│   │   │       ├── pages/
│   │   │       │   ├── LoginPage.java
│   │   │       │   ├── DashboardPage.java
│   │   │       │   └── CheckoutPage.java
│   │   │       ├── components/
│   │   │       │   ├── Header.java
│   │   │       │   └── CartWidget.java
│   │   │       ├── workflows/
│   │   │       │   ├── LoginWorkflow.java
│   │   │       │   └── PurchaseWorkflow.java
│   │   │       ├── model/
│   │   │       │   ├── User.java
│   │   │       │   └── Product.java
│   │   │       └── utils/
│   │   │           ├── WaitUtils.java
│   │   │           ├── ScreenshotUtils.java
│   │   │           └── JsonReader.java
│   │   └── resources/
│   │       ├── config/
│   │       │   ├── dev.properties
│   │       │   └── staging.properties
│   │       └── testdata/
│   │           └── users.json
│   └── test/
│       ├── java/
│       │   └── com/example/automation/tests/
│       │       ├── LoginTests.java
│       │       ├── CheckoutTests.java
│       │       └── SearchTests.java
│       └── resources/
│           ├── testng.xml
│           └── log4j2.xml

Base Classes for Reuse

Base classes provide shared setup and teardown logic so individual tests stay focused on assertions.

package core;

import org.openqa.selenium.WebDriver;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Parameters;

public class BaseTest {

    protected WebDriver driver;

    @Parameters("browser")
    @BeforeMethod
    public void setUp(String browser) {
        driver = BrowserFactory.create(browser);
        driver.manage().window().maximize();
        driver.get(ConfigReader.get("baseUrl"));
    }

    @AfterMethod
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }
}

The BasePage class centralizes common wait and interaction helpers.

package core;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;

public abstract class BasePage {

    protected WebDriver driver;
    protected WebDriverWait wait;

    public BasePage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        PageFactory.initElements(driver, this);
    }

    protected void click(WebElement element) {
        wait.until(ExpectedConditions.elementToBeClickable(element)).click();
    }

    protected void type(WebElement element, String text) {
        wait.until(ExpectedConditions.visibilityOf(element));
        element.clear();
        element.sendKeys(text);
    }

    protected String getText(WebElement element) {
        return wait.until(ExpectedConditions.visibilityOf(element)).getText();
    }

    protected boolean isVisible(WebElement element) {
        try {
            return wait.until(ExpectedConditions.visibilityOf(element)).isDisplayed();
        } catch (Exception e) {
            return false;
        }
    }
}

Configuration Management

Externalizing configuration keeps environment-specific values out of code. A simple ConfigReader loads properties based on the active environment.

package config;

import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;

public class ConfigReader {

    private static final Properties properties = new Properties();

    static {
        String env = System.getProperty("env", "dev");
        String path = "src/main/resources/config/" + env + ".properties";
        try (FileInputStream fis = new FileInputStream(path)) {
            properties.load(fis);
        } catch (IOException e) {
            throw new RuntimeException("Unable to load config: " + path, e);
        }
    }

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

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

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

An example properties file:

# dev.properties
baseUrl=https://dev.example.com
headless=true
implicitWait=5
explicitWait=10

Workflow Layer for Multi-Step Processes

When a test needs to perform a sequence of page actions, a workflow class encapsulates the choreography. This keeps tests high-level and avoids duplicating multi-step logic.

package workflows;

import model.User;
import pages.DashboardPage;
import pages.LoginPage;
import core.DriverManager;

public class LoginWorkflow {

    public static DashboardPage loginAs(User user) {
        return new LoginPage(DriverManager.getDriver())
            .open()
            .enterUsername(user.username)
            .enterPassword(user.password)
            .submit();
    }
}
@Test
public void adminCanAccessReports() {
    User admin = new User.Builder()
        .username("admin")
        .password("admin123")
        .role("admin")
        .build();

    DashboardPage dashboard = LoginWorkflow.loginAs(admin);
    Assert.assertTrue(dashboard.isReportsTabVisible());
}

Best Practices

Parallel Execution Example

TestNG makes parallel runs straightforward. Configure the suite XML to run tests across threads.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="RegressionSuite" parallel="methods" thread-count="4">
    <parameter name="browser" value="chrome"/>
    <test name="RegressionTests">
        <classes>
            <class name="com.example.automation.tests.LoginTests"/>
            <class name="com.example.automation.tests.CheckoutTests"/>
            <class name="com.example.automation.tests.SearchTests"/>
        </classes>
    </test>
</suite>

Failure Listeners for Screenshots

package core;

import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.testng.ITestListener;
import org.testng.ITestResult;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class ScreenshotListener implements ITestListener {

    @Override
    public void onTestFailure(ITestResult result) {
        WebDriver driver = DriverManager.getDriver();
        if (driver instanceof TakesScreenshot) {
            File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
            String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
            String dest = "screenshots/" + result.getName() + "-" + timestamp + ".png";
            try {
                Files.createDirectories(Paths.get("screenshots"));
                Files.copy(screenshot.toPath(), Paths.get(dest));
            } catch (IOException e) {
                System.err.println("Failed to save screenshot: " + e.getMessage());
            }
        }
    }
}

Conclusion

A solid Selenium architecture is the difference between a test suite that scales and one that collapses under its own weight. By applying the Page Object Model, factory and builder patterns, a layered project structure, and disciplined configuration management, you create a framework where adding new tests is fast, maintenance is localized, and parallel execution is natural. Start with these foundations early, refine them as your application evolves, and your automation will remain a reliable asset rather than a liability.

— Ad —

Google AdSense will appear here after approval

← Back to all articles