The previous lesson got you a token and showed how to share it across a class. That works until the token expires — and then a long-running test suite, a slow integration job, or a flaky CI runner starts seeing 401s halfway through. Production code solves this with a token manager that refreshes transparently. Test code should solve it the same way. This lesson is the small TokenManager class that handles login, expiry, refresh, and fallback — plus the explicit tests you should write for the refresh flow itself.
What "token management" actually means
Three responsibilities, none complicated, but each easy to get wrong if you bolt them onto individual tests:
- Login — call
/auth/login(or/oauth/token) once, capture the access token + refresh token + expiry. - Reuse — every subsequent request asks for the current token; the manager hands back the cached one if it's still valid.
- Refresh — when the token is expired (or about to be), use the refresh token to get a new pair without making the user log in again.
Done well, the rest of the test code never thinks about any of this. String token = TokenManager.getToken(); is the only line that ever appears in a test.
A complete TokenManager
package com.mycompany.apitests.auth;
import io.restassured.response.Response;
import java.util.Map;
import static io.restassured.RestAssured.given;
import io.restassured.http.ContentType;
public class TokenManager {
private static String accessToken;
private static String refreshToken;
private static long tokenExpiresAt; // epoch millis
public static synchronized String getToken() {
if (accessToken == null || isExpired()) {
refreshOrLogin();
}
return accessToken;
}
private static boolean isExpired() {
// Refresh slightly early to avoid edge-of-expiry races
return System.currentTimeMillis() > (tokenExpiresAt - 30_000);
}
private static void refreshOrLogin() {
if (refreshToken != null) {
Response res = given()
.contentType(ContentType.JSON)
.body(Map.of("refresh_token", refreshToken))
.when()
.post(System.getenv("AUTH_URL") + "/auth/refresh");
if (res.statusCode() == 200) {
store(res);
return;
}
// Refresh failed (revoked, expired refresh token, etc.) — fall through to a full login
}
login();
}
private static void login() {
Response res = given()
.contentType(ContentType.JSON)
.body(Map.of(
"email", System.getenv("TEST_USER_EMAIL"),
"password", System.getenv("TEST_USER_PASSWORD")
))
.when()
.post(System.getenv("AUTH_URL") + "/auth/login");
if (res.statusCode() != 200) {
throw new IllegalStateException(
"Auth login failed with status " + res.statusCode() + ": " + res.asString());
}
store(res);
}
private static void store(Response res) {
accessToken = res.jsonPath().getString("access_token");
refreshToken = res.jsonPath().getString("refresh_token");
long expiresIn = res.jsonPath().getLong("expires_in"); // seconds
tokenExpiresAt = System.currentTimeMillis() + (expiresIn * 1000);
}
/** For tests that need to force a refresh (rare). */
public static synchronized void invalidate() {
accessToken = null;
tokenExpiresAt = 0;
}
}A handful of small decisions, each deliberate:
- Static + synchronized. A test class running in parallel can have multiple threads asking for a token at once.
synchronizedprevents two simultaneous logins. - 30-second early refresh. Refreshing exactly at expiry races the server clock. A small buffer turns "occasional 401" into "never."
- Refresh-then-fallback-to-login. If the refresh token is also dead, fall back to a full login rather than failing the suite.
invalidate()for explicit tests. When a test deliberately wants to force a re-login (e.g., after a password rotation test), it can invalidate the cache without touching the internals.
Using the manager in tests
@BeforeMethod
public void useFreshToken() {
RestAssured.requestSpecification = new RequestSpecBuilder()
.addHeader("Authorization", "Bearer " + TokenManager.getToken())
.build();
}
@Test
public void getCurrentUser() {
given()
.when()
.get("/users/me")
.then()
.statusCode(200);
}The token is fetched fresh per method (cheap — the manager just returns the cached token), so a long-running suite can't hold a stale token across an expiry boundary. The cached token is reused the second through Nth call within the validity window.
The token lifecycle
Step 1 of 6
First call
Test asks TokenManager.getToken(). Cache empty → POST /auth/login with credentials. Capture access_token, refresh_token, expires_in.
The manager turns a multi-step pile of HTTP calls into one method (getToken()). The complexity is real, but it lives in one place — and once it's right, every test gets it for free.
Tests for the refresh flow itself
The refresh logic is itself code worth testing — separate from the regular suite that uses it.
@Test
public void expiredAccessTokenReturns401() {
String expiredToken = System.getenv("TEST_EXPIRED_TOKEN"); // pre-baked
given()
.auth().oauth2(expiredToken)
.when()
.get("/users/me")
.then()
.statusCode(401)
.body("error", anyOf(equalTo("token_expired"), equalTo("invalid_token")));
}
@Test
public void refreshTokenReturnsNewAccessToken() {
Response loginRes = given()
.contentType(ContentType.JSON)
.body(Map.of(
"email", System.getenv("TEST_USER_EMAIL"),
"password", System.getenv("TEST_USER_PASSWORD")
))
.when()
.post("/auth/login");
String refreshToken = loginRes.jsonPath().getString("refresh_token");
given()
.contentType(ContentType.JSON)
.body(Map.of("refresh_token", refreshToken))
.when()
.post("/auth/refresh")
.then()
.statusCode(200)
.body("access_token", notNullValue())
.body("access_token", not(equalTo(loginRes.jsonPath().getString("access_token"))))
.body("expires_in", greaterThan(0));
}
@Test
public void invalidRefreshTokenReturns401() {
given()
.contentType(ContentType.JSON)
.body(Map.of("refresh_token", "definitely-not-a-real-token"))
.when()
.post("/auth/refresh")
.then()
.statusCode(401);
}Three tests, three real refresh-flow scenarios: expired access token returns 401, refresh produces a new (different) access token, and a fake refresh token is rejected. These are the auth team's contract; the rest of the suite can rely on them.
Environment-aware credentials
Real test suites run against multiple environments — local, dev, staging, occasionally prod (for smoke tests). Hardcoding credentials breaks the moment the suite needs to test against a different env. Read everything from environment variables:
public class AuthConfig {
public static String authUrl() { return required("AUTH_URL"); }
public static String email() { return required("TEST_USER_EMAIL"); }
public static String password() { return required("TEST_USER_PASSWORD"); }
private static String required(String key) {
String value = System.getenv(key);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Required env var '" + key + "' is not set. " +
"Add it to your local .env file or CI secret store.");
}
return value;
}
}A short helper class with strict validation. The early failure (with a useful message) is much better than a confusing NullPointerException halfway through a test.
When tokens aren't the bottleneck
A common premature optimisation: building a complex token cache when the auth endpoint is fast and the suite is short. If /auth/login returns in 50 ms and your suite has 30 tests, just call @BeforeClass and accept the one extra request — it's noise. The TokenManager pattern earns its keep when:
- The suite has hundreds of tests.
- The auth endpoint is slow (200 ms+, common for OAuth servers).
- The auth provider rate-limits aggressively.
- Tests run in parallel (and you don't want N parallel logins).
For short suites, @BeforeClass and a static String token is enough. Reach for the full manager when the simpler version starts to hurt.
⚠️ Common mistakes
- No early-refresh window. Refreshing exactly at
expiresAtraces the server's clock — different machines, different NTP drift. Always subtract a buffer (10–60 seconds is typical) so refreshes happen before expiry, not at it. - Sharing a TokenManager across roles. A
TokenManagerkeyed by user makes sense for a single role; tests that need an admin and a viewer end up sharing the wrong token. Either keep one manager per role (AdminTokens,ViewerTokens) or accept user-as-parameter (TokenManager.tokenFor("viewer")). - Reusing tokens across test runs. Persisting a token to disk to "skip the login on the next run" is a bad trade — token leakage from a stale cache is a real production incident vector. Tokens are short-lived for a reason; let them be.
🎯 Practice task
Build the manager and prove the lifecycle behaviour. 30–40 minutes against REQRES or your own staging API.
- Create
src/test/java/com/mycompany/apitests/auth/TokenManager.javawith the structure shown above. WiregetToken(),login(), andstore(). (refreshcan wait — REQRES doesn't expose a refresh endpoint.) - Write a TestNG test class with three test methods, each calling
TokenManager.getToken()then making an authenticated GET. Run the suite with-DshowLogs=true(or just add a print inlogin()) and confirm only one login call appears. - Add an
invalidate()method. Write a test that callsinvalidate()thengetToken()again. Confirm a second login fires. - Test the expiry guard. Manually set
tokenExpiresAt = System.currentTimeMillis() - 1000. CallgetToken()and confirm a fresh login happens. Reset to a future time and confirm the cache hits. - Read from env vars. Move the email/password into
System.getenv("REQRES_EMAIL")andSystem.getenv("REQRES_PASSWORD"). Set them in your shell. Run the suite. - Add a hard validation. Make
login()throw a clear exception when credentials are missing. Force an empty value and confirm the message is useful. - Refresh test against a real API. If you have access to one with
/auth/refresh, write the three refresh tests from this lesson. If not, sketch them as@Ignored stubs in your suite — they're valuable as documentation even before they run. - Stretch: add a
Map<String, String> tokenByRolecache so the manager can hand out admin, user, and viewer tokens from one place. Note how the call sites (TokenManager.getToken("admin")) stay clean while the internals get more capable.
Next lesson: putting roles to work — testing authorisation levels, permission matrices, and the horizontal-privilege bugs that bite every secured API.