Back to Blog
Test AutomationPerformance TestingCI/CD

Testcontainers: Why Your 'Fast' Integration Tests Are Slow

Many teams adopt Testcontainers for reliable, isolated integration tests, only to find their build pipelines grind to a halt. The problem isn't the library; it's the widespread failure to treat container lifecycle management as a critical performance concern, turning a powerful tool into a latency bottleneck.

July 13, 2026
10 min read
RS
Raju Shanigarapu

The belief that Testcontainers automatically translates to fast, reliable integration tests is a dangerous fantasy many teams cling to. They meticulously isolate dependencies, only to find their CI/CD pipelines crawling, entirely missing the critical operational overhead of container orchestration at scale.

The Lie of "Isolated" Performance

Teams get seduced by the local developer experience. A few containers spin up quickly on a powerful machine, running a handful of tests. They push this setup to CI, assuming the same performance. This is where the illusion shatters. Each build agent, often with constrained resources, is asked to pull images, create containers, start them, and tear them down, repeatedly. Multiply this by dozens or hundreds of integration tests, and your pipeline becomes a Docker daemon's worst nightmare.

The core problem isn't isolation itself, but the naive implementation of it. When every TestClass or even every TestMethod spins up its own PostgreSQL, Kafka, or Redis instance from scratch, you're not just creating isolated environments; you're creating an unnecessary, cascading performance debt that accumulates with every test run. Your focus shifts from testing business logic to waiting for infrastructure to initialize.

Stop Reinventing the Wheel (And the Container)

Testcontainers offers powerful mechanisms to manage container lifecycles, yet most teams stick to the simplest @Container annotation and call it a day. This is fine for truly unique, resource-intensive per-test dependencies, but it's a colossal waste for common services like a database or a message queue that can be shared across multiple tests within a test suite.

The library’s true power lies in its ability to manage shared resources intelligently. Instead of declaring a new PostgreSQLContainer for every single test class, create a single instance that lives for the duration of your test suite. JUnit 5’s @Testcontainers extension, combined with a static field, is your ally here. It ensures that the container starts once and is reused across all tests in that class, or even across multiple classes if you design a shared base test class.

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

@Testcontainers
public class SharedPostgreSqlTest {

    // Using the generic Testcontainers image for PostgreSQL 13.3
    @Container
    private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:13.3"))
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass");

    private static Connection connection;

    @BeforeAll
    static void setup() throws SQLException {
        // Testcontainers 1.18.x automatically starts containers declared with @Container.
        // Explicit start() is often redundant but safe for clarity or specific scenarios.
        System.out.println("PostgreSQL container ensuring startup...");
        
        String jdbcUrl = postgres.getJdbcUrl();
        String username = postgres.getUsername();
        String password = postgres.getPassword();
        connection = DriverManager.getConnection(jdbcUrl, username, password);
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name VARCHAR(255))");
        }
        System.out.println("PostgreSQL container ready and schema created.");
    }

    @AfterAll
    static void tearDown() throws SQLException {
        if (connection != null) {
            connection.close();
            System.out.println("PostgreSQL connection closed.");
        }
        // Testcontainers automatically stops containers declared with @Container at the end of the test suite.
        System.out.println("PostgreSQL container stopped (or reused if configured globally).");
    }

    @Test
    void testUserInsertion() throws SQLException {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("INSERT INTO users (name) VALUES ('Raju')");
            // In a real scenario, you'd add assertions here, e.g., SELECT and check count.
            System.out.println("Test 1: User 'Raju' inserted.");
        }
    }

    @Test
    void testUserRetrieval() throws SQLException {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("INSERT INTO users (name) VALUES ('Shanigarapu')");
            // In a real scenario, you'd add assertions here, e.g., SELECT and check count.
            System.out.println("Test 2: Another user 'Shanigarapu' inserted.");
        }
    }
}

This Java code snippet demonstrates a static @Container field, ensuring the PostgreSQL instance is started only once for all tests within SharedPostgreSqlTest. The setup and tearDown methods handle database schema initialization and connection closing, respectively. This drastically cuts down startup overhead compared to per-test container instantiation, which would create a new PostgreSQL for each @Test method if declared non-statically.

Optimizing Container Startup: The Hidden Cost

The most overlooked performance sink isn't the container running; it's the container starting. Every time a Testcontainers-managed container needs to be spun up, Docker has to pull the image (if not local), create the container, allocate resources, and run its entrypoint. For services like PostgreSQL or Kafka, this can take several seconds per instance.

Leverage Testcontainers' container.withReuse(true) or configure testcontainers.reuse.enable=true in ~/.testcontainers.properties. This isn't just a convenience; it's a performance imperative. It allows Testcontainers 1.18.x to stop and reuse containers between test runs, dramatically cutting down on startup time for subsequent executions. However, understand its implications: your tests must be truly isolated at the data level, not just the environment level. You need robust data cleanup strategies (e.g., TRUNCATE tables, clear queues) if you reuse containers across unrelated test suites.

Another critical optimization is image management. Ensure your CI agents have a warm Docker image cache. If your Dockerfile for a custom service builds from a common base image, ensure that base image is frequently pulled and cached. Consider docker load of pre-built images as part of your CI setup if network latency for docker pull is a significant factor. We've seen docker pull operations add minutes to a pipeline when fetching large images over slower connections.

When to Share, When to Isolate: The Context Matters

The "share everything" mantra is as dangerous as "isolate everything." The decision to share or isolate a Testcontainers instance depends entirely on the nature of your tests and the dependency itself. For stateless services or those with trivial state that can be easily reset (e.g., Redis cache, a simple Kafka topic), sharing is almost always the right call for performance.

However, for complex databases where schema migrations are involved, or where specific test scenarios require a pristine, pre-loaded dataset that's expensive to reset, strict per-test-class isolation might be necessary. The key is to be intentional. Do not default to isolation out of laziness or fear. Measure the performance impact. A single PostgreSQLContainer shared across 50 test classes, with a TRUNCATE statement run in a @BeforeEach hook, will outperform 50 separate PostgreSQLContainer instances by orders of magnitude.

For example, when integrating with a payment gateway service, we used WireMock 2.35.0 alongside a shared Testcontainers Kafka instance. The Kafka instance handled message queues for our internal services, while WireMock simulated the external payment provider's API. This allowed us to test the full asynchronous flow without incurring the overhead of a real external dependency or spinning up a new Kafka for every single scenario.

Where This Breaks Down

While powerful, Testcontainers is not a panacea, and its misuse can still lead to significant problems. Its reliance on Docker means that if your CI/CD environment struggles with Docker resource management – memory, CPU, disk I/O contention – Testcontainers will inherit and amplify those issues. Running multiple heavy Testcontainers instances concurrently on a single under-provisioned GitHub Actions runner, for example, can lead to mysterious timeouts and flaky tests, not because of Testcontainers itself, but due to resource exhaustion at the Docker daemon level.

Furthermore, Testcontainers doesn't magically make poorly designed microservices fast. If your application takes 30 seconds to start up within a Docker container, Testcontainers will faithfully wait those 30 seconds. It exposes your application's fundamental startup latency, rather than fixing it. It also won't solve problems with bloated test data or inefficient database queries within your application code. It provides a clean environment; it's up to your application and tests to perform well within it.

Real-World Example: Cutting 18 Minutes from Our Pipeline

At Mendix, we had a critical service with over 150 integration tests, each spinning up its own PostgreSQLContainer and KafkaContainer. Our GitHub Actions pipeline for this service took an agonizing 30-35 minutes, with the majority of that time spent in container startup and teardown. This bottleneck was killing our deployment cadence and developer feedback loop.

Our solution was multi-pronged. First, we refactored our test suite to use static, shared PostgreSQLContainer and KafkaContainer instances (using Testcontainers 1.18.x), managed by a base test class. Each test class then injected these shared instances. Second, we implemented a robust data cleanup strategy using Flyway to reset database schemas and Kafka Admin Client to clear topics before each test method where necessary. Third, we enabled Testcontainers' reuse feature (testcontainers.reuse.enable=true) for developer machines and our specific CI runners that supported persistent Docker volumes.

This refactoring, while non-trivial, reduced our overall test suite execution time from 35 minutes to just under 17 minutes – a massive 18-minute improvement, cutting the total pipeline time by more than 50%. The perceived "flakiness" also dropped, as resource contention on the CI agents was significantly reduced. We further integrated WireMock 2.35.0 for external HTTP dependencies, ensuring that even those were fast and deterministic.

// Example of a base test class for shared containers
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;

@SpringBootTest
@Testcontainers // Testcontainers JUnit 5 extension
public abstract class AbstractIntegrationTest {

    // Share PostgreSQL container across all tests extending this class
    @Container
    protected static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:13.3"))
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass")
            .withReuse(true); // Enable reuse for faster subsequent runs

    // Share Kafka container across all tests extending this class
    @Container
    protected static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"))
            .withReuse(true); // Enable reuse

    // This will dynamically set Spring Boot properties to connect to Testcontainers instances
    @DynamicPropertySource
    static void setApplicationProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
        // If WireMock is also used and managed within this setup:
        // registry.add("external-service.base-url", () -> wireMockServer.baseUrl());
    }

    // This method ensures data cleanup before each test method
    @BeforeEach
    void setupBase(DataSource dataSource) { // Inject DataSource provided by Spring Boot
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        // Example: Truncate tables to ensure a clean state for each test
        // This assumes tables are known or can be dynamically queried.
        jdbcTemplate.execute("TRUNCATE TABLE users RESTART IDENTITY CASCADE;");
        System.out.println("PostgreSQL tables truncated before test.");

        // For Kafka, you might use KafkaAdminClient to delete/recreate topics or clear groups
        // e.g., new KafkaAdminClient(...).deleteTopics(List.of("my-topic"));
        System.out.println("Kafka topics cleared (placeholder for actual implementation).");
    }

    @AfterEach
    void tearDownBase() {
        // Optional: additional cleanup or verification after test
        System.out.println("Base test tear down complete.");
    }
}

This Java snippet shows a base class AbstractIntegrationTest that defines static @Container fields for PostgreSQL and Kafka, both configured for reuse. @DynamicPropertySource dynamically sets application properties to connect to these Testcontainers instances. The setupBase method now includes an example of critical data cleanup logic using JdbcTemplate to TRUNCATE tables, which is crucial when reusing containers across tests.

Actionable Thing This Week: Identify your slowest integration test suite that uses Testcontainers. Refactor it to use one shared static @Container instance (with withReuse(true)) for its primary database or message queue across all test classes in that suite, then implement a robust data cleanup strategy (TRUNCATE TABLE, DELETE FROM, Kafka topic deletion) in a @BeforeEach method. Measure the before and after execution times.

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.