Every QA Automation Engineer eventually faces the same challenge: starting a framework from a blank project. Tutorials teach you how to write a Selenium test. They rarely teach you how to build the infrastructure that makes those tests maintainable, scalable, and reliable across a real team.
This guide walks through every layer of a production-ready Selenium framework — from Maven project setup to CI/CD integration — with code you can use immediately.
Last updated: June 2026
Table of Contents
- What Is a Selenium Framework and Why Build One?
- Step 1: Set Up Your Maven Project
- Step 2: Add Your Core Dependencies
- Step 3: Build the Driver Factory
- Step 4: Implement the Page Object Model
- Step 5: Create a Base Test Class
- Step 6: Add Configuration Management
- Step 7: Build Wait Utilities
- Step 8: Configure TestNG for Parallel Execution
- Step 9: Add Reporting
- Step 10: Connect to CI/CD
- Recommended Project Structure
What Is a Selenium Framework and Why Build One?
A Selenium framework is the organized set of classes, utilities, and configuration that surrounds your test logic. Without one, tests are brittle, duplicated, and impossible to scale.
A well-built framework provides:
- A single place to initialize and tear down the browser
- Reusable page representations (Page Object Model)
- Centralized configuration so no credentials are hardcoded
- Reliable waits that prevent flaky tests
- Parallel execution so the full suite runs in minutes
- Reporting that gives teams actionable pass/fail information
Building one from scratch teaches you exactly how each piece fits together — which is also what interviewers expect SDETs to explain on the spot.
Step 1: Set Up Your Maven Project
Maven manages dependencies and the build lifecycle. Create a standard Maven project with the following structure:
selenium-framework/
├── pom.xml
└── src/
├── main/java/
└── test/
├── java/
└── resources/In your IDE, create a new Maven project and set the groupId and artifactId:
<groupId>com.dmvtek</groupId>
<artifactId>selenium-framework</artifactId>
<version>1.0.0</version>Set the Java compiler version in pom.xml:
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>Step 2: Add Your Core Dependencies
Add these dependencies to pom.xml:
<dependencies>
<!-- Selenium WebDriver -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.21.0</version>
</dependency>
<!-- WebDriverManager — auto-downloads browser drivers -->
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.8.0</version>
</dependency>
<!-- TestNG -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.10.2</version>
<scope>test</scope>
</dependency>
<!-- Extent Reports -->
<dependency>
<groupId>com.aventstack</groupId>
<artifactId>extentreports</artifactId>
<version>5.1.2</version>
</dependency>
</dependencies>Also add the Maven Surefire Plugin so TestNG runs during mvn test:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<suiteXmlFiles>
<suiteXmlFile>src/test/resources/testng.xml</suiteXmlFile>
</suiteXmlFiles>
</configuration>
</plugin>
</plugins>
</build>Step 3: Build the Driver Factory
The Driver Factory is responsible for creating, configuring, and storing a WebDriver instance per thread. Using ThreadLocal is essential for parallel execution — without it, threads share a single driver and tests interfere with each other.
Create src/main/java/com/dmvtek/driver/DriverFactory.java:
package com.dmvtek.driver;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
public class DriverFactory {
private static final ThreadLocal<WebDriver> driver = new ThreadLocal<>();
public static void initDriver(String browser) {
if ("chrome".equalsIgnoreCase(browser)) {
WebDriverManager.chromedriver().setup();
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new", "--no-sandbox", "--disable-dev-shm-usage");
driver.set(new ChromeDriver(options));
}
getDriver().manage().window().maximize();
}
public static WebDriver getDriver() {
return driver.get();
}
public static void quitDriver() {
if (driver.get() != null) {
driver.get().quit();
driver.remove();
}
}
}The ThreadLocal ensures each parallel test thread gets its own isolated browser session.
Step 4: Implement the Page Object Model
The Page Object Model (POM) wraps each page of your application in a dedicated class. This separates locators and page actions from test logic, so a UI change only requires updating one class — not every test that touches that page.
Create src/main/java/com/dmvtek/pages/LoginPage.java:
package com.dmvtek.pages;
import com.dmvtek.driver.DriverFactory;
import com.dmvtek.utils.WaitUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
public class LoginPage {
private final WebDriver driver;
private final By usernameField = By.id("username");
private final By passwordField = By.id("password");
private final By loginButton = By.id("login-btn");
private final By errorMessage = By.cssSelector(".error-message");
public LoginPage() {
this.driver = DriverFactory.getDriver();
}
public void enterUsername(String username) {
WaitUtils.waitForVisible(usernameField).sendKeys(username);
}
public void enterPassword(String password) {
driver.findElement(passwordField).sendKeys(password);
}
public void clickLogin() {
driver.findElement(loginButton).click();
}
public String getErrorMessage() {
return WaitUtils.waitForVisible(errorMessage).getText();
}
}Keep page classes free of assertions. Assertions belong in the test layer.
Step 5: Create a Base Test Class
The Base Test class handles setup and teardown for every test. Using TestNG's @BeforeMethod and @AfterMethod ensures the browser is initialized before each test and closed after, even when a test fails.
Create src/test/java/com/dmvtek/base/BaseTest.java:
package com.dmvtek.base;
import com.dmvtek.driver.DriverFactory;
import com.dmvtek.utils.ConfigReader;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
public class BaseTest {
@BeforeMethod
public void setUp() {
DriverFactory.initDriver(ConfigReader.get("browser"));
DriverFactory.getDriver().get(ConfigReader.get("base.url"));
}
@AfterMethod
public void tearDown() {
DriverFactory.quitDriver();
}
}Every test class extends BaseTest and inherits this lifecycle automatically.
Step 6: Add Configuration Management
Hardcoding URLs, credentials, or browser names into test code is a common mistake that makes frameworks fragile and environment-specific. A properties file read through a singleton utility solves this cleanly.
Create src/test/resources/config.properties:
browser=chrome
base.url=https://your-app.com
timeout=10Create src/main/java/com/dmvtek/utils/ConfigReader.java:
package com.dmvtek.utils;
import java.io.InputStream;
import java.util.Properties;
public class ConfigReader {
private static final Properties props = new Properties();
static {
try (InputStream in = ConfigReader.class
.getClassLoader()
.getResourceAsStream("config.properties")) {
props.load(in);
} catch (Exception e) {
throw new RuntimeException("Could not load config.properties", e);
}
}
public static String get(String key) {
String value = System.getProperty(key);
return (value != null) ? value : props.getProperty(key);
}
}Checking System.getProperty first lets CI/CD pipelines override values at runtime without touching the file — for example, mvn test -Dbase.url=https://staging.your-app.com.
Step 7: Build Wait Utilities
Flaky tests are almost always caused by inadequate synchronization. Implicit waits are unreliable in mixed codebases. Explicit waits, centralized in a utility class, give you precise control over every interaction.
Create src/main/java/com/dmvtek/utils/WaitUtils.java:
package com.dmvtek.utils;
import com.dmvtek.driver.DriverFactory;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
public class WaitUtils {
private static WebDriverWait getWait() {
long timeout = Long.parseLong(ConfigReader.get("timeout"));
return new WebDriverWait(DriverFactory.getDriver(), Duration.ofSeconds(timeout));
}
public static WebElement waitForVisible(By locator) {
return getWait().until(ExpectedConditions.visibilityOfElementLocated(locator));
}
public static WebElement waitForClickable(By locator) {
return getWait().until(ExpectedConditions.elementToBeClickable(locator));
}
public static boolean waitForInvisible(By locator) {
return getWait().until(ExpectedConditions.invisibilityOfElementLocated(locator));
}
}The timeout comes from config.properties and can be overridden per environment.
Step 8: Configure TestNG for Parallel Execution
TestNG controls which tests run, in what order, and how many run simultaneously. Create src/test/resources/testng.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Selenium Framework Suite" parallel="methods" thread-count="4">
<test name="Login Tests">
<classes>
<class name="com.dmvtek.tests.LoginTest"/>
</classes>
</test>
</suite>parallel="methods" runs each @Test method on its own thread. The thread-count controls how many run simultaneously. Because the Driver Factory uses ThreadLocal, each thread gets its own isolated browser instance.
Step 9: Add Reporting
Extent Reports generates rich HTML reports with test status, timestamps, screenshots on failure, and log output. Wire it into TestNG using a listener.
Create src/main/java/com/dmvtek/listeners/ExtentReportListener.java:
package com.dmvtek.listeners;
import com.aventstack.extentreports.ExtentReports;
import com.aventstack.extentreports.ExtentTest;
import com.aventstack.extentreports.reporter.ExtentSparkReporter;
import org.testng.ITestListener;
import org.testng.ITestResult;
public class ExtentReportListener implements ITestListener {
private static final ExtentReports extent = new ExtentReports();
private static final ThreadLocal<ExtentTest> test = new ThreadLocal<>();
static {
ExtentSparkReporter spark = new ExtentSparkReporter("target/extent-report.html");
spark.config().setReportName("Selenium Framework Report");
extent.attachReporter(spark);
}
@Override
public void onTestStart(ITestResult result) {
test.set(extent.createTest(result.getMethod().getMethodName()));
}
@Override
public void onTestSuccess(ITestResult result) {
test.get().pass("Test passed");
}
@Override
public void onTestFailure(ITestResult result) {
test.get().fail(result.getThrowable());
}
@Override
public void onFinish(org.testng.ITestContext context) {
extent.flush();
}
}Register the listener in testng.xml:
<suite name="Selenium Framework Suite" parallel="methods" thread-count="4">
<listeners>
<listener class-name="com.dmvtek.listeners.ExtentReportListener"/>
</listeners>
...
</suite>After each run, open target/extent-report.html to see a full breakdown of results.
Step 10: Connect to CI/CD
A framework that only runs locally provides limited value. Connecting to a CI/CD pipeline ensures tests run automatically on every push or pull request.
Create .github/workflows/selenium.yml for GitHub Actions:
name: Selenium Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Run Selenium Tests
run: mvn test -Dbase.url=${{ secrets.APP_URL }}
- name: Upload Extent Report
if: always()
uses: actions/upload-artifact@v4
with:
name: extent-report
path: target/extent-report.htmlStore sensitive values like APP_URL in GitHub Actions Secrets, not in the repository. The -Dbase.url flag overrides config.properties at runtime via the ConfigReader system property check built in Step 6.
Recommended Project Structure
After completing all steps, your framework should look like this:
selenium-framework/
├── pom.xml
├── .github/
│ └── workflows/
│ └── selenium.yml
└── src/
├── main/java/com/dmvtek/
│ ├── driver/
│ │ └── DriverFactory.java
│ ├── pages/
│ │ └── LoginPage.java
│ ├── utils/
│ │ ├── ConfigReader.java
│ │ └── WaitUtils.java
│ └── listeners/
│ └── ExtentReportListener.java
└── test/
├── java/com/dmvtek/
│ ├── base/
│ │ └── BaseTest.java
│ └── tests/
│ └── LoginTest.java
└── resources/
├── config.properties
└── testng.xmlThis structure scales naturally. Add new page classes as you cover more of the application. Add new test classes as coverage grows. The utilities, driver, and base class stay unchanged.
Learn Framework Development With Real Projects
Understanding each component individually is valuable. Knowing how to build one end-to-end — from blank project to CI/CD pipeline — is what separates junior QA engineers from candidates who command strong offers.
DMVTEK's SDET training program walks through complete framework development with hands-on projects, code reviews, and direct interview preparation. Students build real automation frameworks they can show during their job search.
Explore the SDET Program or contact our team to learn how DMVTEK supports your automation engineering career.
Frequently Asked Questions
Do I need to use Maven, or can I use Gradle?
Either works. Maven is more common in enterprise Selenium frameworks and is what most interviewers expect candidates to be familiar with. The framework structure and patterns described here apply equally to a Gradle project with minor syntax differences.
Should I use WebDriverManager or Selenium Manager?
Selenium 4.6+ ships with Selenium Manager, which auto-downloads browser drivers without any additional dependency. WebDriverManager offers more configuration options and has been battle-tested across more environments. Both are valid — pick one and be consistent.
What is the difference between implicit and explicit waits in Selenium?
An implicit wait tells WebDriver to poll for an element for a set duration globally. An explicit wait targets a specific condition on a specific element. Mixing both can cause unpredictable wait times. The pattern in this guide uses only explicit waits through WaitUtils to keep synchronization predictable.
How do I run tests against a specific browser from the command line?
Pass the browser as a system property: mvn test -Dbrowser=firefox. The ConfigReader.get("browser") call checks System.getProperty first, so the command-line value overrides config.properties without any code changes.
How many threads should I use for parallel execution?
Start with 2–4 threads and scale up based on your machine or CI runner capacity. More threads are not always faster — the bottleneck shifts to browser startup time and available CPU. Test with your specific suite to find the optimal number.
Conclusion
A Selenium framework is not a single file — it is a set of layered decisions that determine how stable, maintainable, and scalable your automation will be. The Driver Factory handles thread safety. The Page Object Model handles maintainability. Configuration management handles environment flexibility. Wait utilities handle synchronization. TestNG and CI/CD handle execution at scale.
Build each layer deliberately and you will have a framework that supports a real team — and a project you can speak to confidently in every QA Automation Engineer interview.