10 · Project — POM Framework with API Tests¶
This project assembles all nine modules into one deliverable: a Page Object Model framework with a thread-safe driver factory, externalised configuration, TestNG data providers, RestAssured API tests, cross-browser execution and an Allure report. It is the artefact you show in an interview when asked "what have you built?"
Application under test: https://the-internet.herokuapp.com
API under test: https://jsonplaceholder.typicode.com
Project layout¶
qa-automation-framework/
├── pom.xml
├── README.md
└── src/test
├── java/com/example/
│ ├── config/Config.java
│ ├── driver/DriverFactory.java
│ ├── listeners/ScreenshotListener.java
│ ├── pages/ BasePage, LoginPage, SecurePage, DropdownPage
│ ├── api/ PostsApiTest, UsersApiTest, PostRequest
│ └── tests/ BaseTest, LoginIT, DropdownIT, LoginDataDrivenIT
└── resources/
├── config.properties
├── logins.csv
├── schemas/user-schema.json
├── smoke.xml
└── regression.xml
The build file¶
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>qa-automation-framework</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<suiteXmlFile>src/test/resources/regression.xml</suiteXmlFile>
<browser>chrome</browser>
<headless>true</headless>
<threads>3</threads>
<aspectj.version>1.9.22</aspectj.version>
</properties>
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.23.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-schema-validator</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-testng</artifactId>
<version>2.27.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<suiteXmlFiles>
<suiteXmlFile>${suiteXmlFile}</suiteXmlFile>
</suiteXmlFiles>
<trimStackTrace>false</trimStackTrace>
<argLine>
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
</argLine>
<systemPropertyVariables>
<browser>${browser}</browser>
<headless>${headless}</headless>
<allure.results.directory>${project.build.directory}/allure-results</allure.results.directory>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>smoke</id>
<properties>
<suiteXmlFile>src/test/resources/smoke.xml</suiteXmlFile>
</properties>
</profile>
</profiles>
</project>
The base test¶
Everything the framework does per test lives here, so no test class ever touches a driver directly.
package com.example.tests;
import com.example.config.Config;
import com.example.driver.DriverFactory;
import org.openqa.selenium.WebDriver;
import org.testng.annotations.*;
@Listeners(com.example.listeners.ScreenshotListener.class)
public abstract class BaseTest {
protected WebDriver driver;
@Parameters({"browser"})
@BeforeMethod(alwaysRun = true)
public void startBrowser(@Optional String browser) {
String chosen = (browser != null) ? browser : Config.browser();
DriverFactory.start(chosen, Config.headless());
driver = DriverFactory.get();
}
@AfterMethod(alwaysRun = true)
public void stopBrowser() {
DriverFactory.stop();
}
}
package com.example.tests;
import com.example.config.Config;
import com.example.pages.LoginPage;
import com.example.pages.SecurePage;
import io.qameta.allure.*;
import org.testng.annotations.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Epic("Authentication")
@Feature("Login")
public class LoginIT extends BaseTest {
@Test(groups = "smoke", description = "REQ-02/03/04 -- valid credentials reach the secure area")
@Severity(SeverityLevel.BLOCKER)
@Link(name = "REQ-02", url = "https://wiki.example.com/reqs/REQ-02")
public void validLoginReachesSecureArea() {
SecurePage secure = new LoginPage(driver, Config.baseUrl())
.open()
.loginAs(Config.username(), Config.password());
assertThat(secure.getHeading()).isEqualTo("Secure Area");
assertThat(secure.getFlashMessage()).contains("You logged into a secure area");
assertThat(secure.isLogoutVisible()).as("REQ-04 logout button").isTrue();
}
@Test(groups = "regression", description = "REQ-09 -- logout returns to /login")
public void logoutReturnsToLogin() {
String flash = new LoginPage(driver, Config.baseUrl())
.open()
.loginAs(Config.username(), Config.password())
.logout()
.getFlashMessage();
assertThat(flash).contains("You logged out of the secure area");
}
}
package com.example.tests;
import com.example.config.Config;
import com.example.pages.LoginPage;
import org.testng.annotations.*;
import static org.assertj.core.api.Assertions.assertThat;
public class LoginDataDrivenIT extends BaseTest {
@DataProvider(name = "invalidLogins")
public Object[][] invalidLogins() {
return new Object[][] {
{ "wronguser", "SuperSecretPassword!", "Your username is invalid!" },
{ "tomsmith", "wrongpassword", "Your password is invalid!" },
{ "", "", "Your username is invalid!" },
{ "TOMSMITH", "SuperSecretPassword!", "Your username is invalid!" },
};
}
@Test(dataProvider = "invalidLogins", groups = "regression",
description = "REQ-05/06/07 -- invalid credentials are rejected")
public void rejectsInvalidCredentials(String user, String pass, String expected) {
String flash = new LoginPage(driver, Config.baseUrl())
.open()
.loginExpectingFailure(user, pass)
.getFlashMessage();
assertThat(flash)
.as("user='%s' pass='%s'", user, pass)
.contains(expected);
}
}
The API tests¶
package com.example.api;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import org.testng.annotations.*;
import static io.restassured.RestAssured.given;
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;
import static org.hamcrest.Matchers.*;
public class UsersApiTest {
private RequestSpecification spec;
@BeforeClass(alwaysRun = true)
public void buildSpec() {
spec = new RequestSpecBuilder()
.setBaseUri("https://jsonplaceholder.typicode.com")
.setContentType(ContentType.JSON)
.build();
}
@Test(groups = "smoke", description = "API-01 -- GET /users returns 10 valid users")
public void listUsers() {
given().spec(spec)
.when().get("/users")
.then().statusCode(200)
.body("size()", equalTo(10))
.body("email", everyItem(containsString("@")))
.time(lessThan(5000L));
}
@Test(groups = "regression", description = "API-02 -- a user matches the agreed schema")
public void userMatchesSchema() {
given().spec(spec)
.when().get("/users/1")
.then().statusCode(200)
.body(matchesJsonSchemaInClasspath("schemas/user-schema.json"));
}
@Test(groups = "regression", description = "API-03 -- an unknown user returns 404")
public void unknownUserReturns404() {
given().spec(spec).when().get("/users/9999").then().statusCode(404);
}
}
Running it¶
# One-time: JDK 21 and Maven on PATH, Chrome and Firefox installed
mvn -version
# Full regression, headless Chrome, 3 threads
mvn clean verify
# Smoke only
mvn clean verify -Psmoke
# Watch it happen in a real browser
mvn clean verify -Dheadless=false
# A different browser and environment
mvn clean verify -Dbrowser=firefox -Dbase.url=https://staging.example.com
# One class, one method
mvn clean verify -Dit.test=LoginIT
mvn clean verify -Dit.test=LoginIT#validLoginReachesSecureArea
# The report
allure serve target/allure-results
===============================================
Regression Suite
Total tests run: 14, Passes: 14, Failures: 0, Skips: 0
===============================================
[INFO] BUILD SUCCESS
[INFO] Total time: 01:12 min
Your README.md must contain exactly this section. A framework a new
starter cannot run in ten minutes from the README is not finished.
Acceptance checklist¶
- [ ] No
Bylocator appears anywhere insrc/test/java/com/example/tests/ - [ ] No
Thread.sleepanywhere; implicit wait is zero - [ ] No URL, username or password is hard-coded in a test class
- [ ]
DriverFactoryusesThreadLocaland callsremove()in teardown - [ ] Every teardown method has
alwaysRun = true - [ ] At least one
@DataProviderdrives four or more rows - [ ] At least three RestAssured tests, one with schema validation
- [ ]
smoke.xmlandregression.xmlboth run green, smoke in under a minute - [ ] The suite runs with
parallel="methods" thread-count="3"without failures - [ ] Failures produce a screenshot attached to the Allure report
- [ ]
mvn clean verifyis green from a fresh clone with no manual setup - [ ]
README.mddocuments every command in "Running it"
Stretch goals¶
- Prove the parallelism is safe. Set
thread-count="5", run twenty times, and record every failure. Any failure that appears in fewer than twenty runs is shared state — find it. This is the exercise that turns a framework from "works" into "trusted". - Add API-driven UI setup. Create a record through the API, then assert it in the UI. Measure how much faster it is than doing the setup through forms, and put the number in your README.
- Add cross-browser smoke. A
cross-browser.xmlwith Chrome, Firefox and Edge blocks underparallel="tests". Report grouped by test, not by browser (Module 09, trap 5). - Wire in CI. A GitHub Actions workflow running
mvn clean verifyon every push, archivingtarget/allure-resultsand the screenshots as artifacts. Level 3 covers this properly — a first working version now is worth more than a perfect one later. - Add a retry analyzer, then justify it. Implement
IRetryAnalyzer, log every retry, and write a paragraph on why a rising retry count is itself a defect (Module 03). - Contract-test the API. Extend
user-schema.jsonto constrain formats (email,uri) as well as presence, and add a test that fails when a field's type changes rather than only when it disappears.
How It Actually Works¶
ScreenshotListener is where every mechanism from this level converges,
so it's worth tracing precisely what happens when a test fails. TestNG's
ITestListener interface defines callback methods
(onTestFailure(ITestResult result), onTestSuccess, etc.) that TestNG's
test runner invokes at fixed points during execution — registering the
listener (via @Listeners(ScreenshotListener.class) or in testng.xml)
just adds it to an internal list the runner iterates over after every test
method returns or throws. onTestFailure receives an ITestResult
carrying the throwable and the test method's metadata, but not a
reference to the WebDriver in use, because TestNG has no concept of
Selenium — that's why the listener has to reach back into your framework's
own state, typically by calling the same ThreadLocal<WebDriver> driver
factory the failing test used, retrieving the driver instance for
whichever thread just failed (the callback runs on that same thread),
and calling ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE)
— which itself sends one more WebDriver-protocol command asking Chrome to
render its current frame to a PNG, entirely independent of whatever caused
the test to fail.
That screenshot file is then attached to the Allure result for that same
test via Allure.addAttachment(...), which — as covered in Module 08 —
just writes bytes alongside the JSON result file Allure's own listener
already produced for that test. Three independent listener/callback
mechanisms (TestNG's ITestListener, the driver factory's ThreadLocal
lookup, and Allure's own result writer) chain together through nothing
more than "run this code when that event fires" — no framework here is
aware of the others' internals, they're composed entirely through
well-defined extension points.
Exercise¶
Build the framework above, then produce a reflection.md answering:
- Which module's technique removed the most duplication from your Level 1 code — page objects, the driver factory, data providers or the config class? Quantify it: lines before and after.
- Run the suite serially and with three threads. Report both times, and name one thing that had to be thread-safe for the parallel run to work.
- Point at one assertion in your suite whose failure message would let a developer diagnose the bug without opening your code, and one whose message would not. Fix the second.
- Your API tests run against a fake backend that accepts every POST and stores nothing. Name two specific assertions that pass here but would fail against a real API, and how you would know.
- Take the requirement list from the Level 1 project and map each requirement to the test that covers it. Any requirement with no test, and any test covering no requirement, is a finding — list both.
- A colleague clones your repository on a Windows machine with only Firefox
installed. Walk through exactly what happens when they run
mvn clean verify, and fix whatever breaks.