Base Test Class and Configuration

8 min read

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:

  1. Set the base URI and base path from configuration (so a single env var switches environments).
  2. Configure default request behaviour — content type, accept header, logging-on-failure.
  3. Authenticate once for the suite (using the TokenManager from Chapter 4) and stash the token where every test can reach it.
  4. Tear down cleanly — reset Rest Assured's static state so test classes don't bleed configuration into each other.
  5. 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.
  • @BeforeSuite not @BeforeClass — runs once per test run, not once per test class. Sharing one auth token across the whole suite avoids needless logins.
  • alwaysRun = true on @AfterSuite — even if a @BeforeSuite setup 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 smoke

Same 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.com

Loaded 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. @BeforeSuite once, refresh through the TokenManager lazily.
  • Putting domain helpers on the base class. createTestUser() looks tempting on BaseApiTest. It's not a base concern — it belongs in a UserApiHelper (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 Maven Surefire forks if cleanup is skipped — particularly painful when one suite's filters silently apply to another's. The @AfterSuite reset is cheap insurance.
  • Mixing test setup with framework setup. @BeforeMethod on 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.

  1. Create Config.java with at least three values (baseUri, adminEmail, adminPassword). Use the system-property-or-env-var pattern. Run mvn test -DbaseUri=https://reqres.in and confirm the override takes effect.
  2. Create BaseApiTest.java (abstract) with @BeforeSuite and @AfterSuite. Set the base URI, configure enableLoggingOfRequestAndResponseIfValidationFails, and authenticate via TokenManager.getToken() (or skip auth for now if the API is REQRES).
  3. Pick three existing test classes and make them extends BaseApiTest. Delete the per-class @BeforeClass setup. Run them — they should still pass, but with cleaner code.
  4. 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.
  5. Override one value via -D. Run mvn test -DbaseUri=https://jsonplaceholder.typicode.com and confirm the suite redirects without source changes.
  6. Required-value test. Comment out the env var for adminPassword. Run the suite. Confirm the failure message names the variable. Restore.
  7. Reset proof. Add a RestAssured.filters(new RequestLoggingFilter()) on the base class. Run a test class. Then delete the filter line, re-run. Without RestAssured.reset(), you'd still see logging from a stale filter — confirm it's gone.
  8. 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.

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