Building Utility Classes for Common Operations

8 min read

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:

  • final class 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 admin spec 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 = true on the POJO (set with @Builder(toBuilder = true) on Lombok) makes the randomUser().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

Test method
  • – 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 an expectedStatus parameter, 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 expectedStatusCode parameter. 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 ApiHelper class 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.

  1. Create UserApiHelper.java with 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.
  2. 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.
  3. Test data factory. Create TestDataFactory.randomUser() returning a CreateUserRequest with a unique email. Use it in three tests. Confirm parallel runs don't collide.
  4. Variants. Add randomAdmin() and userWithName(String) using the toBuilder() pattern. Use one in a test that needs an admin user.
  5. 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.
  6. 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.
  7. 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.
  8. Stretch: add Faker (com.github.javafaker:javafaker:1.0.2) to the pom. Replace the "Test User " + counter strings with faker.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.

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