Spring Boot Testcontainers Cloud for Integration Testing
Testcontainers Cloud integration testing has become the gold standard for verifying Spring Boot applications against real databases, message brokers, and external services. While local Testcontainers work great on developer machines, running them in CI/CD pipelines presents significant challenges — Docker-in-Docker complexity, resource constraints, and slow startup times. Testcontainers Cloud solves these problems by offloading container execution to managed cloud infrastructure.
This guide covers everything from setting up the agent with Spring Boot 3.x to optimizing parallel test execution in GitHub Actions and GitLab CI. Moreover, you will learn patterns for managing test data, handling service dependencies, and reducing overall pipeline execution time substantially. Throughout, the goal is testing against the real engines you ship to production rather than against in-memory substitutes that hide bugs.
Why Traditional Testcontainers Struggle in CI
Local Testcontainers rely on a Docker daemon running alongside your tests. In CI environments, this typically means Docker-in-Docker (DinD) which adds security risks and performance overhead. Additionally, CI runners often have limited CPU and memory, making it difficult to spin up multiple containers simultaneously.
Furthermore, each CI job starts fresh — pulling container images from registries every time. A typical Spring Boot test suite that needs PostgreSQL, Redis, and Kafka containers can spend two to three minutes just on container startup before any tests run. DinD also commonly requires privileged mode, which many security-conscious platforms forbid outright, so teams end up maintaining brittle workarounds instead of testing their code.
Setting Up Testcontainers Cloud
Getting started requires a Testcontainers Cloud account and a lightweight agent that redirects Docker API calls to the cloud. The agent runs as a background process on your CI runner and transparently intercepts container lifecycle operations.
<!-- pom.xml dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<scope>test</scope>
</dependency>
Spring Boot Service Connections
Spring Boot 3.1+ introduced @ServiceConnection which automatically configures connection properties from Testcontainers. This eliminates manual property mapping and reduces boilerplate code significantly. Before this annotation existed, teams wrote a @DynamicPropertySource method for every container to wire the JDBC URL, broker address, and credentials by hand; @ServiceConnection infers all of that from the container type.
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders_test")
.withInitScript("schema.sql");
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Container
@ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Autowired
private OrderService orderService;
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Test
void shouldCreateOrderAndPublishEvent() {
// Arrange
var request = new CreateOrderRequest("SKU-001", 5, "customer-123");
// Act
var order = orderService.createOrder(request);
// Assert
assertThat(order.getId()).isNotNull();
assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
// Verify Kafka event was published
var records = KafkaTestUtils.getRecords(consumer, Duration.ofSeconds(10));
assertThat(records).hasSize(1);
assertThat(records.iterator().next().value().getOrderId())
.isEqualTo(order.getId());
}
@Test
void shouldHandleConcurrentOrdersWithOptimisticLocking() {
var order = orderService.createOrder(
new CreateOrderRequest("SKU-002", 1, "customer-456")
);
// Simulate concurrent updates
CompletableFuture<Void> update1 = CompletableFuture.runAsync(() ->
orderService.updateQuantity(order.getId(), 10));
CompletableFuture<Void> update2 = CompletableFuture.runAsync(() ->
orderService.updateQuantity(order.getId(), 20));
// One should succeed, one should throw OptimisticLockException
assertThatThrownBy(() ->
CompletableFuture.allOf(update1, update2).join()
).hasCauseInstanceOf(OptimisticLockException.class);
}
}
Speeding Up the Local Loop with Container Reuse
On developer machines, the biggest cost is restarting containers for every run. Testcontainers supports a reuse flag that keeps a matching container alive between executions, so the second and subsequent runs skip startup entirely. Reuse must be opted into per machine because a leftover container can leak state between test runs, so it is deliberately off by default.
# ~/.testcontainers.properties (per developer machine, opt-in)
testcontainers.reuse.enable=true
Combine reuse with an init script and a clean-up step inside each test class. A common pattern is to truncate tables in a @BeforeEach rather than recreating the schema, which preserves the warm container while guaranteeing each test starts from a known state. In CI, however, reuse is usually disabled because runners are ephemeral and Testcontainers Cloud already amortizes startup across the fleet.
CI/CD Pipeline Configuration
Testcontainers Cloud integration with GitHub Actions requires installing the TC Cloud agent before running tests. The agent authenticates with your account and routes all Docker API calls to cloud infrastructure. As a result, your CI runners do not need Docker installed at all.
# .github/workflows/integration-tests.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Install Testcontainers Cloud Agent
run: |
curl -fsSL https://get.testcontainers.cloud/bash | bash
testcontainers-cloud-agent --wait &
env:
TC_CLOUD_TOKEN: ${{ secrets.TC_CLOUD_TOKEN }}
- name: Run integration tests
run: ./mvnw verify -Pintegration-tests -Dtest.parallel.threads=4
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: target/surefire-reports/
One detail teams miss is that the agent exits when the workflow step finishes, so it must run in the background (&) and stay alive for the duration of the test step. Scope the TC_CLOUD_TOKEN as a repository or organization secret, and prefer a dedicated CI token you can revoke independently of developer credentials. The same agent install works almost identically in GitLab CI; only the secret-injection syntax differs.
Reusable Container Configurations
Therefore, creating a shared test infrastructure module prevents container configuration duplication across test classes. This approach also enables consistent test data setup and teardown patterns.
@TestConfiguration(proxyBeanMethods = false)
public class TestInfrastructureConfig {
@Bean
@ServiceConnection
@RestartScope
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("test")
.withReuse(true)
.withLabel("reuse.hash", "integration-postgres");
}
@Bean
@ServiceConnection
@RestartScope
public KafkaContainer kafkaContainer() {
return new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withReuse(true)
.withKraft()
.withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true");
}
@Bean
public WireMockServer paymentServiceMock() {
var server = new WireMockServer(
wireMockConfig().dynamicPort());
server.start();
return server;
}
}
// Base test class that all integration tests extend
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestInfrastructureConfig.class)
@ActiveProfiles("test")
public abstract class BaseIntegrationTest {
@Autowired
protected TestRestTemplate restTemplate;
@Autowired
protected WireMockServer paymentMock;
@BeforeEach
void resetMocks() {
paymentMock.resetAll();
}
}
Notice that the payment service is faked with WireMock rather than a container. This is deliberate: you control third-party HTTP APIs that you do not own through a stub, and reserve real containers for infrastructure you do own and ship. Mixing the two in one base class gives every test a realistic database, broker, and a deterministic external dependency without flaky network calls to a vendor sandbox.
Parallel Test Execution Strategies
Consequently, running tests in parallel dramatically reduces pipeline execution time. Testcontainers Cloud handles the concurrency on the infrastructure side, but your test code needs proper isolation to avoid flaky results.
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4
Additionally, each test class should use unique schema prefixes or tenant IDs to prevent data collisions when running concurrently against shared containers. The safest isolation strategy is one container per test class so parallel classes never share mutable state; the cheaper strategy shares a container but partitions data per class. Choose based on how much your tests mutate global tables — auth and reference data that every test reads are fine to share, whereas order and ledger tables usually are not.
When NOT to Use Testcontainers Cloud
Testcontainers Cloud adds network latency between your tests and the containers since they run remotely. For tests that make thousands of rapid database calls in tight loops, local containers may actually be faster. Moreover, if your organization has strict data residency requirements, sending test data to managed infrastructure may not be acceptable.
Unit tests and simple mock-based tests do not benefit from Testcontainers at all. Reserve container-based testing for integration scenarios where you genuinely need a real database, message broker, or external service. There is also a cost dimension: the managed service is billed per minute of container runtime, so a small project whose CI already has Docker available may see no payback. Pilot it on your slowest, most resource-hungry suites first and compare wall-clock time before rolling it out everywhere.
Key Takeaways
Adopting Testcontainers Cloud integration testing transforms how teams run integration tests in CI/CD pipelines. By offloading container execution to managed infrastructure, you eliminate Docker-in-Docker complexity and reduce pipeline times significantly. Furthermore, Spring Boot’s @ServiceConnection annotation makes configuration nearly zero-effort, while container reuse keeps the local feedback loop fast.
- Test against the real PostgreSQL, Kafka, and Redis you ship, not in-memory substitutes that mask behavior
- Use
@ServiceConnectionto wire connection properties automatically instead of hand-written@DynamicPropertySource - Enable container reuse locally for fast loops; rely on the cloud agent to amortize startup in CI
- Isolate parallel tests with per-class containers or partitioned data to avoid flaky failures
- Weigh latency, data residency, and per-minute cost before migrating every suite to the cloud
Start by migrating your most resource-intensive integration tests and measure the improvement. For further reading, check the Testcontainers Cloud documentation and the Spring Boot Testcontainers reference. You may also find our guides on Spring Boot virtual threads, Java records and sealed classes, and GitHub Actions CI/CD automation helpful for building modern Spring applications.
In conclusion, mastering Spring Boot Testcontainers Cloud is an essential topic for modern software development. By applying the patterns and practices covered in this guide — service connections, container reuse, careful isolation, and honest cost analysis — you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.