Guided Walkthrough — POJOs, Auth, Data-Driven Tests, Reports

12 min read

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-history

Smoke 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: false rejects it. Add the field to the schema with "required": [...] not including it. The contract just gained a permitted field.
  • Allure report empty. The allure-testng listener isn't being picked up. Add <listener>io.qameta.allure.testng.AllureTestNg</listener> to testng.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 @Step annotations. Adding @Step to 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.createBook that 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.

  1. Read your pom.xml. Are versions in <properties>? Is Surefire wired to ${suiteXmlFile}?
  2. Read one POJO. Lombok used? @JsonIgnoreProperties(ignoreUnknown = true) on the response model? @JsonInclude(NON_NULL) on the partial-update request?
  3. Read your TokenManager. Per-role cache? Expiry buffer (30s typical)? Reads from env vars via Config.required(...)?
  4. Read your Specs. Built once on class load? Each role on a separate spec? Both Authorization header and base URI baked in?
  5. Read one @Test. Does it read like business intent? Or is half the body given().header(...).contentType(...) plumbing that should live in a spec?
  6. 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.
  7. 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?
  8. 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.

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