Spring Boot, Testcontainers, and Flyway - A Real-World Integration Testing Approach.

Published on June 14, 2025

Introduction

In this article, we share our recent experience evolving our integration testing strategy for a Spring Boot application. We’ll explain how blockers become solutions, and how we achieved our goals = Test isolation, reliability, and speed, while keeping our tests as close to production as possible. “Maybe to close? ” so they said.

We leveraged Testcontainers for transient databases, Flyway for schema management, and fine-tuned our configuration.

🚨 Spoiler alert 🚨 We switched our final approach to overcome a Gitlab pipeline security blocker!

The Starting Point

Our integration tests were designed to verify the following business logic:

  • Filtering provider agreements by consumer organization.
  • Ensuring all entity relationships (Masters, Parties, Agreements), and LookupValues were correctly established.
  • Authenticating and authorizing requests using Auth0 tokens.

Sample test scenario:

@Test
void testAgreementsAreFilteredByConsumerOrgId1() throws Exception {
String accessToken = auth0Helper.getTokensFromFile(REFRESH_TOKEN_PD_FILE, "OKTA_REFRESH_TOKEN_PD").get("access_token");
mockMvc.perform(get("/api/v1/provider/" + providerMaster.getId() + "/agreements")
.header("Authorization", "Bearer " + accessToken)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].agreementTitle").value("i svlAgreement1"));
}

We will explain in detail the above, lets keep on reading 👩🏻‍💻

⚠️ The dataBase Dillema️ ⚠️️️

When we initially implemented this, our tests were running against or local database, when moving to the pipeline, we hit a Wall 🚧 The pipeline did not have access to a db.

One of the options was to deploy a dummy db to run in parallel with our tests in the pipeline, but it was a bit complex, and required work and time from DevOps end. During our research, we got a suggestion about using and integrating Testcontainers to spin up a fresh PostgreSQL database for our tests to run 🚀

Key configuration

Dependencies



org.testcontainers
testcontainers-bom
1.19.7
pom
import



org.testcontainers
testcontainers
test



org.testcontainers
junit-jupiter
test



org.testcontainers
postgresql
test



org.springframework.boot
spring-boot-testcontainers
test

The Spring Boot integration module for Testcontainers provides automatic support for managing Testcontainers lifecycle and configuration in Spring Boot tests, so you don’t need to write extra setup code for starting/stopping containers or wiring them into your Spring context.

You still need the core Testcontainers dependencies (like testcontainers, postgresql, etc.), but this module makes using them with Spring Boot much easier.

application.properties

spring.datasource.url=jdbc:tc:postgresql:16:///testdb
spring.datasource.username=test
spring.datasource.password=test

Managing a separate application.properties file is a MUST. Specially in these type of set up where you need to manage different properties at run time.

After this you just need to pass the @Testcontainers annotation in your test class, and that’s basically it!

Schema Management with Flyway

We needed to ensure the test database schema matched our entities, but running all production migrations was slow and loaded unnecessary data. So, we configured Flyway to run only a minimal set of migrations needed for our tests:

spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration/${FLYWAY_ENV_API}/common

⚡ This kept test runs fast and focused ⚡

As mentioned, all test-specific configuration is isolated in an application.properties file, the idea is to ensure:

  • Test DB is always a Testcontainer.
  • Only required migrations are run.
  • No accidental use of main credentials or data occurs.

Token Management for Authenticated Tests

For Auth0, we used a helper to obtain and rotate tokens per test, storing them after each use. This rotation solved the invalid_grant error when running multiple tests at the same time 💥

package com.testing.article.config;

import io.restassured.RestAssured;
import io.restassured.response.Response;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.File;
import java.io.FileWriter;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;

@Component
public class Auth0Helper {

/*
* Gets tokens using refresh_token grant.
* Stores new refresh_tokens if returned in variables or files.
*/

// Overloaded method to support multiple refresh token files and env vars
public Map getTokensFromFile(String refreshTokenFile, String envVarName) throws IOException {
String refreshToken = loadRefreshToken(refreshTokenFile, envVarName);
if (refreshToken == null || refreshToken.isEmpty()) {
throw new IOException("No refresh token available in " + refreshTokenFile + " or env var " + envVarName);
}
Map body = new HashMap<>();
body.put("grant_type", "refresh_token");
body.put("refresh_token", refreshToken);
body.put("audience", audience);
body.put("scope", scope);
body.put("client_id", clientId);
body.put("client_secret", clientSecret);

String tokenEndpoint = domainUrl.endsWith("/") ? domainUrl + "oauth/token" : domainUrl + "/oauth/token";
Response response = RestAssured.given()
.header("Content-Type", "application/json")
.body(body)
.post(tokenEndpoint);

String rawBody = response.getBody().asString();
//System.out.println("Auth0 Token Raw Response: " + rawBody);

if (!rawBody.trim().startsWith("{")) {
throw new IOException("Non-JSON response from Auth0: " + rawBody);
}

String accessToken = response.jsonPath().getString("access_token");
String newRefreshToken = response.jsonPath().getString("refresh_token");

if (accessToken == null || accessToken.isEmpty()) {
throw new IOException("Failed to obtain access token. Response: " + rawBody);
}

// Store new refresh token if present
if (newRefreshToken != null && !newRefreshToken.isEmpty() && !newRefreshToken.equals(refreshToken)) {
storeRefreshToken(newRefreshToken, refreshTokenFile);
String maskedToken = newRefreshToken.length() > 8
? newRefreshToken.substring(0, 4) + "****" + newRefreshToken.substring(newRefreshToken.length() - 4)
: "****";
System.out.println("NEW_REFRESH_TOKEN:" + maskedToken);

}

Map tokens = new HashMap<>();
tokens.put("access_token", accessToken);
return tokens;
}

// Overloaded store method for custom file
private void storeRefreshToken(String refreshToken, String refreshTokenFile) throws IOException {
File file = new File(System.getProperty("user.dir"), refreshTokenFile);
try (FileWriter writer = new FileWriter(file)) {
writer.write(refreshToken);
}
}

// Overloaded load method for custom file and env var
private String loadRefreshToken(String refreshTokenFile, String envVarName) {
// 1. Check environment variable first (for CI/CD)
String envToken = System.getenv(envVarName);
if (envToken != null && !envToken.trim().isEmpty()) {
return envToken.trim();
}
// Fallback to file (for local/dev)
try {
File file = new File(System.getProperty("user.dir"), refreshTokenFile);
if (file.exists()) {
return new String(Files.readAllBytes(file.toPath())).trim();
}
} catch (IOException e) {
System.err.println("Failed to load refresh token from " + refreshTokenFile + ": " + e.getMessage());
}
return null;
}

}

Migrating Integration Test Authentication

From Real Okta Tokens to Simulated JWTs with @WithMockJwt

Context

As shown above, our integration tests authenticated API requests using real Auth0 access tokens, managed by an Auth0Helper utility. This approach required:

  • Storing and rotating refresh tokens as CI/CD variables.
  • Reading and updating these tokens at runtime in the GitLab pipeline.
  • Handling token rotation logic.

🛑 🚧 The Blocker 🚧 🛑

Because of security and audit reasons, it was not recommended to update variables at run time in Gitlab. It was possible, but not recommended …This made it impossible to reliably rotate and persist refresh tokens during test runs, leading to manual intervention.

And so, we migrated to a fully simulated authentication approach for integration tests to run and execute the request. This basically meant:

  • No real Auth0 Refresh token access grant.
  • No logic to extract and use Access tokens on request.
  • No external authentication dependencies.
  • All authentication is handled in-memory using Spring Security’s test.

How It Works 🤔

Custom Annotation

We created a custom annotation to specify the orgId and roles for the simulated JWT:

package com.testing.article.config;

import org.springframework.security.test.context.support.WithSecurityContext;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockJwtSecurityContextFactory.class)
public @interface WithMockJwt {
String orgId() default "org_0123456789";
String[] roles() default {"Consumer User"};
}

Security Context Factory

This factory injects a mock Jwt principal into the Spring Security context for each test:

package com.testing.article.config;

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.test.context.support.WithSecurityContextFactory;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.*;

public class WithMockJwtSecurityContextFactory implements WithSecurityContextFactory {
@Override
public SecurityContext createSecurityContext(WithMockJwt annotation) {
Map claims = new HashMap<>();
claims.put("org_id", annotation.orgId());
claims.put("sub", "test-user");
claims.put("https://api.drm.peerdata.tech/roles", Arrays.asList(annotation.roles()));

Jwt jwt = new Jwt("token", null, null, Map.of("alg", "none"), claims);

List authorities = new ArrayList<>();
for (String role : annotation.roles()) {
authorities.add(new SimpleGrantedAuthority(role));
}

JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt, authorities);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
return context;
}
}

Test Security Configuration

This configuration disables real security for tests, ensuring no real authentication is required:

package com.testing.article.config;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@TestConfiguration
public class TestSecurityConfig {
@Bean
public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
return http.build();
}
}

You may want to create and set a non test profile inside your main security config file, WHY?

@Configuration
@Profile("!test")
@EnableMethodSecurity
public class SecurityConfig {
// ...
}

It’s a profile restriction, this means the config is now only loaded when the test profile is NOT active (i.e., in production and development, but NOT during tests).

How to Use in Tests

Just annotate your test methods or classes with @WithMockJwt:

@WithMockJwt(orgId = "org_0123456789", roles = {"Consumer User"})
void testAgreementsAreFilteredByConsumerOrgId1() throws Exception {
mockMvc.perform(get("/api/v1/provider/" + providerMaster.getId() + "/agreements")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].agreementTitle").value("i svlAgreement1"));
}

Now, the test authenticates as the first consumer org using a mock JWT, then calls the provider agreements endpoint, and asserts that only the agreement relevant to consumer 1 is returned.

Benefits of This Approach

Now our tests run fast and reliably, even offline, there is no need to rotate or persist Auth0 Refresh tokens, and yes, we have no pipeline variable updates or security risks.

Our current E2E Flow

Testcontainers is used to provide a clean, isolated PostgreSQL database for every test run. We set up all required data and relationships in the database, then we authenticate as a specific consumer org using a mock JWT, to call the provider agreements API endpoint, and validate that the response contains only the agreements visible to that org.

Blockers == Solutions

When Blockers Become Solutions
Throughout the testing journey, the obstacles we deal with often spark our most innovative solutions. Each blocker forces us to pause, reassess, and dig deeper into our assumptions and processes. In that search for a workaround, we frequently discover a more elegant architecture, a more reliable test harness, or an even more scalable framework, turning what once felt like a huge wall into a stepping-stone for excellence.

Special thanks to Testcontainers