Guided Walkthrough

12 min read

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).

  1. Copy pom.xml and run mvn dependency:resolve. All seven artifacts should resolve without error.
  2. Implement DriverManager.java and BaseTest.java. Write a single @Test in a scratch class that calls driver().get("https://www.saucedemo.com") and asserts the title. Run it — confirm a browser opens, navigates, and quits cleanly.
  3. Implement TestListener.java and RetryTransformer.java. Register both in smoke.xml. Force one test to fail. Confirm the console shows the ❌ line and a screenshot appears in reports/screenshots/.
  4. Implement LoginTest.java and DataDrivenLoginTest.java. Create testdata/login-scenarios.json with 4 rows (valid user, locked user, empty username, wrong password). Run with mvn test -DsuiteXmlFile=smoke.xml. Confirm 2 smoke tests + 4 DataProvider rows = 6 results.
  5. Add regression.xml with parallel="classes" thread-count="2". Run regression suite. Confirm in the console that LoginTest and InventoryTest start on different threads simultaneously.
  6. 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.
  7. Add ExtentReportListener.java. Register it. After a run with at least one failure, open reports/extent-report.html and 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.

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