Pavan Rangani

HomeBlogSpring Boot Testcontainers Cloud: Scalable Integration Testing in CI/CD

Spring Boot Testcontainers Cloud: Scalable Integration Testing in CI/CD

By Pavan Rangani · March 22, 2026 · Java & Spring

Spring Boot Testcontainers Cloud: Scalable Integration Testing in CI/CD

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.

Testcontainers Cloud integration testing architecture
Testcontainers Cloud offloads container execution to managed infrastructure

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.

CI/CD pipeline integration testing with containers
Parallel integration tests running against cloud-hosted containers

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.

Code review and testing best practices
Choosing the right testing strategy for each layer of your application

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 @ServiceConnection to 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.

← Back to all articles