Back to Blog
TestcontainersCI/CDTest AutomationPerformance Testing

Testcontainers: Your 'Local Dev' Illusion Is Crushing CI Speed

Most teams are misusing Testcontainers in CI, mistaking local developer convenience for scalable pipeline efficiency. This naive approach bakes unacceptable latency into critical feedback loops, turning your integration suite into a slow, resource-hungry monster. We need to stop treating CI like a local sandbox.

September 14, 2026
9 min read
RS
Raju Shanigarapu

The widespread adoption of Testcontainers has been a godsend for local development, providing a robust, isolated environment for integration tests. But here’s the cold truth: the way most teams leverage Testcontainers in their CI pipelines is fundamentally broken, leading to bloated execution times and a false sense of security. You’re not getting true isolation at scale; you’re just paying an orchestration tax for every single test run, and your developers are feeling the pinch of slow feedback.

The Illusion of Perpetual Isolation

The appeal is obvious: a fresh, clean database for every test, guaranteed isolation, no more shared dev environments. Testcontainers delivers on this promise beautifully for local development. A developer runs a few tests, a container spins up, runs the test, and tears down. It's fast enough, the overhead is negligible. But scale this across hundreds or thousands of tests, running in parallel across multiple CI agents, and the "throwaway" model becomes a performance nightmare. Each container spin-up, image pull, and network initialization adds seconds, even tens of seconds. Multiply that by hundreds of test classes, and your 10-minute pipeline just became an hour-long ordeal. A naive Testcontainers rollout can triple the runtime of a core service's integration suite.

Your CI Is Not My Local Machine

The critical mistake is treating your CI environment as an extension of your local development setup. CI is a shared, high-throughput, resource-constrained environment, especially when using cloud-based runners like GitHub Actions. Every byte pulled, every CPU cycle consumed, every second wasted on container startup is a cost. The default Testcontainers behavior, spinning up a new container for each @Test method or even @TestInstance, is devastating for CI performance. This isn't just about monetary cost; it's about developer productivity and confidence. A slow pipeline means less frequent merges, larger batches of changes, and a higher probability of integration hell.

Consider a simple Spring Boot application with 50 integration test classes, each needing a PostgreSQL instance. If each class starts and stops its own Testcontainers PostgreSQL, that's 50 container lifecycles. Even if each takes only 5 seconds to initialize and 2 seconds to tear down, that's 7 minutes of pure overhead before your actual test logic even begins. This is unacceptable.

Testcontainers' Latency Trap

The primary culprit is the repeated provisioning of Docker containers. Docker itself is efficient, but the overhead of starting a container, waiting for its services to become ready (e.g., database accepting connections), and then tearing it down, accumulates rapidly. This isn't a Testcontainers flaw; it's a fundamental aspect of container orchestration. Testcontainers just makes it so easy to do it inefficiently that teams fall into the trap.

Profile Java-based integration tests with tools like YourKit or JProfiler, and you often find that most of the suite's execution time is spent outside of actual business logic assertions. A significant portion of that was Testcontainers lifecycle management. Specifically, waiting for postgres:14.5 to report healthy and kafka:3.4.0 to be ready for producer/consumer connections is a constant, repeatable drain.

Architecting for Reuse: The static and Extension Approach

The solution isn't to abandon Testcontainers, but to use it intelligently. The core principle is reuse. For suites of tests that can tolerate a shared, but reset, state, a single container instance should serve multiple test classes. JUnit 5 and Testcontainers provide mechanisms for this.

The simplest approach is using a static @Container field:

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 java.sql.Connection;
import java.sql.DriverManager;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Testcontainers // Enables automatic startup/shutdown of @Container fields
public class SharedContainerExampleTest {

    // This container will be started once before all tests in this class
    // and stopped once after all tests in this class.
    // Making it static ensures it's shared across all test *methods* within this class.
    @Container
    private static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:14.5")
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass");

    @BeforeAll
    static void setupDatabaseConnection() {
        System.out.println("Shared PostgreSQL container ready at: " + postgres.getJdbcUrl());
        // Here you would typically configure your application's DataSource
        // using the connection details from 'postgres'
    }

    @Test
    void testConnectionValidity() throws Exception {
        try (Connection conn = DriverManager.getConnection(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword())) {
            assertTrue(conn.isValid(10), "Connection should be valid");
        }
    }

    @Test
    void testAnotherConnectionValidity() throws Exception {
        // This test reuses the same 'postgres' container instance
        try (Connection conn = DriverManager.getConnection(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword())) {
            assertTrue(conn.isValid(10), "Connection should be valid for another test");
        }
    }

    // You could also add @AfterEach to clean up data between tests
    // or use transactional tests that rollback changes.
}

For more advanced lifecycle management, especially when sharing across multiple test classes or entire suites, a custom JUnit 5 extension is powerful:

package com.example.qa.extensions;

import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.PostgreSQLContainer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SharedPostgresExtension implements BeforeAllCallback, AfterAllCallback {

    private static final Logger log = LoggerFactory.getLogger(SharedPostgresExtension.class);
    private static PostgreSQLContainer<?> postgresContainer;
    private static boolean started = false; // Flag to ensure container starts only once

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        // Use a store to ensure this logic runs only once for the entire test run,
        // even if multiple test classes use this extension.
        // This is crucial for performance.
        ExtensionContext.Store store = context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL);
        if (store.get("SHARED_POSTGRES_STARTED") == null) {
            log.info("Starting shared PostgreSQL container for the entire test suite...");
            postgresContainer = new PostgreSQLContainer<>("postgres:14.5")
                .withDatabaseName("ci_test_db")
                .withUsername("ci_user")
                .withPassword("ci_pass")
                .withInitScript("sql/init_schema.sql"); // Optional: pre-populate schema
            postgresContainer.start();
            System.setProperty("TEST_DB_URL", postgresContainer.getJdbcUrl());
            System.setProperty("TEST_DB_USERNAME", postgresContainer.getUsername());
            System.setProperty("TEST_DB_PASSWORD", postgresContainer.getPassword());
            log.info("PostgreSQL container started at: {}", postgresContainer.getJdbcUrl());
            store.put("SHARED_POSTGRES_CONTAINER", postgresContainer);
            store.put("SHARED_POSTGRES_STARTED", true);
            started = true; // For local static reference
        } else {
            postgresContainer = (PostgreSQLContainer<?>) store.get("SHARED_POSTGRES_CONTAINER");
            started = true; // Flag for local consistency
            log.info("Reusing already started PostgreSQL container: {}", postgresContainer.getJdbcUrl());
        }
    }

    @Override
    public void afterAll(ExtensionContext context) throws Exception {
        // Check if this is the last context to be torn down for the entire run
        // This is tricky with parallel execution, but for sequential, it works.
        // A better approach for global shutdown is often a separate listener or a custom runner.
        // For simplicity, we'll rely on the 'started' flag and hope for the last AfterAll.
        // In a real scenario, you'd use a reference count or a global shutdown hook.
        ExtensionContext.Store store = context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL);
        if (store.get("SHARED_POSTGRES_CONTAINER") != null && started && context.getParent().isEmpty()) { // Check if it's the root context
            log.info("Stopping shared PostgreSQL container...");
            ((PostgreSQLContainer<?>) store.get("SHARED_POSTGRES_CONTAINER")).stop();
            store.remove("SHARED_POSTGRES_CONTAINER");
            store.remove("SHARED_POSTGRES_STARTED");
            started = false;
        }
    }

    public static String getJdbcUrl() {
        if (!started || postgresContainer == null) {
            throw new IllegalStateException("PostgreSQL container has not been started or already stopped.");
        }
        return postgresContainer.getJdbcUrl();
    }

    public static String getUsername() {
        return System.getProperty("TEST_DB_USERNAME"); // Or directly from postgresContainer if available
    }

    public static String getPassword() {
        return System.getProperty("TEST_DB_PASSWORD"); // Or directly from postgresContainer if available
    }
}

And your test class:

package com.example.qa.tests;

import com.example.qa.extensions.SharedPostgresExtension; // Import your extension
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;

@ExtendWith(SharedPostgresExtension.class)
public class UserIntegrationTest {

    @BeforeAll
    static void setupApplicationDatabase() {
        // Configure your application's DataSource using the shared container's details
        // For example, in Spring Boot, you'd set properties or programmatically configure.
        System.setProperty("spring.datasource.url", SharedPostgresExtension.getJdbcUrl());
        System.setProperty("spring.datasource.username", SharedPostgresExtension.getUsername());
        System.setProperty("spring.datasource.password", SharedPostgresExtension.getPassword());
        System.out.println("Application configured to use shared DB: " + SharedPostgresExtension.getJdbcUrl());
    }

    @Test
    void testUserTableExists() throws Exception {
        try (Connection conn = DriverManager.getConnection(
            SharedPostgresExtension.getJdbcUrl(),
            SharedPostgresExtension.getUsername(),
            SharedPostgresExtension.getPassword());
             Statement stmt = conn.createStatement()) {
            ResultSet rs = stmt.executeQuery("SELECT 1 FROM pg_tables WHERE tablename = 'users'");
            assertTrue(rs.next(), "Table 'users' should exist in the database.");
        }
    }

    @Test
    void testCanInsertAndRetrieveUser() throws Exception {
        try (Connection conn = DriverManager.getConnection(
            SharedPostgresExtension.getJdbcUrl(),
            SharedPostgresExtension.getUsername(),
            SharedPostgresExtension.getPassword());
             Statement stmt = conn.createStatement()) {

            // Clean up before test to ensure isolation (crucial for shared containers)
            stmt.executeUpdate("DELETE FROM users");
            stmt.executeUpdate("INSERT INTO users (id, name) VALUES (1, 'Raju Shanigarapu')");

            ResultSet rs = stmt.executeQuery("SELECT name FROM users WHERE id = 1");
            assertTrue(rs.next());
            assertEquals("Raju Shanigarapu", rs.getString("name"));
            assertEquals(0, stmt.executeUpdate("DELETE FROM users WHERE id = 1"), "Cleanup should remove the inserted user."); // Cleanup after test
        }
    }
}

This approach, when implemented correctly with proper data cleanup between tests (e.g., using transactional tests with rollback, or explicit DELETE statements), significantly reduces the overhead. Moving from per-class container spins to a single, suite-managed instance can cut a service's integration suite time substantially.

Where This Breaks Down

The trade-off for speed is perfect isolation. When sharing a container, you introduce the risk of state leakage between tests. A test that modifies data without cleaning up, or changes configuration in a way that affects subsequent tests, will lead to flaky failures. This requires disciplined test design: ensure each test starts with a known state, either by transactional rollbacks or explicit data setup/teardown. This added complexity in test code is the cost of faster feedback. Furthermore, for highly complex scenarios requiring independent instances of multiple services (e.g., Kafka, Zookeeper, multiple microservices), even a shared approach might not be enough. In such cases, Docker Compose with Testcontainers' ComposeContainer or managed ephemeral environments become necessary, shifting the orchestration burden further upstream to platform engineering.

What About External Dependencies?

Not every dependency needs Testcontainers. For simple unit tests where a database is just a storage mechanism, an in-memory database like H2 or an embedded Kafka can be faster and simpler. For external services, WireMock 2.3x or similar tools for service virtualization are often superior to spinning up the actual service in a container. These mock responses are instantaneous and remove external flakiness. Use Testcontainers for true integration tests where the interaction with a real database or message queue is essential, not for every trivial data access.

The Platform Engineering Imperative

Ultimately, the optimal use of Testcontainers in CI often requires a partnership with platform engineering. Providing a standardized way to launch and manage shared container instances (e.g., via Docker Compose files or ephemeral environments provisioned by Kubernetes operators) can abstract away much of the complexity from individual test suites. This moves the "orchestration tax" from every developer's build to a centralized, optimized platform. We're currently exploring how to use dedicated, ephemeral K8s namespaces for larger integration suites, where Testcontainers can connect to services within that namespace rather than spinning up local ones, further reducing resource contention and improving stability.

Actionable Takeaway This Week

Identify your slowest integration test suite that uses Testcontainers. Profile its execution and quantify the time spent on container lifecycle management. Then, refactor at least one major test class to use the SharedPostgresExtension (or equivalent for your chosen container type) approach shown above, ensuring proper data cleanup between tests. Aim to reduce the container startup overhead for that specific suite by at least 30% by the end of the week. This isn't just theory; it's a measurable improvement to your team's velocity.

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.

Sent with MailerLite. See the privacy policy.