The framework spine — base class, config, specs, filters — handles cross-cutting concerns. The last layer is the domain layer: the helpers that name common API operations as Java methods, and the factories that produce realistic test data without copy-paste. A UserApiHelper.createUser(req, token) reads better than ten lines of given()/when()/then() in every test that needs a user. A TestDataFactory.randomUser() produces a unique user per test without three lines of UUID-fiddling. This lesson is the patterns that grow naturally on top of the previous chapter, the layout that keeps them findable, and the rule for deciding when a helper has earned its place.
API helper classes
A helper class wraps the most common operations on one resource. The shape:
package com.mycompany.apitests.helpers;
import com.mycompany.apitests.models.request.CreateUserRequest;
import com.mycompany.apitests.models.request.UpdateUserRequest;
import com.mycompany.apitests.models.response.UserResponse;
import com.mycompany.apitests.specs.Specs;
import io.restassured.http.ContentType;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.is;
public final class UserApiHelper {
private UserApiHelper() {}
public static UserResponse createUser(CreateUserRequest req) {
return given().spec(Specs.admin)
.body(req)
.when()
.post("/users")
.then()
.statusCode(201)
.extract().as(UserResponse.class);
}
public static UserResponse getUser(int id) {
return given().spec(Specs.admin)
.pathParam("id", id)
.when()
.get("/users/{id}")
.then()
.statusCode(200)
.extract().as(UserResponse.class);
}
public static UserResponse updateUser(int id, UpdateUserRequest req) {
return given().spec(Specs.admin)
.pathParam("id", id)
.body(req)
.when()
.patch("/users/{id}")
.then()
.statusCode(200)
.extract().as(UserResponse.class);
}
public static void deleteUser(int id) {
given().spec(Specs.admin)
.pathParam("id", id)
.when()
.delete("/users/{id}")
.then()
.statusCode(anyOf(is(200), is(204)));
}
}A few decisions baked in:
finalclass with a private constructor. It's a static-method namespace, never instantiated.- Helpers assume the happy path. They assert on success and return the typed response. A test that wants to assert on a failure doesn't call the helper — it writes the rejection inline.
- Helpers default to one role. The
adminspec is hardcoded; tests that need to act as a viewer call the API directly. - Methods return the typed POJO — so the test can assert on it without re-extracting.
Tests using the helper
@Test
public void createUserHasGeneratedId() {
CreateUserRequest req = new CreateUserRequest("Alice", "alice@test.com", "admin");
UserResponse user = UserApiHelper.createUser(req);
Assert.assertTrue(user.getId() > 0);
Assert.assertEquals(user.getName(), "Alice");
}
@Test
public void deletedUserIsNotFound() {
UserResponse created = UserApiHelper.createUser(
new CreateUserRequest("Temp", "temp@test.com", "tester"));
UserApiHelper.deleteUser(created.getId());
given().spec(Specs.admin)
.pathParam("id", created.getId())
.when()
.get("/users/{id}")
.then()
.statusCode(404);
}Each test reads as the business action it represents — create, delete, verify gone. The Rest Assured plumbing for the create and delete steps is named once in the helper, not repeated in every test.
The crucial discipline: the helper assertion is "the operation succeeded." The test's assertion is the thing being tested (the user has an ID, the deleted user is gone). When the test asserts on a failure (a 404 after delete), it skips the helper for that step and writes the call inline.
Test data factories
Tests need data; that data should be unique per run (so concurrent runs don't collide), realistic (so debug logs aren't a sea of "test1"), and minimal at the call site (so the test doesn't lead with five lines of setup).
package com.mycompany.apitests.factories;
import com.github.javafaker.Faker;
import com.mycompany.apitests.models.request.CreateUserRequest;
import java.util.UUID;
public final class TestDataFactory {
private static final Faker faker = new Faker();
private TestDataFactory() {}
public static CreateUserRequest randomUser() {
String unique = UUID.randomUUID().toString().substring(0, 8);
return CreateUserRequest.builder()
.name(faker.name().fullName())
.email("test+" + unique + "@example.com")
.role("tester")
.build();
}
public static CreateUserRequest randomAdmin() {
return randomUser().toBuilder().role("admin").build();
}
public static CreateUserRequest userWithName(String name) {
return randomUser().toBuilder().name(name).build();
}
}Notes worth making explicit:
@Builder.toBuilder = trueon the POJO (set with@Builder(toBuilder = true)on Lombok) makes therandomUser().toBuilder().role("admin").build()chain work. It's the variant pattern: a base random user, with one or two fields tweaked.UUID.randomUUID().toString().substring(0, 8)for uniqueness — short enough to read in logs, unique enough that two parallel test runs don't conflict.- Faker generates realistic names, emails, addresses, etc. It makes test logs legible.
Tests with the factory
@Test
public void newUserCanLogIn() {
CreateUserRequest req = TestDataFactory.randomUser();
UserResponse created = UserApiHelper.createUser(req);
String token = AuthApiHelper.login(req.getEmail(), "DefaultPass123");
given().auth().oauth2(token)
.when().get("/users/me")
.then().statusCode(200)
.body("email", equalTo(req.getEmail()));
}The data is unique on every run, the helper does the create, the auth helper does the login. The test reads as a story: make a user, log in as them, confirm they're authenticated. No JsonPath, no headers, no plumbing.
Project layout
src/test/java/com/mycompany/apitests/
├── BaseApiTest.java
├── config/
│ ├── Config.java
│ └── TokenManager.java
├── specs/
│ ├── Specs.java
│ └── ResponseSpecs.java
├── filters/
│ ├── TraceIdFilter.java
│ └── TimingFilter.java
├── helpers/
│ ├── UserApiHelper.java
│ ├── AuthApiHelper.java
│ ├── OrderApiHelper.java
│ └── ProductApiHelper.java
├── factories/
│ └── TestDataFactory.java
├── models/
│ ├── request/
│ │ └── CreateUserRequest.java
│ └── response/
│ └── UserResponse.java
└── tests/
├── UserApiTest.java
├── OrderApiTest.java
└── auth/
├── LoginTest.java
└── AuthorisationMatrixTest.java
Eight directories, each with a single concern. The shape is the same in every project that grows past 50 tests — and it's findable for someone joining the team six months later, because the structure tells the story.
How the layers interact
- – UserApiHelper.createUser(req)
- – AuthApiHelper.login(email, pw)
- – OrderApiHelper.cancelOrder(id)
- – TestDataFactory.randomUser()
- – TestDataFactory.userWithRole("admin")
- – Fixtures + Faker for realism
- – Specs.admin / .user / .guest
- – ResponseSpecs.ok / .created
- CreateUserRequest –
- UserResponse –
- ErrorResponse –
- BaseApiTest + Config –
- TokenManager –
- Filters: log, trace, time –
The test sits at the centre, calling outward. Each layer has a single concern; no layer reaches around another. The test cares only about the business assertion — every other concern is handled by the layer below.
When to add a helper method
Three rules of thumb that keep helpers from becoming bloated:
- Three callers. Don't create a helper for a one-off API call. Wait until at least three tests need the same shape — then extract.
- Happy-path only. The helper asserts the call succeeded (
statusCode(201), returns the typed response). It doesn't take anexpectedStatusparameter, doesn't carry retry logic, doesn't do error handling. Tests for failures call the API directly. - One operation per method. Don't write
createUserAndLogIn(...)— that's two helpers (UserApiHelper.createUser,AuthApiHelper.login) chained. The test composes them.
These rules collapse to one principle: helpers describe API operations, not test workflows. Test workflows live in the test methods, where they belong.
Helper for the auth API
Auth is the second resource that always grows a helper:
public final class AuthApiHelper {
private AuthApiHelper() {}
public static String login(String email, String password) {
return given().spec(Specs.guest)
.body(Map.of("email", email, "password", password))
.when()
.post("/auth/login")
.then()
.statusCode(200)
.extract().path("token");
}
public static void logout(String token) {
given().auth().oauth2(token)
.when()
.post("/auth/logout")
.then()
.statusCode(anyOf(is(200), is(204)));
}
}Used in tests where the login itself isn't being tested — just used as a step:
@Test
public void newlyCreatedUserCanLogIn() {
CreateUserRequest req = TestDataFactory.randomUser();
UserApiHelper.createUser(req);
String token = AuthApiHelper.login(req.getEmail(), "DefaultPass");
Assert.assertNotNull(token);
}The login is being verified by virtue of returning a non-null token. The test that actually tests the login contract (response shape, error cases) sits in LoginTest and calls the API directly without the helper.
A small reminder about over-engineering
Helpers solve real problems — but only at scale. A suite with 8 tests doesn't benefit from a 6-method helper for each resource; the indirection costs more than the duplication saves. Build helpers when:
- The same call pattern appears in 3+ tests.
- The number of tests is growing past 30–50.
- New team members are spending time figuring out "how to make a user" instead of "what to test."
For small suites, inline given().post(...) is clearer, not worse. The framework patterns from this chapter are tools — apply them when the suite is large enough to need them.
⚠️ Common mistakes
- Helpers that take an
expectedStatusCodeparameter. The moment a helper handles both 201 and 400, it's not a helper, it's a generic HTTP client. Keep helpers happy-path; failure tests inline the call. - Test data factories that don't randomise. A factory that always returns
"Test User"produces unique-constraint violations on the second run. Always include a per-run unique component (UUID, timestamp). - One giant
ApiHelperclass for all resources. The layer is fine; the flatness isn't. Split per resource (UserApiHelper,OrderApiHelper,ProductApiHelper). The same code, organised better.
🎯 Practice task
Build the helper layer on top of the framework you've grown. 30–40 minutes.
- Create
UserApiHelper.javawith at least four methods:createUser(req),getUser(id),updateUser(id, req),deleteUser(id). Each returns the typed POJO (or void for delete) and asserts the happy-path status. - Refactor three existing tests to use the helper. Run them; confirm green. Read the test methods — note that they're now mostly assertions and almost no plumbing.
- Test data factory. Create
TestDataFactory.randomUser()returning aCreateUserRequestwith a unique email. Use it in three tests. Confirm parallel runs don't collide. - Variants. Add
randomAdmin()anduserWithName(String)using thetoBuilder()pattern. Use one in a test that needs an admin user. - Auth helper. Create
AuthApiHelper.login(email, password)returning a token. Use it in a test that creates a user and immediately logs in as them. - Layer check. Pick a test you wrote three lessons ago — count the lines. Now use the helpers. Diff the line count. The new version should be 50–70% shorter and far more readable.
- Negative test. Write a test that asserts a failure from the API (e.g., creating a user with a duplicate email returns 409). Notice that this test doesn't call the helper for the failing step — it inlines the API call. This is the pattern.
- Stretch: add Faker (
com.github.javafaker:javafaker:1.0.2) to the pom. Replace the"Test User " + counterstrings withfaker.name().fullName(). Run the suite and read the logs. Note how much more legible the data is.
That's the framework chapter complete. Chapter 7 turns to scale: data-driven tests via TestNG's @DataProvider and CSV files, parallel execution, environment-aware runs, and integrating the suite into a CI/CD pipeline.