The first five chapters built tests one method at a time — each test set its own base URI, authenticated, configured logging, and asserted. That's the right way to learn. It's the wrong way to scale. The first time the API moves from staging.myapp.com to qa-1.myapp.com and you have to update 40 test classes, the case for centralisation makes itself. This lesson is the small framework spine that every Rest Assured suite eventually grows: a BaseApiTest class that handles setup once, a Config class that reads environment-aware values, and a layout that lets test classes do nothing but test.
What "base" actually does
A good BaseApiTest does five things, and only those:
- Set the base URI and base path from configuration (so a single env var switches environments).
- Configure default request behaviour — content type, accept header, logging-on-failure.
- Authenticate once for the suite (using the
TokenManagerfrom Chapter 4) and stash the token where every test can reach it. - Tear down cleanly — reset Rest Assured's static state so test classes don't bleed configuration into each other.
- Stay out of the way — no test logic, no assertions, no business rules. Just plumbing.
That last point matters. The moment a base class starts asserting on responses or chaining preconditions, every concrete test inherits coupling it didn't ask for.
The base class itself
package com.mycompany.apitests;
import com.mycompany.apitests.auth.TokenManager;
import com.mycompany.apitests.config.Config;
import io.restassured.RestAssured;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.filter.log.RequestLoggingFilter;
import io.restassured.filter.log.ResponseLoggingFilter;
import io.restassured.http.ContentType;
import org.testng.annotations.AfterSuite;
import org.testng.annotations.BeforeSuite;
public abstract class BaseApiTest {
protected static String authToken;
@BeforeSuite(alwaysRun = true)
public void globalSetup() {
RestAssured.baseURI = Config.baseUri();
RestAssured.basePath = Config.basePath();
RestAssured.requestSpecification = new RequestSpecBuilder()
.setContentType(ContentType.JSON)
.setAccept(ContentType.JSON)
.build();
// Show full request + response only when an assertion fails — clean output normally.
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
authToken = TokenManager.getToken();
}
@AfterSuite(alwaysRun = true)
public void globalTeardown() {
RestAssured.reset();
}
}A handful of decisions worth flagging:
abstract— the class is meant to be extended, not instantiated directly.@BeforeSuitenot@BeforeClass— runs once per test run, not once per test class. Sharing one auth token across the whole suite avoids needless logins.alwaysRun = trueon@AfterSuite— even if a@BeforeSuitesetup fails, teardown still attempts to clean state, so the next run starts fresh.enableLoggingOfRequestAndResponseIfValidationFails— silent on green tests, full request/response dump on red ones. The default that keeps CI logs readable until something breaks.RestAssured.reset()— dumps every static field (baseURI,requestSpecification, filters) so a subsequent run isn't contaminated by this one.
A test class extending the base
package com.mycompany.apitests;
import org.testng.annotations.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
public class UserApiTest extends BaseApiTest {
@Test
public void getCurrentUserReturns200() {
given()
.auth().oauth2(authToken)
.when()
.get("/users/me")
.then()
.statusCode(200)
.body("email", containsString("@"));
}
}Notice what's missing: no @BeforeClass, no RestAssured.baseURI = ..., no auth call. Every test class that extends BaseApiTest inherits the lot. New test classes start with a single @Test method and grow from there.
The Config class — environment-aware values
Hardcoding https://api.staging.myapp.com works for one environment. Real suites run against several — local, staging, the team's preview deployments, occasionally prod. A small Config class pulls every value from system properties or environment variables:
package com.mycompany.apitests.config;
public final class Config {
private Config() {}
public static String baseUri() {
return get("baseUri", "API_BASE_URI", "https://api.staging.myapp.com");
}
public static String basePath() {
return get("basePath", "API_BASE_PATH", "/api/v1");
}
public static String adminEmail() {
return get("adminEmail", "ADMIN_EMAIL", "admin@test.com");
}
public static String adminPassword() {
// No default for secrets — fail loudly if missing.
return required("adminPassword", "ADMIN_PASSWORD");
}
private static String get(String sysProp, String envVar, String defaultValue) {
String value = System.getProperty(sysProp);
if (value != null && !value.isBlank()) return value;
value = System.getenv(envVar);
if (value != null && !value.isBlank()) return value;
return defaultValue;
}
private static String required(String sysProp, String envVar) {
String value = System.getProperty(sysProp);
if (value != null && !value.isBlank()) return value;
value = System.getenv(envVar);
if (value != null && !value.isBlank()) return value;
throw new IllegalStateException(
"Missing required value: -D" + sysProp + " or env var " + envVar);
}
}Two decisions that pay back constantly:
- System property or environment variable. Local devs prefer
mvn test -DbaseUri=...(one-shot). CI prefers env vars (set in the secret store). Supporting both means no friction for either. required()for secrets. A password that silently falls back to a default would be a security incident waiting to happen. Throwing an exception with the env var name is the friendlier failure.
Running against different environments
# Staging (default)
mvn test
# Local dev
mvn test -DbaseUri=http://localhost:8080 -DbasePath=/api
# CI prod smoke
ADMIN_EMAIL=svc-bot@prod.com \
ADMIN_PASSWORD=$(vault read -field=password secret/api/svc-bot) \
API_BASE_URI=https://api.myapp.com \
mvn test -P smokeSame suite, different inputs, no source changes. Maven profiles handle the -P smoke selection if you want named environments.
Properties files for non-secret defaults
Some teams prefer a config.properties file checked into git for non-secret defaults. Place it at src/test/resources/config.properties:
base.uri=https://api.staging.myapp.com
base.path=/api/v1
admin.email=admin@test.comLoaded once on startup with a Properties object:
private static final Properties props = loadProperties("config.properties");
private static Properties loadProperties(String file) {
Properties p = new Properties();
try (var stream = Config.class.getClassLoader().getResourceAsStream(file)) {
if (stream != null) p.load(stream);
} catch (IOException e) {
throw new RuntimeException("Failed to load " + file, e);
}
return p;
}Then layer the lookup: system property → env var → properties file → hardcoded default. The order matters; the highest-priority source wins. Pick whichever scheme fits your team's habit; what's not negotiable is that values are external, not in source files.
How the framework spine fits together
The arrows go one way: configuration into the base, the base into the tests. Nothing flows back. Each layer's responsibilities are separable, which means each is testable and replaceable on its own.
Common pitfalls in base classes
A few patterns that look helpful but accumulate problems:
- Storing the response of the last call on a static field. "Helpful" — until parallel tests stomp on each other's response. Keep state on stack-local variables; if you need to share, make it explicit.
- Re-authenticating in
@BeforeMethod. Cheap, until your auth server rate-limits.@BeforeSuiteonce, refresh through theTokenManagerlazily. - Putting domain helpers on the base class.
createTestUser()looks tempting onBaseApiTest. It's not a base concern — it belongs in aUserApiHelper(covered in Lesson 4 of this chapter). Keep the base class infrastructural.
The discipline is summarised by a single rule: if it isn't needed by every test class, it doesn't go on the base class.
⚠️ Common mistakes
- Hardcoding the base URI on the base class. A single literal kills the ability to retarget environments. Always read from
Config. The first time you have to point the suite at a feature-branch deploy, you'll be glad. - Forgetting
RestAssured.reset(). Static state survives between MavenSurefireforks if cleanup is skipped — particularly painful when one suite's filters silently apply to another's. The@AfterSuitereset is cheap insurance. - Mixing test setup with framework setup.
@BeforeMethodon the base class that creates a fresh test user via the API "for convenience" makes every test 30% slower and silently coupled to that user existing. Keep base setup truly global; test-specific setup belongs on the test class itself.
🎯 Practice task
Build the framework spine and migrate three existing tests onto it. 30–40 minutes.
- Create
Config.javawith at least three values (baseUri,adminEmail,adminPassword). Use the system-property-or-env-var pattern. Runmvn test -DbaseUri=https://reqres.inand confirm the override takes effect. - Create
BaseApiTest.java(abstract) with@BeforeSuiteand@AfterSuite. Set the base URI, configureenableLoggingOfRequestAndResponseIfValidationFails, and authenticate viaTokenManager.getToken()(or skip auth for now if the API is REQRES). - Pick three existing test classes and make them
extends BaseApiTest. Delete the per-class@BeforeClasssetup. Run them — they should still pass, but with cleaner code. - Test the failure logging. Force one assertion to fail (
equalTo("WRONG")). Note that the run output now includes the full request and response — without your tests calling.log().all()anywhere. - Override one value via -D. Run
mvn test -DbaseUri=https://jsonplaceholder.typicode.comand confirm the suite redirects without source changes. - Required-value test. Comment out the env var for
adminPassword. Run the suite. Confirm the failure message names the variable. Restore. - Reset proof. Add a
RestAssured.filters(new RequestLoggingFilter())on the base class. Run a test class. Then delete the filter line, re-run. WithoutRestAssured.reset(), you'd still see logging from a stale filter — confirm it's gone. - Stretch: add a
@BeforeSuite(dependsOnMethods = "globalSetup")method that prints the current environment (base URI, who you authenticated as) to stdout. Run the suite. Note that every CI log now self-documents which env it ran against.
Next lesson: lifting common request and response shapes into reusable specs, so per-test boilerplate shrinks even further.