The brief named the deliverables; this lesson is the implementation walkthrough for the components most learners trip on. It isn't a copy-paste-and-ship template — that wouldn't teach anything. It's the connective tissue between the chapters: how the POJO from Chapter 5 becomes the helper from Chapter 6 becomes the data-provider row from Chapter 7, all anchored in BookVault's domain. Read it as a worked example, then return to your own project and build the parts you didn't know how to start.
Step 1 — pom.xml
Anchor every dependency to a property so version bumps are one-line edits:
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<rest-assured.version>5.4.0</rest-assured.version>
<jackson.version>2.17.0</jackson.version>
<testng.version>7.10.2</testng.version>
<lombok.version>1.18.32</lombok.version>
<faker.version>1.0.2</faker.version>
<allure.version>2.27.0</allure.version>
</properties>
<dependencies>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>${faker.version}</version>
</dependency>
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-rest-assured</artifactId>
<version>${allure.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-testng</artifactId>
<version>${allure.version}</version>
<scope>test</scope>
</dependency>
</dependencies>The Surefire plugin needs to be told to read your testng.xml:
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<suiteXmlFiles>
<suiteXmlFile>${suiteXmlFile}</suiteXmlFile>
</suiteXmlFiles>
<argLine>-Xmx1024m</argLine>
</configuration>
</plugin>-DsuiteXmlFile=src/test/resources/testng.xml from the command line picks the suite. Set a default in <properties> so mvn test works without flags.
Step 2 — POJOs
Lombok keeps these short. The Book response model:
package com.bookvault.tests.models.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.*;
@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Book {
private int id;
private String title;
private String author;
private String isbn;
private int copiesTotal;
private int copiesAvailable;
private String createdAt;
}The matching create request — note no id, no timestamps, no copies-available:
package com.bookvault.tests.models.request;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.*;
@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CreateBookRequest {
private String title;
private String author;
private String isbn;
@Builder.Default private int copiesTotal = 1;
}@JsonInclude(NON_NULL) matters here — partial-update tests reuse this shape via toBuilder() and rely on null fields not going on the wire.
The generic wrapper for envelope responses:
@Data @NoArgsConstructor @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ApiResponse<T> {
private T data;
private String message;
private String trace;
}Use it via TypeRef<ApiResponse<Book>> in helpers — the next step.
Step 3 — TokenManager with per-role caching
The Chapter 4 manager, extended for multiple roles:
package com.bookvault.tests.config;
import com.bookvault.tests.helpers.AuthApiHelper;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
public final class TokenManager {
private static final Map<String, Token> cache = new ConcurrentHashMap<>();
private record Token(String value, long expiresAt) {
boolean expired() {
return System.currentTimeMillis() > expiresAt - 30_000;
}
}
private TokenManager() {}
public static synchronized String tokenFor(String role) {
Token t = cache.get(role);
if (t == null || t.expired()) {
t = login(role);
cache.put(role, t);
}
return t.value();
}
private static Token login(String role) {
String email = Config.required("ROLE_" + role.toUpperCase() + "_EMAIL");
String password = Config.required("ROLE_" + role.toUpperCase() + "_PASSWORD");
var loginResponse = AuthApiHelper.loginRaw(email, password);
return new Token(
loginResponse.getToken(),
System.currentTimeMillis() + loginResponse.getExpiresIn() * 1000L);
}
public static void invalidate(String role) {
cache.remove(role);
}
}The role string is the key — admin, librarian, member. loginRaw returns the typed LoginResponse; the helper does the actual HTTP call so TokenManager stays focused on caching.
Step 4 — Specs
package com.bookvault.tests.specs;
import com.bookvault.tests.config.Config;
import com.bookvault.tests.config.TokenManager;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import static org.hamcrest.Matchers.lessThan;
public final class Specs {
public static final RequestSpecification ADMIN = forRole("admin");
public static final RequestSpecification LIBRARIAN = forRole("librarian");
public static final RequestSpecification MEMBER = forRole("member");
public static final RequestSpecification GUEST = base().build();
private Specs() {}
private static RequestSpecification forRole(String role) {
return base()
.addHeader("Authorization", "Bearer " + TokenManager.tokenFor(role))
.build();
}
private static RequestSpecBuilder base() {
return new RequestSpecBuilder()
.setBaseUri(Config.baseUri())
.setBasePath(Config.basePath())
.setContentType(ContentType.JSON)
.setAccept(ContentType.JSON);
}
}A separate ResponseSpecs class for status/contentType combinations the same way:
public final class ResponseSpecs {
public static final ResponseSpecification OK = new ResponseSpecBuilder()
.expectStatusCode(200)
.expectContentType(ContentType.JSON)
.expectResponseTime(lessThan(5000L))
.build();
public static final ResponseSpecification CREATED = new ResponseSpecBuilder()
.expectStatusCode(201)
.expectContentType(ContentType.JSON)
.build();
public static final ResponseSpecification NO_CONTENT = new ResponseSpecBuilder()
.expectStatusCode(204)
.build();
public static final ResponseSpecification NOT_FOUND = new ResponseSpecBuilder()
.expectStatusCode(404)
.build();
public static final ResponseSpecification UNAUTHORIZED = new ResponseSpecBuilder()
.expectStatusCode(401)
.build();
public static final ResponseSpecification FORBIDDEN = new ResponseSpecBuilder()
.expectStatusCode(403)
.build();
private ResponseSpecs() {}
}Step 5 — Helpers
BookApiHelper is the canonical example:
package com.bookvault.tests.helpers;
import com.bookvault.tests.models.request.*;
import com.bookvault.tests.models.response.*;
import com.bookvault.tests.specs.*;
import io.qameta.allure.Step;
import static io.restassured.RestAssured.given;
public final class BookApiHelper {
private BookApiHelper() {}
@Step("Create a book")
public static Book createBook(CreateBookRequest req) {
return given().spec(Specs.ADMIN)
.body(req)
.when()
.post("/books")
.then()
.spec(ResponseSpecs.CREATED)
.extract().as(Book.class);
}
@Step("Get book by id: {id}")
public static Book getBook(int id) {
return given().spec(Specs.MEMBER)
.pathParam("id", id)
.when()
.get("/books/{id}")
.then()
.spec(ResponseSpecs.OK)
.extract().as(Book.class);
}
@Step("Update book {id}")
public static Book updateBook(int id, UpdateBookRequest req) {
return given().spec(Specs.LIBRARIAN)
.pathParam("id", id)
.body(req)
.when()
.patch("/books/{id}")
.then()
.spec(ResponseSpecs.OK)
.extract().as(Book.class);
}
@Step("Delete book {id}")
public static void deleteBook(int id) {
given().spec(Specs.ADMIN)
.pathParam("id", id)
.when()
.delete("/books/{id}")
.then().spec(ResponseSpecs.NO_CONTENT);
}
}Note @Step from Allure — when the report renders, each helper call becomes a named step the reader can drill into for the request/response payload.
Step 6 — Three example tests
CRUD round-trip:
public class BookCrudTest extends BaseApiTest {
private final List<Integer> created = new ArrayList<>();
@Test
public void createReadUpdateDelete() {
// CREATE
CreateBookRequest req = BookFactory.random();
Book book = BookApiHelper.createBook(req);
created.add(book.getId());
Assert.assertTrue(book.getId() > 0);
Assert.assertEquals(book.getTitle(), req.getTitle());
Assert.assertEquals(book.getCopiesAvailable(), req.getCopiesTotal());
// READ
Book fetched = BookApiHelper.getBook(book.getId());
Assert.assertEquals(fetched.getIsbn(), req.getIsbn());
// UPDATE
UpdateBookRequest patch = UpdateBookRequest.builder().title("Updated " + req.getTitle()).build();
Book updated = BookApiHelper.updateBook(book.getId(), patch);
Assert.assertEquals(updated.getTitle(), "Updated " + req.getTitle());
// DELETE
BookApiHelper.deleteBook(book.getId());
created.remove(Integer.valueOf(book.getId()));
given().spec(Specs.MEMBER)
.pathParam("id", book.getId())
.when().get("/books/{id}")
.then().spec(ResponseSpecs.NOT_FOUND);
}
@AfterMethod(alwaysRun = true)
public void cleanup() {
for (int id : created) {
try { BookApiHelper.deleteBook(id); } catch (Exception ignored) {}
}
created.clear();
}
}The CRUD-and-verify-deleted shape is the test you'll write most often. Note the cleanup tracks the in-progress ID and removes it from the list once the test deletes it itself — so the cleanup hook doesn't try to delete it again.
Authorisation matrix:
@DataProvider(name = "bookAccessMatrix")
public Object[][] bookAccessMatrix() {
return new Object[][] {
// { role, endpoint, method, expectedStatus }
{ "admin", "/books", "POST", 201 },
{ "librarian", "/books", "POST", 201 },
{ "member", "/books", "POST", 403 },
{ "guest", "/books", "POST", 401 },
{ "admin", "/books/{id}", "DELETE", 204 },
{ "librarian", "/books/{id}", "DELETE", 403 }, // librarians can't delete
{ "member", "/books/{id}", "DELETE", 403 },
};
}
@Test(dataProvider = "bookAccessMatrix")
public void roleAccess(String role, String endpoint, String method, int expected) {
var spec = switch (role) {
case "admin" -> Specs.ADMIN;
case "librarian" -> Specs.LIBRARIAN;
case "member" -> Specs.MEMBER;
default -> Specs.GUEST;
};
var req = given().spec(spec);
if (method.equals("POST")) {
req = req.body(BookFactory.random());
}
if (endpoint.contains("{id}")) {
// for DELETE — create a book to delete
Book b = BookApiHelper.createBook(BookFactory.random());
req = req.pathParam("id", b.getId());
}
req.when().request(method, endpoint)
.then().statusCode(expected);
}Seven cases, one method body. Read the failure: roleAccess("librarian", "/books/{id}", "DELETE", 403) failing tells you the librarian-delete-block regressed.
Data-driven creation from JSON:
@DataProvider(name = "bookCases")
public Object[][] bookCases() throws IOException {
BookTestCase[] cases = new ObjectMapper().readValue(
new File("src/test/resources/testdata/book-creation.json"),
BookTestCase[].class);
return Arrays.stream(cases)
.map(c -> new Object[]{ c.getLabel(), c.getRequest(), c.getExpectedStatus() })
.toArray(Object[][]::new);
}
@Test(dataProvider = "bookCases")
public void createBookCases(String label, CreateBookRequest req, int expected) {
given().spec(Specs.ADMIN)
.body(req)
.when().post("/books")
.then().statusCode(expected);
}Five rows in JSON, five tests. The label parameter shows up in the report — createBookCases("missing_isbn_400", ...) — making failures self-describing.
Step 7 — Schema validation
src/test/resources/schemas/book-schema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "title", "author", "isbn", "copiesTotal", "copiesAvailable"],
"properties": {
"id": { "type": "integer", "minimum": 1 },
"title": { "type": "string", "minLength": 1 },
"author": { "type": "string", "minLength": 1 },
"isbn": { "type": "string", "pattern": "^[\\d-]+$" },
"copiesTotal": { "type": "integer", "minimum": 0 },
"copiesAvailable": { "type": "integer", "minimum": 0 },
"createdAt": { "type": "string" }
},
"additionalProperties": false
}@Test
public void getBookMatchesSchema() {
Book existing = BookApiHelper.createBook(BookFactory.random());
given().spec(Specs.MEMBER)
.pathParam("id", existing.getId())
.when()
.get("/books/{id}")
.then()
.statusCode(200)
.body(matchesJsonSchemaInClasspath("schemas/book-schema.json"));
}The schema test is the regression net: rename a field on the API and this fails first, before any field-level test gets confused.
Step 8 — CI workflow
.github/workflows/api-tests.yml:
name: API Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 2 * * *"
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
cache: maven
- name: Run smoke (PR) or full (main/cron)
env:
API_BASE_URI: ${{ secrets.STAGING_API_URI }}
ROLE_ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}
ROLE_ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
ROLE_LIBRARIAN_EMAIL: ${{ secrets.LIBRARIAN_EMAIL }}
ROLE_LIBRARIAN_PASSWORD:${{ secrets.LIBRARIAN_PASSWORD }}
ROLE_MEMBER_EMAIL: ${{ secrets.MEMBER_EMAIL }}
ROLE_MEMBER_PASSWORD: ${{ secrets.MEMBER_PASSWORD }}
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
mvn -B test -DsuiteXmlFile=src/test/resources/smoke.xml
else
mvn -B test -DsuiteXmlFile=src/test/resources/testng.xml
fi
- name: Upload Surefire reports
if: always()
uses: actions/upload-artifact@v4
with: { name: surefire-reports, path: target/surefire-reports/ }
- name: Upload Allure results
if: always()
uses: actions/upload-artifact@v4
with: { name: allure-results, path: target/allure-results/ }
- name: Generate and publish Allure
if: always() && github.ref == 'refs/heads/main'
uses: simple-elf/allure-report-action@v1.7
with:
allure_results: target/allure-results
allure_history: allure-history
- name: Deploy report to Pages
if: always() && github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: allure-historySmoke on PR (~2 minutes, fast feedback), full suite on main and cron, Allure published to Pages from main only. Set the seven secrets in the repo settings; the rest is wiring.
The framework, working
Step 1 of 7
Push triggers CI
GitHub Actions checks out the repo, sets up Java 21, restores Maven cache, injects secrets as env vars.
Read the steps top to bottom: every layer the framework builds is a separable concern, but together they produce a single line of test method that reads as the business intent. That's the framework working.
Where to look when stuck
- Authentication failing in CI but passing locally. The role env var names are different between your laptop and GitHub. Print
Config.adminEmail()(without the password) at suite startup to verify which value the env vars resolved to. - Schema test failing on a newly-added optional field.
additionalProperties: falserejects it. Add the field to the schema with"required": [...]not including it. The contract just gained a permitted field. - Allure report empty. The
allure-testnglistener isn't being picked up. Add<listener>io.qameta.allure.testng.AllureTestNg</listener>totestng.xml. - Cleanup sometimes fails. A test that creates a borrowing referencing a book — the cleanup tries to delete the book first, fails because of the FK. LIFO cleanup (delete borrowings before books) fixes it.
Each of these is a one-line fix once you know to look. The framework's value is containing problems to a single layer — the bug is rarely in two places at once.
⚠️ Common mistakes
- Cargo-culted
@Stepannotations. Adding@Stepto every helper method makes the Allure report a wall of nearly identical entries. Annotate only the ones whose name adds context — "Borrow a book," "Return a book," "Create a member with role admin." Skip the trivial getters. - Schemas frozen in time. A schema written six months ago and never reviewed since flags changes that are intentional product evolution. Treat schema files like API documentation — review them as part of any API contract change.
- Helpers that hide failure modes. A
BookApiHelper.createBookthat asserts 201 is fine. One that also logs in, also creates a member, also preheats the cache hides three operations behind one call. Helpers should describe API operations, not test workflows.
🎯 Practice task
Use this lesson as a checklist against your own implementation. Spend 30 minutes auditing.
- Read your
pom.xml. Are versions in<properties>? Is Surefire wired to${suiteXmlFile}? - Read one POJO. Lombok used?
@JsonIgnoreProperties(ignoreUnknown = true)on the response model?@JsonInclude(NON_NULL)on the partial-update request? - Read your
TokenManager. Per-role cache? Expiry buffer (30s typical)? Reads from env vars viaConfig.required(...)? - Read your
Specs. Built once on class load? Each role on a separate spec? BothAuthorizationheader and base URI baked in? - Read one
@Test. Does it read like business intent? Or is half the bodygiven().header(...).contentType(...)plumbing that should live in a spec? - Find a slow test. Run with
-Dtest.verbose=true. Any test over 3 seconds against a stub deserves a look — the helper is doing too much, or auth is being re-fetched per method. - Run the failure path. Force one test to fail. Is the artefact useful? Does the request log have the bearer token blacklisted? Does Allure attach the request and response?
- Review your CI run. Does the PR build run only smoke? Does the main build run the full suite? Does the cron exist? Does the report reach Pages?
Anywhere your implementation differs from the walkthrough, decide deliberately whether your version is better, worse, or equivalent. The goal isn't conformity — it's awareness of the trade-off you've made.
Final lesson: a self-assessment checklist for the project, reflection prompts, and stretch goals — including how to extend the framework into UI integration, performance testing, and contract testing.