This lesson provides complete, working code for every key class in the capstone framework. Read through it once first, then implement each step in your own project. The code uses saucedemo.com as the target application — it is a purpose-built Selenium demo site with stable locators, a login page, an inventory page, and a shopping cart. Swap in your own target application's locators wherever you see By.id("user-name") and similar.
Step 1 — pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.mycompany.tests</groupId>
<artifactId>testng-selenium-framework</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.21.0</version>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.8.0</version>
</dependency>
<dependency>
<groupId>com.aventstack</groupId>
<artifactId>extentreports</artifactId>
<version>5.1.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
<scope>test</scope>
</dependency>
</dependencies>
<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/${suiteXmlFile:-smoke.xml}
</suiteXmlFile>
</suiteXmlFiles>
</configuration>
</plugin>
</plugins>
</build>
</project>Step 2 — DriverManager and BaseTest
// src/test/java/com/mycompany/tests/base/DriverManager.java
package com.mycompany.tests.base;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
public class DriverManager {
private static final ThreadLocal<WebDriver> driver = new ThreadLocal<>();
public static WebDriver getDriver() {
return driver.get();
}
public static void initDriver(String browser) {
boolean headless = Boolean.parseBoolean(
System.getProperty("headless", "false"));
WebDriver d = switch (browser.toLowerCase()) {
case "firefox" -> {
WebDriverManager.firefoxdriver().setup();
FirefoxOptions opts = new FirefoxOptions();
if (headless) opts.addArguments("--headless");
yield new FirefoxDriver(opts);
}
default -> {
WebDriverManager.chromedriver().setup();
ChromeOptions opts = new ChromeOptions();
if (headless) {
opts.addArguments("--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage");
}
yield new ChromeDriver(opts);
}
};
d.manage().window().maximize();
driver.set(d);
}
public static void quitDriver() {
WebDriver d = driver.get();
if (d != null) {
d.quit();
driver.remove();
}
}
}// src/test/java/com/mycompany/tests/base/BaseTest.java
package com.mycompany.tests.base;
import org.openqa.selenium.WebDriver;
import org.testng.annotations.*;
public class BaseTest {
protected WebDriver driver() {
return DriverManager.getDriver();
}
@BeforeMethod
@Parameters("browser")
public void setup(@Optional("chrome") String browser) {
String browserOverride = System.getProperty("browser", browser);
DriverManager.initDriver(browserOverride);
}
@AfterMethod(alwaysRun = true)
public void teardown() {
DriverManager.quitDriver();
}
}Step 3 — TestListener and ExtentReportListener
// src/test/java/com/mycompany/tests/listener/TestListener.java
package com.mycompany.tests.listener;
import com.mycompany.tests.base.DriverManager;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.testng.ITestListener;
import org.testng.ITestResult;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
public class TestListener implements ITestListener {
@Override
public void onTestStart(ITestResult result) {
System.out.printf("▶ %-60s%n", result.getName());
}
@Override
public void onTestSuccess(ITestResult result) {
long ms = result.getEndMillis() - result.getStartMillis();
System.out.printf("✅ %-55s %5dms%n", result.getName(), ms);
}
@Override
public void onTestFailure(ITestResult result) {
System.out.printf("❌ %-55s FAIL%n", result.getName());
System.out.printf(" %s%n", result.getThrowable().getMessage());
captureScreenshot(result.getName());
}
@Override
public void onTestSkipped(ITestResult result) {
System.out.printf("⏭ %-55s SKIP%n", result.getName());
}
private void captureScreenshot(String testName) {
WebDriver d = DriverManager.getDriver();
if (!(d instanceof TakesScreenshot ts)) return;
try {
File src = ts.getScreenshotAs(OutputType.FILE);
Path dir = Paths.get("reports", "screenshots");
Files.createDirectories(dir);
Files.copy(src.toPath(),
dir.resolve(testName + "_" + System.currentTimeMillis() + ".png"),
StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ignored) {}
}
}Step 4 — RetryAnalyzer and RetryTransformer
// src/test/java/com/mycompany/tests/retry/RetryAnalyzer.java
package com.mycompany.tests.retry;
import org.testng.IRetryAnalyzer;
import org.testng.ITestResult;
public class RetryAnalyzer implements IRetryAnalyzer {
private int count = 0;
private static final int MAX = 2;
@Override
public boolean retry(ITestResult result) {
if (count < MAX) {
System.out.printf(" 🔁 Retry %d/%d: %s%n", ++count, MAX, result.getName());
return true;
}
return false;
}
}// src/test/java/com/mycompany/tests/listener/RetryTransformer.java
package com.mycompany.tests.listener;
import com.mycompany.tests.retry.RetryAnalyzer;
import org.testng.IAnnotationTransformer;
import org.testng.annotations.ITestAnnotation;
import java.lang.reflect.*;
public class RetryTransformer implements IAnnotationTransformer {
@Override
public void transform(ITestAnnotation annotation, Class testClass,
Constructor testConstructor, Method testMethod) {
if (annotation.getRetryAnalyzer() == null) {
annotation.setRetryAnalyzer(RetryAnalyzer.class);
}
if (annotation.getTimeOut() == 0) {
annotation.setTimeOut(30_000);
}
}
}Step 5 — Three complete test classes
// src/test/java/com/mycompany/tests/tests/LoginTest.java
package com.mycompany.tests.tests;
import com.mycompany.tests.base.BaseTest;
import org.openqa.selenium.By;
import org.testng.Assert;
import org.testng.annotations.Test;
public class LoginTest extends BaseTest {
@Test(groups = {"smoke", "regression"},
description = "Valid credentials navigate to inventory")
public void validLoginSucceeds() {
driver().get("https://www.saucedemo.com");
driver().findElement(By.id("user-name")).sendKeys("standard_user");
driver().findElement(By.id("password")).sendKeys("secret_sauce");
driver().findElement(By.id("login-button")).click();
Assert.assertTrue(driver().getCurrentUrl().contains("/inventory.html"),
"Expected inventory URL after login");
}
@Test(groups = {"regression"},
description = "Locked account shows error message")
public void lockedUserSeesError() {
driver().get("https://www.saucedemo.com");
driver().findElement(By.id("user-name")).sendKeys("locked_out_user");
driver().findElement(By.id("password")).sendKeys("secret_sauce");
driver().findElement(By.id("login-button")).click();
String error = driver().findElement(
By.cssSelector("[data-test='error']")).getText();
Assert.assertTrue(error.contains("locked out"), "Expected locked-out error");
}
}// src/test/java/com/mycompany/tests/tests/DataDrivenLoginTest.java
package com.mycompany.tests.tests;
import com.mycompany.tests.base.BaseTest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.openqa.selenium.By;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import java.io.InputStream;
public class DataDrivenLoginTest extends BaseTest {
record LoginScenario(String username, String password,
String expectedUrl, boolean shouldPass) {}
@DataProvider(name = "loginScenarios")
public Object[][] loginScenarios() throws Exception {
ObjectMapper mapper = new ObjectMapper();
try (InputStream is = getClass().getClassLoader()
.getResourceAsStream("testdata/login-scenarios.json")) {
LoginScenario[] scenarios = mapper.readValue(is, LoginScenario[].class);
Object[][] rows = new Object[scenarios.length][4];
for (int i = 0; i < scenarios.length; i++) {
rows[i] = new Object[]{
scenarios[i].username(), scenarios[i].password(),
scenarios[i].expectedUrl(), scenarios[i].shouldPass()
};
}
return rows;
}
}
@Test(dataProvider = "loginScenarios", groups = {"regression"},
description = "Login with various credentials")
public void loginWithScenario(String username, String password,
String expectedUrl, boolean shouldPass) {
driver().get("https://www.saucedemo.com");
driver().findElement(By.id("user-name")).sendKeys(username);
driver().findElement(By.id("password")).sendKeys(password);
driver().findElement(By.id("login-button")).click();
if (shouldPass) {
Assert.assertTrue(driver().getCurrentUrl().contains(expectedUrl),
"Expected URL: " + expectedUrl + " for user: " + username);
} else {
Assert.assertTrue(
driver().findElement(By.cssSelector("[data-test='error']")).isDisplayed(),
"Expected error for invalid user: " + username);
}
}
}// src/test/java/com/mycompany/tests/tests/InventoryTest.java
package com.mycompany.tests.tests;
import com.mycompany.tests.base.BaseTest;
import org.openqa.selenium.By;
import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
public class InventoryTest extends BaseTest {
@BeforeMethod
public void login() {
driver().get("https://www.saucedemo.com");
driver().findElement(By.id("user-name")).sendKeys("standard_user");
driver().findElement(By.id("password")).sendKeys("secret_sauce");
driver().findElement(By.id("login-button")).click();
}
@Test(groups = {"smoke"}, description = "Inventory page shows 6 items")
public void inventoryShowsSixItems() {
int count = driver().findElements(
By.cssSelector("[data-test='inventory-item']")).size();
Assert.assertEquals(count, 6, "Expected 6 inventory items");
}
@Test(groups = {"regression"}, description = "Add to cart increments badge")
public void addToCartIncrementsBadge() {
driver().findElement(
By.cssSelector("[data-test='add-to-cart-sauce-labs-backpack']")
).click();
String badge = driver().findElement(
By.cssSelector(".shopping_cart_badge")).getText();
Assert.assertEquals(badge, "1", "Cart badge should show 1");
}
}Step 6 — testng.xml suite files
src/test/resources/smoke.xml:
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Smoke Suite" verbose="1">
<listeners>
<listener class-name="com.mycompany.tests.listener.RetryTransformer"/>
<listener class-name="com.mycompany.tests.listener.TestListener"/>
</listeners>
<test name="Smoke">
<groups><run><include name="smoke"/></run></groups>
<packages>
<package name="com.mycompany.tests.tests"/>
</packages>
</test>
</suite>src/test/resources/regression.xml:
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Regression Suite" parallel="classes" thread-count="2" verbose="1">
<listeners>
<listener class-name="com.mycompany.tests.listener.RetryTransformer"/>
<listener class-name="com.mycompany.tests.listener.TestListener"/>
</listeners>
<test name="Regression">
<groups>
<run>
<include name="regression"/>
<exclude name="wip"/>
</run>
</groups>
<packages>
<package name="com.mycompany.tests.tests"/>
</packages>
</test>
</suite>Step 7 — GitHub Actions workflow
.github/workflows/testng.yml:
name: TestNG Suite
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * *'
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
- run: mvn clean test -DsuiteXmlFile=smoke.xml -Dheadless=true
env:
BASE_URL: ${{ secrets.BASE_URL }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: smoke-reports-${{ github.run_number }}
path: |
test-output/
reports/The framework execution flow
Step 1 of 7
mvn clean test -DsuiteXmlFile=smoke.xml
Surefire reads smoke.xml and hands control to TestNG. RetryTransformer fires first — it attaches RetryAnalyzer and sets the 30-second timeout on every @Test before any test runs.
Project work
Implement the framework. 90–120 minutes (split over multiple sessions if needed).
- Copy
pom.xmland runmvn dependency:resolve. All seven artifacts should resolve without error. - Implement
DriverManager.javaandBaseTest.java. Write a single@Testin a scratch class that callsdriver().get("https://www.saucedemo.com")and asserts the title. Run it — confirm a browser opens, navigates, and quits cleanly. - Implement
TestListener.javaandRetryTransformer.java. Register both insmoke.xml. Force one test to fail. Confirm the console shows the ❌ line and a screenshot appears inreports/screenshots/. - Implement
LoginTest.javaandDataDrivenLoginTest.java. Createtestdata/login-scenarios.jsonwith 4 rows (valid user, locked user, empty username, wrong password). Run withmvn test -DsuiteXmlFile=smoke.xml. Confirm 2 smoke tests + 4 DataProvider rows = 6 results. - Add
regression.xmlwithparallel="classes" thread-count="2". Run regression suite. Confirm in the console thatLoginTestandInventoryTeststart on different threads simultaneously. - Add the GitHub Actions workflow. Push. Watch the Actions tab — confirm smoke tests run in CI, reports are uploaded as artefacts, and the badge on your README turns green.
- Add
ExtentReportListener.java. Register it. After a run with at least one failure, openreports/extent-report.htmland confirm: donut chart shows pass/fail split, failed test has an embedded screenshot, system info shows the browser name.
The next lesson reviews the complete checklist and points you toward what to build next.