UI Hybrid Framework

Build a professional Selenium Java automation framework step by step — from a single hardcoded script to a production-grade hybrid setup with TestNG, config-driven runs, waits, reporting, retries, logging, data-driven tests, CI/CD, Grid, parallel execution and Page Factory. Reference: amazon-selenium.

Framework overview

A hybrid framework combines page objects, utilities, and test logic so automation stays maintainable and scalable.

Modular structure

Separate page classes, test classes, and utilities to keep responsibilities clear.

Reusable actions

Keep browser actions inside page methods instead of repeating them in tests.

Cleaner tests

Tests should describe the scenario, not low-level locator details.

This is the full progression from “open a browser” to a production-ready hybrid framework. Every step adds exactly one capability so you can stop at any point and still have a working setup. Each section below shows the goal, the change to make, and the code that goes with it. Code mirrors the reference amazon-selenium project.

Step 0 — Environment Setup (Java + Selenium + Maven)

Goal: Browser should open Amazon. Nothing else matters yet.

1. Install Java (JDK)

You need JDK installed. Verify with java -version. Expected: java version "17" (or 11+). If it is not installed, install JDK (Temurin or Oracle, either works).

2. Create Maven Project

In IntelliJ IDEA or Eclipse: New Project → Maven.

  • GroupId: com.automation
  • ArtifactId: amazon-selenium

3. Add Selenium Dependency

Open pom.xml and add:

<dependencies>
    <dependency>
        <groupId>org.seleniumhq.selenium</groupId>
        <artifactId>selenium-java</artifactId>
        <version>4.43.0</version>
    </dependency>
</dependencies>

4. ChromeDriver Setup

Download ChromeDriver matching your Chrome version and set the path in code (you will replace this with WebDriverManager in Step 24):

System.setProperty("webdriver.chrome.driver", "C:\\drivers\\chromedriver.exe");

5. Create First Class

Path: src/test/java/com/automation/basic · Class: AmazonLaunchTest.java.

6. Write Raw Code

package com.automation.basic;

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

public class AmazonLaunchTest {
    public static void main(String[] args) {
        WebDriver driver = new ChromeDriver();
        driver.manage().window().maximize();
        driver.get("https://www.amazon.in");
        System.out.println("Title is: " + driver.getTitle());
        driver.quit();
    }
}

7. Run It

Run as Java Application. Expected: Chrome opens, Amazon loads, title prints, browser closes.

Step 1 — First Real Interaction (Still Hardcoded)

Goal: Search for a product (e.g. “laptop”).

package com.automation.basic;

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

public class AmazonLaunchTest {
    public static void main(String[] args) {
        System.setProperty("webdriver.chrome.driver", "C:\\drivers\\chromedriver.exe");

        WebDriver driver = new ChromeDriver();
        driver.manage().window().maximize();
        driver.get("https://www.amazon.in");

        // Search for product
        driver.findElement(By.id("twotabsearchtextbox")).sendKeys("laptop");
        driver.findElement(By.id("nav-search-submit-button")).click();

        System.out.println("Search done. Title: " + driver.getTitle());
        driver.quit();
    }
}

What you just learned

  • Locate elements using css, xpath, id.
  • Perform actions: sendKeys, click.
  • Basic end-to-end automation flow.

Step 2 — Convert Amazon Script into Page + Test Structure

Goal: Take the working hardcoded script and split it into a Page class (UI logic) and a Test class (flow).

1. Page Class

// pages/AmazonHomePage.java
package pages;

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;

public class AmazonHomePage {
    WebDriver driver;

    public AmazonHomePage(WebDriver driver) {
        this.driver = driver;
    }

    private By searchBox = By.id("twotabsearchtextbox");
    private By searchButton = By.id("nav-search-submit-button");

    public void enterSearchText(String text) {
        driver.findElement(searchBox).sendKeys(text);
    }

    public void clickSearch() {
        driver.findElement(searchButton).click();
    }

    public String getPageTitle() {
        return driver.getTitle();
    }
}

2. Test Class

// tests/AmazonSearchTest.java
package tests;

import pages.AmazonHomePage;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;

public class AmazonSearchTest {
    public static void main(String[] args) {
        System.setProperty("webdriver.chrome.driver", "C:\\drivers\\chromedriver.exe");
        WebDriver driver = new ChromeDriver();
        driver.manage().window().maximize();
        driver.get("https://www.amazon.in");

        AmazonHomePage homePage = new AmazonHomePage(driver);
        homePage.enterSearchText("laptop");
        homePage.clickSearch();
        System.out.println("Result Page Title: " + homePage.getPageTitle());

        driver.quit();
    }
}

Step 3 — Introduce TestNG (Execution Layer)

1. Add TestNG Dependency

<dependency>
    <groupId>org.testng</groupId>
    <artifactId>testng</artifactId>
    <version>7.9.0</version>
    <scope>test</scope>
</dependency>

2. Update Your Test Class

package tests;

import pages.AmazonHomePage;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.testng.annotations.*;

public class AmazonSearchTest {
    WebDriver driver;
    AmazonHomePage homePage;

    @BeforeMethod
    public void setUp() {
        System.setProperty("webdriver.chrome.driver", "C:\\drivers\\chromedriver.exe");
        driver = new ChromeDriver();
        driver.manage().window().maximize();
        driver.get("https://www.amazon.in");
        homePage = new AmazonHomePage(driver);
    }

    @Test
    public void searchProductTest() {
        homePage.enterSearchText("laptop");
        homePage.clickSearch();
        System.out.println("Result Page Title: " + homePage.getPageTitle());
    }

    @AfterMethod
    public void tearDown() {
        driver.quit();
    }
}

Step 4 — Introduce BaseTest (Driver Reusability)

1. BaseTest Class

// utils/BaseTest.java
package utils;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.testng.annotations.*;

public class BaseTest {
    protected WebDriver driver;

    @BeforeMethod
    public void setUp() {
        System.setProperty("webdriver.chrome.driver", "C:\\drivers\\chromedriver.exe");
        driver = new ChromeDriver();
        driver.manage().window().maximize();
        driver.get("https://www.amazon.in");
    }

    @AfterMethod
    public void tearDown() {
        driver.quit();
    }
}

2. Test Class with Inheritance

public class AmazonSearchTest extends BaseTest {

    @Test
    public void searchProductTest() {
        AmazonHomePage homePage = new AmazonHomePage(driver);
        homePage.enterSearchText("laptop");
        homePage.clickSearch();
        System.out.println("Result Page Title: " + homePage.getPageTitle());
    }
}

Step 5 — Config Properties (Remove Hardcoding)

1. config.properties

File path: src/test/resources/config.properties

url=https://www.amazon.in
qa.url=https://qa.example.com
uat.url=https://uat.example.com
browser=chrome

2. ConfigReader

// utils/ConfigReader.java
package utils;

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

public class ConfigReader {
    Properties prop;

    public ConfigReader() {
        try {
            FileInputStream fis = new FileInputStream(
                System.getProperty("user.dir") + "/src/test/resources/config.properties"
            );
            prop = new Properties();
            prop.load(fis);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public String getProperty(String key) {
        return prop.getProperty(key);
    }
}

Step 6 — Wait Strategy (Stability Layer)

WaitUtils

// utils/WaitUtils.java
package utils;

import org.openqa.selenium.*;
import org.openqa.selenium.support.ui.*;
import java.time.Duration;

public class WaitUtils {
    WebDriver driver;
    WebDriverWait wait;

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

    public WebElement waitForVisibility(By locator) {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
    }

    public WebElement waitForClickable(By locator) {
        return wait.until(ExpectedConditions.elementToBeClickable(locator));
    }
}

Step 7 — Add Meaningful, Different Tests

With BaseTest, ConfigReader, and WaitUtils in place, plug them into the Page Object so every action waits before interacting and logs what it is doing.

// pages/AmazonHomePage.java
package pages;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import utils.WaitUtils;

import java.util.Objects;

public class AmazonHomePage {
    private static final Logger log = LogManager.getLogger(AmazonHomePage.class);

    WebDriver driver;
    WaitUtils wait;

    public AmazonHomePage(WebDriver driver) {
        this.driver = Objects.requireNonNull(driver,
            "WebDriver is null. Check BaseTest.setUp() and your TestNG run configuration.");
        this.wait = new WaitUtils(this.driver);
    }

    private By searchBox = By.id("twotabsearchtextbox");
    private By searchButton = By.id("nav-search-submit-button");

    public void enterSearchText(String text) {
        log.info("Entering search text: {}", text);
        WebElement searchInput = wait.waitForVisibility(searchBox);
        searchInput.clear();
        searchInput.sendKeys(text);
    }

    public void clickSearch() {
        log.info("Clicking search");
        wait.waitForClickable(searchButton).click();
    }

    public String getPageTitle() {
        log.info("Reading page title");
        return driver.getTitle();
    }
}

Step 8 — Create testng.xml

<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Amazon Suite">
    <test name="All Tests">
        <classes>
            <class name="tests.AmazonSearchTest"/>
            <class name="tests.AmazonResultsTest"/>
        </classes>
    </test>
</suite>

Step 9 — Multi-browser Support (Chrome / Edge / Firefox)

Goal: Pick the browser from config.properties instead of hardcoding new ChromeDriver(). Each browser also gets headless options so it runs the same way locally and in CI.

// utils/BaseTest.java (multi-browser variant)
package utils;

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;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;

public class BaseTest {

    protected static final ThreadLocal<WebDriver> driver = new ThreadLocal<>();
    private ConfigReader config;

    @BeforeMethod
    public void setUp() {
        config = new ConfigReader();
        String browser = config.getProperty("browser");
        String url = config.getProperty("url");

        if (browser == null || browser.trim().isEmpty()) {
            throw new IllegalStateException("Browser value is missing in config.properties");
        }
        if (url == null || url.trim().isEmpty()) {
            throw new IllegalStateException("URL value is missing in config.properties");
        }

        browser = browser.trim();
        url = url.trim();

        WebDriver wd;
        if (browser.equalsIgnoreCase("chrome")) {
            wd = new ChromeDriver(createChromeOptions());
        } else if (browser.equalsIgnoreCase("edge")) {
            wd = new EdgeDriver(createEdgeOptions());
        } else if (browser.equalsIgnoreCase("firefox")) {
            wd = new FirefoxDriver(createFirefoxOptions());
        } else {
            throw new IllegalStateException("Unsupported browser in config.properties: " + browser);
        }

        driver.set(wd);
        driver.get().get(url);
    }

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

    public WebDriver getDriver() {
        return driver.get();
    }

    private ChromeOptions createChromeOptions() {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless=new");
        options.addArguments("--disable-gpu");
        options.addArguments("--window-size=1920,1080");
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        options.addArguments("--remote-allow-origins=*");
        return options;
    }

    private EdgeOptions createEdgeOptions() {
        EdgeOptions options = new EdgeOptions();
        options.addArguments("--headless=new");
        options.addArguments("--disable-gpu");
        options.addArguments("--window-size=1920,1080");
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        options.addArguments("--remote-allow-origins=*");
        return options;
    }

    private FirefoxOptions createFirefoxOptions() {
        FirefoxOptions options = new FirefoxOptions();
        options.addArguments("--headless");
        options.addArguments("--width=1920");
        options.addArguments("--height=1080");
        return options;
    }
}

Note the use of ThreadLocal<WebDriver>. This is a small change today but it becomes critical in Step 27 when tests run in parallel — each thread keeps its own driver instance.

Step 10 — Add Logging Inside Page Objects

Tests should not need to know why the search box was clicked. The Page Object handles the log, so when a failure shows up in CI, you can read the run history straight from the log.

private static final Logger log = LogManager.getLogger(AmazonHomePage.class);

public void enterSearchText(String text) {
    log.info("Entering search text: {}", text);
    WebElement searchInput = wait.waitForVisibility(searchBox);
    searchInput.clear();
    searchInput.sendKeys(text);
}

public void clickSearch() {
    log.info("Clicking search");
    wait.waitForClickable(searchButton).click();
}

Use log.info for normal actions, log.warn for skipped paths, and log.error for unrecoverable conditions. The actual log4j2.xml wiring is in Step 17.

Step 11 — Add Maven Plugins (Surefire + Compiler)

Add this to your pom.xml:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
            <configuration>
                <suiteXmlFiles>
                    <suiteXmlFile>${suiteXmlFile}</suiteXmlFile>
                </suiteXmlFiles>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version>
            <configuration>
                <source>17</source>
                <target>17</target>
            </configuration>
        </plugin>
    </plugins>
</build>

Add a properties block so the suite file can be overridden from the command line: <suiteXmlFile>testng.xml</suiteXmlFile> inside <properties>. Then run mvn test -DsuiteXmlFile=testng-parallel-execution.xml to switch suites without editing the pom.

Step 12 — Screenshot on Failure

On test failure you want evidence. ScreenshotUtils writes a PNG named after the failing test into screenshots/.

// utils/ScreenshotUtils.java
package utils;

import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;

import java.io.File;
import java.nio.file.Files;

public class ScreenshotUtils {

    public static String capture(WebDriver driver, String testName) {
        try {
            File src = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
            String dir = System.getProperty("user.dir") + "/screenshots/";
            String path = dir + testName + ".png";

            File dest = new File(path);
            dest.getParentFile().mkdirs(); // ensure folder exists
            Files.copy(src.toPath(), dest.toPath());

            return path;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

Step 13 — Retry Analyzer (Flaky Test Recovery)

Real UI tests sometimes fail because of one-off timing. RetryAnalyzer reruns a failed test up to N times before marking it failed for good.

// utils/RetryAnalyzer.java
package utils;

import org.testng.IRetryAnalyzer;
import org.testng.ITestResult;

public class RetryAnalyzer implements IRetryAnalyzer {
    private int count = 0;
    private final int maxTry = 2; // total tries = 1 (original) + 2 retries

    @Override
    public boolean retry(ITestResult result) {
        if (count < maxTry) {
            count++;
            System.out.println("Retrying test: " + result.getName() + " (attempt " + (count + 1) + ")");
            return true;
        }
        return false;
    }
}

Step 14 — TestListener (Hooks for Every Test Event)

TestListener reacts to test start, success, failure, skip, and finish. It also creates an Extent report entry per test and attaches the screenshot from Step 12 on failure (Extent report wiring is in Step 18).

// utils/TestListener.java
package utils;

import com.aventstack.extentreports.ExtentReports;
import com.aventstack.extentreports.ExtentTest;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openqa.selenium.WebDriver;
import org.testng.ITestContext;
import org.testng.ITestListener;
import org.testng.ITestResult;

public class TestListener implements ITestListener {

    private static final Logger log = LogManager.getLogger(TestListener.class);
    private static ExtentReports extent = ReportManager.getInstance();
    private static ThreadLocal<ExtentTest> test = new ThreadLocal<>();

    @Override
    public void onTestStart(ITestResult result) {
        ExtentTest extentTest = extent.createTest(result.getMethod().getMethodName());
        test.set(extentTest);
        System.out.println("STARTED: " + result.getName());
        log.info("STARTED: {}", result.getName());
    }

    @Override
    public void onTestSuccess(ITestResult result) {
        System.out.println("PASSED: " + result.getName());
        log.info("PASSED: {}", result.getName());
        test.get().pass("Test passed");
    }

    @Override
    public void onTestFailure(ITestResult result) {
        WebDriver driver = ((BaseTest) result.getInstance()).getDriver();
        String path = ScreenshotUtils.capture(driver, result.getName());

        test.get().fail(result.getThrowable());
        if (path != null) {
            test.get().addScreenCaptureFromPath(path);
        }

        log.error("FAILED: {}", result.getName());
        System.out.println("FAILED: " + result.getName());
        if (path != null) {
            System.out.println("Screenshot saved at: " + path);
            log.error("Screenshot saved at: {}", path);
        }
    }

    @Override
    public void onTestSkipped(ITestResult result) {
        System.out.println("SKIPPED: " + result.getName());
        log.warn("SKIPPED: {}", result.getName());
        if (test.get() == null) {
            ExtentTest extentTest = extent.createTest(result.getMethod().getMethodName());
            test.set(extentTest);
        }
        test.get().skip("Test skipped");
    }

    @Override
    public void onFinish(ITestContext context) {
        extent.flush();
    }
}

Step 15 — Wire Listener + Retry into a Test

Two things connect the moving parts:

  1. Add retryAnalyzer = RetryAnalyzer.class to the test annotation that should auto-retry.
  2. Register the listener (per class or globally in testng.xml — see Step 22).
// tests/AmazonResultsTest.java
package tests;

import org.testng.Assert;
import org.testng.annotations.Test;
import pages.AmazonHomePage;
import pages.AmazonResultsPage;
import utils.BaseTest;

public class AmazonResultsTest extends BaseTest {

    @Test(retryAnalyzer = utils.RetryAnalyzer.class)
    public void verifySearchResultsDisplayed() {
        AmazonHomePage home = new AmazonHomePage(getDriver());
        home.enterSearchText("headphones");
        home.clickSearch();

        AmazonResultsPage results = new AmazonResultsPage(getDriver());
        Assert.assertTrue(results.isResultsDisplayed(), "Results not displayed");

        System.out.println("Results page validation passed");
    }
}

Step 16 — Run with Failure Handling End-to-End

Force-fail a test (Assert.assertTrue(false, "Force failure to test retry & screenshot");) and run the suite. You should observe:

  • The test runs once, fails, and is automatically retried up to 2 more times by RetryAnalyzer.
  • A screenshot is written to screenshots/<testName>.png for each failure.
  • The Extent report (Step 18) shows the test as failed with the screenshot attached.
  • Log4j prints STARTED / FAILED / Screenshot saved at: … lines.

Once verified, remove the forced failure and your real assertion is back in charge.

Step 17 — Logging (Log4j)

Add dependencies to pom.xml:

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
    <version>2.23.1</version>
</dependency>
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.23.1</version>
</dependency>

Create log4j2.xml under src/test/resources with both console and file appenders so the log shows up live and persists in logs/test.log:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss} %-5level %c - %msg%n"/>
        </Console>
        <File name="File" fileName="logs/test.log">
            <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} %-5level %c - %msg%n"/>
        </File>
    </Appenders>
    <Loggers>
        <Root level="info">
            <AppenderRef ref="Console"/>
            <AppenderRef ref="File"/>
        </Root>
    </Loggers>
</Configuration>

Step 18 — Extent Report

Add the Extent dependency:

<dependency>
    <groupId>com.aventstack</groupId>
    <artifactId>extentreports</artifactId>
    <version>5.1.1</version>
</dependency>

ReportManager owns the singleton ExtentReports instance and points the Spark reporter at reports/extent.html:

// utils/ReportManager.java
package utils;

import com.aventstack.extentreports.ExtentReports;
import com.aventstack.extentreports.reporter.ExtentSparkReporter;

public class ReportManager {

    private static ExtentReports extent;

    public static ExtentReports getInstance() {
        if (extent == null) {
            String path = System.getProperty("user.dir") + "/reports/extent.html";
            ExtentSparkReporter spark = new ExtentSparkReporter(path);
            spark.config().setReportName("Automation Report");

            extent = new ExtentReports();
            extent.attachReporter(spark);
        }
        return extent;
    }
}

TestListener (Step 14) calls extent.flush() in onFinish, which writes the HTML report. Open reports/extent.html in a browser after the run.

Step 19 — Real Assertions with TestNG Assert

A test without an assertion is just a click recorder. Use Assert.assertTrue / assertEquals so the test fails for the right reason and the listener can attach a screenshot.

import org.testng.Assert;

@Test
public void searchProductTest() {
    AmazonHomePage home = new AmazonHomePage(getDriver());
    home.enterSearchText("laptop");
    home.clickSearch();

    String title = home.getPageTitle().toLowerCase();
    Assert.assertTrue(title.contains("laptop"),
        "Result page title did not contain 'laptop'. Actual: " + title);
}

Step 20 — Add the Results Page Object

Search is only half the journey. Add an AmazonResultsPage so tests can ask “did results actually render?”. It uses WaitUtils from Step 6 so the check is synchronized, not racy.

// pages/AmazonResultsPage.java
package pages;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import utils.WaitUtils;

public class AmazonResultsPage {
    private static final Logger log = LogManager.getLogger(AmazonResultsPage.class);

    WebDriver driver;
    WaitUtils wait;

    public AmazonResultsPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WaitUtils(driver);
    }

    private By resultsContainer = By.cssSelector("div.s-main-slot");

    public boolean isResultsDisplayed() {
        log.info("Verifying that the results container is displayed");
        return wait.waitForVisibility(resultsContainer).isDisplayed();
    }
}

Step 21 — Inline @DataProvider

Data-driven without external files: declare the rows in code. Each row makes TestNG re-run the same test once.

// tests/AmazonSearchDataProviderTest.java
package tests;

import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import pages.AmazonHomePage;
import pages.AmazonResultsPage;
import utils.BaseTest;

public class AmazonSearchDataProviderTest extends BaseTest {

    @DataProvider(name = "searchData")
    public Object[][] data() {
        return new Object[][]{
            {"pen"},
            {"car"},
            {"stand"}
        };
    }

    @Test(dataProvider = "searchData")
    public void searchProductWithInlineDataProvider(String keyword) {
        AmazonHomePage home = new AmazonHomePage(getDriver());
        home.enterSearchText(keyword);
        home.clickSearch();

        AmazonResultsPage results = new AmazonResultsPage(getDriver());
        Assert.assertTrue(results.isResultsDisplayed(),
            "Results not displayed for keyword: " + keyword);
    }
}

Step 22 — Register the Listener in testng.xml

Instead of decorating every test class with @Listeners, declare the listener once at the suite level. It applies to every test that runs from this suite file.

<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">

<suite name="Amazon Suite">
    <listeners>
        <listener class-name="utils.TestListener"/>
    </listeners>

    <test name="All Tests">
        <classes>
            <class name="tests.AmazonSearchExcelDataTest"/>
            <class name="tests.AmazonSearchDataProviderTest"/>
            <class name="tests.AmazonResultsTest"/>
        </classes>
    </test>
</suite>

Step 23 — Data Driven Testing (Apache POI)

Add the POI dependency:

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>

ExcelUtils reads a sheet and returns an Object[][] that TestNG’s @DataProvider can consume. Row 0 is the header; the rest become individual test executions.

// utils/ExcelUtils.java
package utils;

import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

import java.io.FileInputStream;
import java.io.IOException;

public class ExcelUtils {

    public static Object[][] getTestData(String filePath, String sheetName) {
        try (FileInputStream fis = new FileInputStream(filePath);
             Workbook workbook = new XSSFWorkbook(fis)) {

            Sheet sheet = workbook.getSheet(sheetName);
            if (sheet == null) {
                throw new IllegalArgumentException("Sheet not found: " + sheetName);
            }

            int rows = sheet.getPhysicalNumberOfRows();
            int cols = sheet.getRow(0).getPhysicalNumberOfCells();

            // TestNG DataProvider expects Object[][]; each row becomes one execution.
            Object[][] data = new Object[rows - 1][cols];

            for (int i = 1; i < rows; i++) {
                Row row = sheet.getRow(i);
                for (int j = 0; j < cols; j++) {
                    // i starts from 1 because row 0 is the header.
                    data[i - 1][j] = row.getCell(j).toString();
                }
            }
            return data;
        } catch (IOException e) {
            throw new RuntimeException("Failed to read test data from Excel: " + filePath, e);
        }
    }
}

Use it from a test class:

// tests/AmazonSearchExcelDataTest.java
package tests;

import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import pages.AmazonHomePage;
import pages.AmazonResultsPage;
import utils.BaseTest;
import utils.ExcelUtils;

public class AmazonSearchExcelDataTest extends BaseTest {

    @DataProvider(name = "excelData")
    public Object[][] getData() {
        String path = System.getProperty("user.dir") + "/testdata/searchData.xlsx";
        return ExcelUtils.getTestData(path, "Sheet1");
    }

    @Test(dataProvider = "excelData")
    public void searchProductWithExcelData(String keyword) {
        AmazonHomePage home = new AmazonHomePage(getDriver());
        home.enterSearchText(keyword);
        home.clickSearch();

        AmazonResultsPage results = new AmazonResultsPage(getDriver());
        Assert.assertTrue(results.isResultsDisplayed(),
            "Results not displayed for keyword: " + keyword);
    }
}

Step 24 — WebDriverManager (No More Manual Driver Path)

Hardcoded ChromeDriver paths break on every machine. WebDriverManager downloads and wires the correct driver binary automatically.

<dependency>
    <groupId>io.github.bonigarcia</groupId>
    <artifactId>webdrivermanager</artifactId>
    <version>5.8.0</version>
</dependency>

Update BaseTest:

import io.github.bonigarcia.wdm.WebDriverManager;

if (browser.equalsIgnoreCase("chrome")) {
    WebDriverManager.chromedriver().setup();
    wd = new ChromeDriver(createChromeOptions());
} else if (browser.equalsIgnoreCase("edge")) {
    WebDriverManager.edgedriver().setup();
    wd = new EdgeDriver(createEdgeOptions());
} else if (browser.equalsIgnoreCase("firefox")) {
    WebDriverManager.firefoxdriver().setup();
    wd = new FirefoxDriver(createFirefoxOptions());
}

You can now delete the System.setProperty("webdriver.chrome.driver", ...) line completely.

Step 25 — CI/CD using Jenkins

  1. Create a Freestyle project in Jenkins.
  2. Configure Source Code Management (Git) with your repo URL and credentials.
  3. Add an Invoke top-level Maven targets build step with goals: clean test.
  4. Run the job and monitor the Console Output for build success / failure.

Step 26 — Jenkins Email Trigger

  1. Generate an App Password in Gmail (2-step verification must be ON).
  2. In Jenkins → Manage Jenkins → Configure System, set the SMTP server, port, and the Gmail account with the App Password.
  3. Use the emailext plugin in your job (or pipeline) to send Success / Failure notifications with attached reports.

Step 27 — Parallel Execution

Goal: Run multiple test classes at the same time. Two pieces matter: a thread-safe BaseTest (driver kept inside a ThreadLocal) and a dedicated suite file with parallel="classes".

1. BaseTestParallelExecution

// utils/BaseTestParallelExecution.java
package utils;

import io.github.bonigarcia.wdm.WebDriverManager;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
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.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;

public class BaseTestParallelExecution {

    protected static final Logger log = LogManager.getLogger(BaseTestParallelExecution.class);
    private static final ThreadLocal<WebDriver> driver = new ThreadLocal<>();
    private ConfigReader config;

    @BeforeMethod(alwaysRun = true)
    public void setUpParallelExecution() {
        config = new ConfigReader();
        String browser = config.getProperty("browser");
        String url = config.getProperty("url");

        WebDriver webDriver;
        if (browser.equalsIgnoreCase("chrome")) {
            WebDriverManager.chromedriver().setup();
            webDriver = new ChromeDriver(createChromeOptions());
        } else if (browser.equalsIgnoreCase("edge")) {
            WebDriverManager.edgedriver().setup();
            webDriver = new EdgeDriver(createEdgeOptions());
        } else if (browser.equalsIgnoreCase("firefox")) {
            WebDriverManager.firefoxdriver().setup();
            webDriver = new FirefoxDriver();
        } else {
            throw new IllegalStateException("Unsupported browser: " + browser);
        }

        driver.set(webDriver);
        log.info("Launching browser for thread {}", Thread.currentThread().getId());
        getDriver().get(url.trim());
    }

    @AfterMethod(alwaysRun = true)
    public void tearDownParallelExecution() {
        if (getDriver() != null) {
            log.info("Closing browser for thread {}", Thread.currentThread().getId());
            getDriver().quit();
            driver.remove();
        }
    }

    public WebDriver getDriver() {
        return driver.get();
    }

    private ChromeOptions createChromeOptions() {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless=new");
        options.addArguments("--disable-gpu");
        options.addArguments("--window-size=1920,1080");
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        options.addArguments("--remote-allow-origins=*");
        return options;
    }

    private EdgeOptions createEdgeOptions() {
        EdgeOptions options = new EdgeOptions();
        options.addArguments("--headless=new");
        options.addArguments("--disable-gpu");
        options.addArguments("--window-size=1920,1080");
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        options.addArguments("--remote-allow-origins=*");
        return options;
    }
}

2. Parallel-aware listener, screenshot, and report

The thread id is appended to test names, screenshots, and report nodes so two parallel failures cannot collide:

// utils/ScreenshotUtilsParallelExecution.java
String path = System.getProperty("user.dir")
        + "/screenshots/parallel-execution/"
        + testName + "-" + Thread.currentThread().getId() + ".png";

// utils/ReportManagerParallelExecution.java
public static synchronized ExtentReports getInstance() {
    if (extent == null) {
        String path = System.getProperty("user.dir")
                + "/reports/parallel-execution/extent-parallel-execution.html";
        ExtentSparkReporter spark = new ExtentSparkReporter(path);
        spark.config().setReportName("Parallel Execution Report");
        spark.config().setDocumentTitle("Parallel Execution Suite");

        extent = new ExtentReports();
        extent.attachReporter(spark);
    }
    return extent;
}

// utils/TestListenerParallelExecution.java
String testName = result.getMethod().getMethodName()
        + " [thread-" + Thread.currentThread().getId() + "]";
test.set(extent.createTest(testName));

3. testng-parallel-execution.xml

<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">

<suite name="Parallel Execution Suite" parallel="classes" thread-count="2">
    <listeners>
        <listener class-name="utils.TestListenerParallelExecution"/>
    </listeners>

    <test name="Parallel Execution Tests">
        <classes>
            <class name="tests.AmazonSearchParallelExecutionTest"/>
            <class name="tests.AmazonMobileParallelExecutionTest"/>
        </classes>
    </test>
</suite>

4. Parallel test classes

public class AmazonSearchParallelExecutionTest extends BaseTestParallelExecution {
    @Test
    public void searchLaptopParallelExecution() {
        AmazonHomePage home = new AmazonHomePage(getDriver());
        home.enterSearchText("laptop");
        home.clickSearch();

        AmazonResultsPage results = new AmazonResultsPage(getDriver());
        Assert.assertTrue(results.isResultsDisplayed(), "Results not displayed for laptop search");
    }
}

public class AmazonMobileParallelExecutionTest extends BaseTestParallelExecution {
    @Test
    public void searchMobileParallelExecution() {
        AmazonHomePage home = new AmazonHomePage(getDriver());
        home.enterSearchText("mobile");
        home.clickSearch();

        AmazonResultsPage results = new AmazonResultsPage(getDriver());
        Assert.assertTrue(results.isResultsDisplayed(), "Results not displayed for mobile search");
    }
}

Run with: mvn test -DsuiteXmlFile=testng-parallel-execution.xml. Both classes execute on separate threads against their own browser instance.

Step 28 — Selenium Grid

  1. Start the Hub: java -jar selenium-server.jar hub
  2. Start a Node: java -jar selenium-server.jar node
  3. In BaseTest, switch from new ChromeDriver() to RemoteWebDriver targeting http://localhost:4444 with the desired browser capabilities.
WebDriver driver = new RemoteWebDriver(
    new URL("http://localhost:4444"),
    new ChromeOptions()
);

Step 29 — Implement Page Factory

Replace standard By locators with @FindBy annotations so the page object reads more declaratively:

// pages/AmazonHomePage_withPageFactory.java
package pages;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;

public class AmazonHomePage_withPageFactory {
    private static final Logger log = LogManager.getLogger(AmazonHomePage_withPageFactory.class);
    private final WebDriver driver;

    public AmazonHomePage_withPageFactory(WebDriver driver) {
        this.driver = driver;
        PageFactory.initElements(driver, this);
    }

    @FindBy(id = "twotabsearchtextbox")
    private WebElement searchBox;

    @FindBy(id = "nav-search-submit-button")
    private WebElement searchButton;

    public void enterSearchText(String text) {
        log.info("Entering search text: {}", text);
        searchBox.clear();
        searchBox.sendKeys(text);
    }

    public void clickSearch() {
        log.info("Clicking search");
        searchButton.click();
    }
}

This is the final shape of a hybrid Selenium Java framework: a clean test layer, declarative page objects, configurable runs, stable waits, reporting, retries, logging, data-driven inputs, CI/CD, parallel execution, Grid, and Page Factory.