This is the walkthrough — every key class for the FlyRight framework, in order, with complete code. You won't paste it directly (your target site's selectors will differ), but the structure transfers verbatim. Read it once end to end before you start; then build alongside it, deviating where your site demands. By the end of this lesson you'll have the foundation, two real page objects, a sample test of each type, the listener, and the GitHub Actions workflow — roughly 60% of the deliverables from lesson 1's brief, with a clear pattern for the rest.
Step 1 — pom.xml
Start with the project's pom.xml. Every dependency has earned its place across the previous chapters:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.flyright</groupId>
<artifactId>flyright-selenium-tests</artifactId>
<version>1.0.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>
<suiteFile>smoke.xml</suiteFile>
<browser>chrome</browser>
<headless>false</headless>
<env>staging</env>
</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>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.16.1</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/${suiteFile}</suiteXmlFile>
</suiteXmlFiles>
<systemPropertyVariables>
<browser>${browser}</browser>
<headless>${headless}</headless>
<env>${env}</env>
</systemPropertyVariables>
<argLine>-Xmx2048m</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>Confirm mvn clean compile runs cleanly. The dependencies download once on the first build and cache locally.
Step 2 — DriverManager + BaseTest
DriverManager holds the per-thread driver. BaseTest is the abstract parent of every test class:
// src/test/java/com/flyright/base/DriverManager.java
package com.flyright.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 final class DriverManager {
private static final ThreadLocal<WebDriver> DRIVERS = new ThreadLocal<>();
private DriverManager() {}
public static WebDriver getDriver() { return DRIVERS.get(); }
public static void initDriver(String browser, boolean headless) {
WebDriver driver;
switch (browser.toLowerCase()) {
case "firefox":
WebDriverManager.firefoxdriver().setup();
FirefoxOptions ff = new FirefoxOptions();
if (headless) ff.addArguments("-headless");
driver = new FirefoxDriver(ff);
break;
case "chrome":
default:
WebDriverManager.chromedriver().setup();
ChromeOptions cr = new ChromeOptions();
if (headless) cr.addArguments("--headless=new", "--no-sandbox", "--disable-dev-shm-usage");
cr.addArguments("--window-size=1920,1080");
driver = new ChromeDriver(cr);
}
DRIVERS.set(driver);
}
public static void quitDriver() {
WebDriver d = DRIVERS.get();
if (d != null) {
d.quit();
DRIVERS.remove();
}
}
}// src/test/java/com/flyright/base/BaseTest.java
package com.flyright.base;
import org.openqa.selenium.WebDriver;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Optional;
import org.testng.annotations.Parameters;
public abstract class BaseTest {
@Parameters({"browser"})
@BeforeMethod(alwaysRun = true)
public void createDriver(@Optional String browser) {
String resolved = browser != null
? browser
: System.getProperty("browser", "chrome");
boolean headless = "true".equalsIgnoreCase(System.getProperty("headless", "false"));
DriverManager.initDriver(resolved, headless);
}
@AfterMethod(alwaysRun = true)
public void quitDriver() {
DriverManager.quitDriver();
}
protected WebDriver getDriver() { return DriverManager.getDriver(); }
}Two classes, ~60 lines total — the foundation every test will use.
Step 3 — BasePage
The shared toolkit every page extends:
// src/test/java/com/flyright/base/BasePage.java
package com.flyright.base;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
public abstract class BasePage {
protected final WebDriver driver;
protected final WebDriverWait wait;
protected BasePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(15));
}
protected void click(By locator) {
wait.until(ExpectedConditions.elementToBeClickable(locator)).click();
}
protected void type(By locator, String text) {
WebElement el = wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
el.clear();
el.sendKeys(text);
}
protected String getText(By locator) {
return wait.until(ExpectedConditions.visibilityOfElementLocated(locator)).getText();
}
protected boolean isDisplayed(By locator) {
try { return driver.findElement(locator).isDisplayed(); }
catch (NoSuchElementException e) { return false; }
}
protected void waitForInvisibility(By locator) {
wait.until(ExpectedConditions.invisibilityOfElementLocated(locator));
}
public String currentUrl() { return driver.getCurrentUrl(); }
public String pageTitle() { return driver.getTitle(); }
}Step 4 — Two page objects: HomePage and SearchResultsPage
// src/test/java/com/flyright/pages/HomePage.java
package com.flyright.pages;
import com.flyright.base.BasePage;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
public class HomePage extends BasePage {
private static final By ORIGIN = By.cssSelector("[data-testid='origin']");
private static final By DESTINATION = By.cssSelector("[data-testid='destination']");
private static final By DEPART_DATE = By.cssSelector("[data-testid='depart-date']");
private static final By RETURN_DATE = By.cssSelector("[data-testid='return-date']");
private static final By ROUND_TRIP = By.cssSelector("[data-testid='round-trip']");
private static final By ONE_WAY = By.cssSelector("[data-testid='one-way']");
private static final By SEARCH_BUTTON = By.cssSelector("[data-testid='search']");
public HomePage(WebDriver driver) { super(driver); }
public HomePage navigateTo(String baseUrl) {
driver.get(baseUrl);
return this;
}
public HomePage selectOneWay() { click(ONE_WAY); return this; }
public HomePage selectRoundTrip() { click(ROUND_TRIP); return this; }
public HomePage origin(String code) { type(ORIGIN, code); return this; }
public HomePage destination(String code) { type(DESTINATION, code); return this; }
public HomePage departDate(String iso) { type(DEPART_DATE, iso); return this; }
public HomePage returnDate(String iso) { type(RETURN_DATE, iso); return this; }
public SearchResultsPage search() {
click(SEARCH_BUTTON);
return new SearchResultsPage(driver);
}
}// src/test/java/com/flyright/pages/SearchResultsPage.java
package com.flyright.pages;
import com.flyright.base.BasePage;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import java.util.List;
public class SearchResultsPage extends BasePage {
private static final By RESULT = By.cssSelector("[data-testid='flight-result']");
private static final By NO_RESULTS = By.cssSelector("[data-testid='no-results']");
private static final By SORT_BY_PRICE = By.cssSelector("[data-testid='sort-price']");
public SearchResultsPage(WebDriver driver) { super(driver); }
public int resultCount() {
wait.until(d -> !d.findElements(RESULT).isEmpty()
|| !d.findElements(NO_RESULTS).isEmpty());
return driver.findElements(RESULT).size();
}
public boolean noResultsShown() { return isDisplayed(NO_RESULTS); }
public SearchResultsPage sortByPrice() { click(SORT_BY_PRICE); return this; }
public List<WebElement> results() {
wait.until(ExpectedConditions.visibilityOfAllElementsLocatedBy(RESULT));
return driver.findElements(RESULT);
}
}The pattern is clear: same-page methods return this; navigation methods return the next page. Every locator is a data-testid first-class citizen. (If your target site doesn't have those, fall back to whatever's stable.)
Step 5 — Three sample tests
// src/test/java/com/flyright/tests/SearchTest.java
package com.flyright.tests;
import com.flyright.base.BaseTest;
import com.flyright.pages.HomePage;
import com.flyright.pages.SearchResultsPage;
import org.testng.Assert;
import org.testng.annotations.Test;
public class SearchTest extends BaseTest {
private static final String BASE_URL = System.getProperty("base.url", "https://flyright.example/");
@Test(groups = {"smoke"}, description = "One-way LHR → JFK on a future date returns at least one result")
public void shouldFindOneWayResults() {
SearchResultsPage results = new HomePage(getDriver())
.navigateTo(BASE_URL)
.selectOneWay()
.origin("LHR")
.destination("JFK")
.departDate("2026-09-15")
.search();
Assert.assertTrue(results.resultCount() > 0,
"Expected at least one result for LHR → JFK on 2026-09-15");
}
}// src/test/java/com/flyright/tests/DataDrivenLoginTest.java
package com.flyright.tests;
import com.flyright.base.BaseTest;
import com.flyright.utils.ExcelReader;
import com.flyright.pages.HomePage;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import java.io.IOException;
public class DataDrivenSearchTest extends BaseTest {
@DataProvider(name = "routes")
public Object[][] routes() throws IOException {
return ExcelReader.readData("src/test/resources/testdata/routes.xlsx", "Routes");
}
@Test(groups = {"regression"}, dataProvider = "routes",
description = "Each row in routes.xlsx becomes a separate parameterised test")
public void shouldSearchRoute(String origin, String dest, String date, String expected) {
int count = new HomePage(getDriver())
.navigateTo("https://flyright.example/")
.selectOneWay()
.origin(origin)
.destination(dest)
.departDate(date)
.search()
.resultCount();
Assert.assertTrue(count >= Integer.parseInt(expected),
origin + " → " + dest + " on " + date + " expected ≥ " + expected + " results, got " + count);
}
}// src/test/java/com/flyright/tests/CrossBrowserSearchTest.java
package com.flyright.tests;
import com.flyright.base.BaseTest;
import com.flyright.pages.HomePage;
import org.testng.Assert;
import org.testng.annotations.Test;
public class CrossBrowserSearchTest extends BaseTest {
@Test(groups = {"regression", "cross-browser"})
public void shouldRunSearchOnAnyBrowser() {
int results = new HomePage(getDriver())
.navigateTo("https://flyright.example/")
.selectOneWay()
.origin("LHR")
.destination("CDG")
.departDate("2026-09-15")
.search()
.resultCount();
Assert.assertTrue(results > 0);
}
}The same test class runs across browsers because BaseTest reads @Parameters("browser") from the suite XML. Three tests, three patterns: smoke, data-driven, cross-browser.
Step 6 — Screenshot listener
// src/test/java/com/flyright/listeners/ScreenshotListener.java
package com.flyright.listeners;
import com.flyright.base.DriverManager;
import org.apache.commons.io.FileUtils;
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.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.nio.file.Paths;
public class ScreenshotListener implements ITestListener {
@Override
public void onTestFailure(ITestResult result) {
WebDriver driver = DriverManager.getDriver();
if (driver == null) return;
File source = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
String stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HHmmss"));
File target = Paths.get("target", "screenshots",
result.getName() + "_" + stamp + ".png").toFile();
try { FileUtils.copyFile(source, target); }
catch (IOException e) { System.err.println("Screenshot save failed: " + e.getMessage()); }
}
}Register it in testng.xml:
<suite name="Smoke">
<listeners>
<listener class-name="com.flyright.listeners.ScreenshotListener"/>
</listeners>
<test name="Smoke">
<groups><run><include name="smoke"/></run></groups>
<packages><package name="com.flyright.tests"/></packages>
</test>
</suite>Step 7 — GitHub Actions workflow
.github/workflows/selenium.yml:
name: Selenium Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 6 * * *"
jobs:
smoke:
if: github.event_name == 'push' || github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: "21", distribution: "temurin", cache: maven }
- run: mvn -B test -DsuiteFile=smoke.xml -Dheadless=true
- if: always()
uses: actions/upload-artifact@v4
with: { name: surefire-reports, path: target/surefire-reports/ }
- if: failure()
uses: actions/upload-artifact@v4
with: { name: failure-screenshots, path: target/screenshots/ }
cross-browser:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome, firefox]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: "21", distribution: "temurin", cache: maven }
- run: mvn -B test -DsuiteFile=cross-browser.xml -Dbrowser=${{ matrix.browser }} -Dheadless=trueSmoke runs on push and PR. Cross-browser runs nightly across Chrome and Firefox in parallel jobs.
The build timeline
Step 1 of 8
Foundation
pom.xml + folder structure + DriverManager + BaseTest + BasePage. Compile cleanly. Run an empty mvn test successfully.
Build in this order. Trying to do everything at once (foundation + 25 tests + listeners + CI) tangles the work; each phase has clear acceptance criteria.
Project work
Build the framework's first thirteen tests. 4–6 hours.
- Create
DriverManager,BaseTest,BasePage, theHomePageandSearchResultsPagepage objects from this lesson — adapted to your target site's selectors. - Write the five search tests from lesson 1's brief: valid one-way, valid round-trip, past-dated departure rejection, no-results message, sort-by-price.
- Write the five booking-flow tests. The end-to-end happy path is the hardest — it touches every page object. Get it green before the others.
- Build
ExcelReaderandJsonReaderutilities. Createroutes.xlsxwith 5 routes; createusers.jsonwith 5 user shapes. Add the three remaining data-driven tests (parameterised search using the Excel file). - Run all 13 tests via
mvn test -DsuiteFile=smoke.xml. They should all pass headlessly. Total wall-clock should be well under 10 minutes.
Lesson 3 is your self-assessment, the remaining 12 tests, the stretch goals, and the README that turns this into a portfolio piece.