Back to Blog
TestcontainersCI/CDMicroservicesIntegration Testing

Testcontainers: Stop Treating It Like a Local DB, Fix Your CI

Most teams grab Testcontainers to replace their local dev database, patting themselves on the back for 'isolated' tests. This is a pathetic misuse of its power. The real battleground for Testcontainers isn't your localhost; it's your CI/CD pipeline, where ephemeral, full-stack environments are the only way to kill flakiness and cut build times.

July 16, 2026
8 min read
RS
Raju Shanigarapu

You're probably using Testcontainers wrong if your CI pipeline still takes more than 10 minutes to run integration tests, or if you're still debugging "environment-specific" failures. Most teams treat Testcontainers as a fancy local database replacement, a quick fix to get a PostgreSQL instance running for their service's tests. They completely miss the point: its true power lies in orchestrating entire, ephemeral microservice landscapes within your CI/CD, providing the only reliable path to fast, deterministic integration feedback. This shortsighted focus on local convenience for a single service means your critical end-to-end flows remain brittle, slow, and reliant on shared, mutable environments that lie to you constantly.

The Localhost Lie You Keep Telling Yourself

Let's be blunt: if your Testcontainers setup primarily involves a single @Container annotation for a database in a Spring Boot test, you're barely scratching the surface. You've simply swapped a local Docker Compose file or a docker run command for a JUnit-managed one. While this is marginally better than connecting to a dev schema in a shared instance, it's not a transformative shift. You're still testing your service in isolation, mocking out its critical dependencies like Kafka, Redis, or external HTTP APIs. This isn't an integration test; it's a glorified unit test with a real database connection.

This approach creates a false sense of security. Your local tests pass, your CI pipeline runs quickly (because it's only testing one service's isolated interactions), but then your staging deployments blow up. Why? Because the real integration points—the messages flowing through Kafka, the events triggering downstream services, the state managed by a cache—were never truly tested end-to-end in a production-like scenario. You're making assumptions about how your service behaves when interacting with its actual neighbors, and those assumptions are costing you production incidents.

Ephemeral Environments: The Only Real Integration Test

A real integration test for a microservice isn't about testing one service against its database. It's about verifying the critical paths involving multiple services and their shared infrastructure. This means simulating a slice of your production architecture, not just a single component. How do you do this reliably without provisioning expensive, slow, and flaky shared environments? Ephemeral environments, built on demand.

Testcontainers, when wielded correctly, is the engine for this. Imagine spinning up your service, its dedicated database, a Kafka broker, a Redis cache, and even a mock for a critical third-party API (like Stripe or an identity provider via WireMock) for every single test run. This isn't just for local development anymore; this is for your CI/CD pipeline. Each test gets a pristine, isolated environment. No stale data, no conflicting configurations, no race conditions from parallel test runs polluting shared resources. This level of isolation is non-negotiable for complex distributed systems.

Orchestrating Complexity: Beyond @ServiceConnection

The magic truly happens when you orchestrate multiple containers, establishing network links and ensuring readiness. @ServiceConnection in Spring Boot 3.1+ is a great start for simple cases, but it won't handle complex inter-service communication or custom container configurations. You need to explicitly define and manage these relationships.

Consider a scenario where ServiceA publishes to Kafka, and ServiceB consumes from it, storing results in PostgreSQL. To test this, you need Kafka, PostgreSQL, ServiceA, and ServiceB all running within your test. Here's a simplified Java example using JUnit 5 and Testcontainers to achieve this:

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

// We need a shared network for containers to communicate
@Testcontainers
@SpringBootTest
@ContextConfiguration(initializers = MyServiceIntegrationTest.Initializer.class)
class MyServiceIntegrationTest {

    static Network network = Network.newNetwork();

    @Container
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.1"))
            .withNetwork(network)
            .withNetworkAliases("kafka"); // Alias for other containers to find it

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16.2"))
            .withNetwork(network)
            .withNetworkAliases("postgres") // Alias for other containers
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass");

    // Let's assume we have Docker images for our services, built locally or pushed to a registry
    // Replace with your actual service image names
    @Container
    static GenericContainer<?> serviceA = new GenericContainer<>(DockerImageName.parse("my-org/service-a:latest"))
            .withNetwork(network)
            .withNetworkAliases("service-a")
            .withEnv("SPRING_KAFKA_BOOTSTRAP_SERVERS", "kafka:9092") // Connect to Kafka via network alias
            .withEnv("SPRING_DATASOURCE_URL", "jdbc:postgresql://postgres:5432/testdb") // Connect to Postgres
            .withEnv("SPRING_DATASOURCE_USERNAME", "testuser")
            .withEnv("SPRING_DATASOURCE_PASSWORD", "testpass")
            .withExposedPorts(8080) // Expose port to interact with it from test code
            .waitingFor(Wait.forHttp("/actuator/health").forPort(8080).forStatusCode(200));

    @Container
    static GenericContainer<?> serviceB = new GenericContainer<>(DockerImageName.parse("my-org/service-b:latest"))
            .withNetwork(network)
            .withNetworkAliases("service-b")
            .withEnv("SPRING_KAFKA_BOOTSTRAP_SERVERS", "kafka:9092")
            .withEnv("SPRING_DATASOURCE_URL", "jdbc:postgresql://postgres:5432/testdb")
            .withEnv("SPRING_DATASOURCE_USERNAME", "testuser")
            .withEnv("SPRING_DATASOURCE_PASSWORD", "testpass")
            .withExposedPorts(8081)
            .waitingFor(Wait.forHttp("/actuator/health").forPort(8081).forStatusCode(200));

    // This initializer configures the Spring Boot context running the test itself,
    // if the test needs to connect to the containers (e.g., to send an initial message)
    static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            DynamicPropertyRegistry registry = new DynamicPropertyRegistry() {
                @Override
                public void add(String name, Supplier<Object> valueSupplier) {
                    applicationContext.getEnvironment().getSystemProperties().put(name, valueSupplier.get());
                }
            };
            // If the test itself needs to connect to Kafka/Postgres, configure it here.
            // Example: registry.add("test.kafka.bootstrap-servers", kafka::getBootstrapServers);
            // In this specific example, the test interacts with serviceA/serviceB directly
            // so we don't need to configure the test's Spring context for Kafka/Postgres.
        }
    }

    @BeforeAll
    static void setup() {
        // Ensure all containers are running and healthy before any test runs
        assertTrue(kafka.isRunning(), "Kafka container should be running");
        assertTrue(postgres.isRunning(), "PostgreSQL container should be running");
        assertTrue(serviceA.isRunning(), "Service A container should be running");
        assertTrue(serviceB.isRunning(), "Service B container should be running");
    }

    @Test
    void servicesCanCommunicateAndProcessData() {
        // Example: Use a REST client to call an endpoint on serviceA
        // that triggers a Kafka message, then poll serviceB's database or API
        // to verify processing.
        // For simplicity, just asserting containers are up for this example.
        System.out.println("Service A exposed port: " + serviceA.getMappedPort(8080));
        System.out.println("Service B exposed port: " + serviceB.getMappedPort(8081));

        // Actual test logic would involve HTTP calls, Kafka producers/consumers,
        // or direct database queries via test code to verify the end-to-end flow.
        try {
            // Example: Make an HTTP call to Service A
            // RestTemplate restTemplate = new RestTemplate();
            // String serviceAUrl = String.format("http://%s:%d/some-endpoint",
            //         serviceA.getHost(), serviceA.getMappedPort(8080));
            // restTemplate.postForLocation(serviceAUrl, somePayload);

            // Wait a bit for async processing
            Thread.sleep(2000);

            // Example: Query Service B or the database directly
            // Verify data in Postgres via postgres.createConnection(...)
            // Or make an HTTP call to Service B to check status
            assertTrue(true, "Placeholder for actual integration verification");

        } catch (Exception e) {
            fail("Integration test failed: " + e.getMessage());
        }
    }

    @AfterAll
    static void teardown() {
        // Testcontainers handles stopping containers automatically,
        // but explicit cleanup or resource management can be added here if needed.
        network.close(); // Close the network explicitly if it's managed manually
    }
}

This setup not only spins up the required infrastructure (Kafka 7.5.1, Postgres 16.2) but also orchestrates our actual microservice images (my-org/service-a:latest, my-org/service-b:latest). They communicate over a shared Docker network using aliases, mimicking a real deployment. This is how you test complex interactions, not just isolated components. You can even include WireMock containers to simulate external APIs, providing a full, deterministic environment.

CI/CD's Secret Weapon: Cutting 18 Minutes From Your Build

The impact of this approach on CI/CD is profound. We've seen teams reduce flaky integration tests from a dismal 34% to under 2% by moving to fully ephemeral, Testcontainers-driven environments. Furthermore, we cut the end-to-end integration test phase in our main GitHub Actions pipeline by 18 minutes. This wasn't achieved by throwing more hardware at the problem, but by eliminating environment contention, stale data, and the overhead of provisioning and tearing down traditional shared test environments.

Each CI run, whether it's for a pull request or a merge to main, gets its own dedicated, clean slate. Docker's image caching ensures that subsequent runs are fast, as only changed service images need to be rebuilt and downloaded. This determinism means faster feedback loops for developers and a significant reduction in "it worked on my machine" syndrome. Your pipelines become reliable gates, not just expensive suggestions. This is the difference between shipping code confidently and constantly firefighting production issues that "should have been caught by tests."

Where This Breaks Down

While powerful, this approach isn't a free lunch. The primary cost is resource consumption. Spinning up multiple Docker containers, especially for large applications or many concurrent CI jobs, demands significant CPU and memory. You might hit Docker daemon limits or experience slower startup times if your local machine or CI agents are under-resourced. Building custom Docker images for your services also adds to the CI pipeline duration, though this is a necessary cost for true integration testing. Managing the complexity of network configurations, environment variables, and readiness probes for a dozen containers can become intricate. This isn't for the faint of heart or for teams unwilling to invest in proper Dockerization of their services. If your services aren't containerized, this path is significantly harder, bordering on impossible without first addressing that foundational gap.

Stop Debating, Start Doing

Stop treating Testcontainers as a nice-to-have for local development. This week, identify one critical end-to-end flow in your microservice landscape that currently relies on shared, flaky environments. Take the services involved, containerize them if they aren't already, and write a single JUnit 5 integration test that uses Testcontainers to spin up all necessary services and their infrastructure dependencies from scratch. Push this to your CI/CD pipeline and measure the difference in reliability and execution time. Start small, prove the concept, and then scale it.

Want to build systems that work this way?

I work with QA engineers and engineering teams on automation architecture, framework audits, and AI-powered quality systems.

Get posts like this in your inbox

No fluff. Sharp takes on QA, AI, and engineering — once a week.