Back to Blog
TestcontainersJavaTest AutomationIntegration Testing

Your Testcontainers Strategy Is Probably Too Slow

Most teams use Testcontainers like a hammer, seeing every external dependency as a nail. This approach turns your 'fast' integration tests into sluggish, resource-hungry beasts that hide real bottlenecks, rather than exposing them.

June 4, 2026
8 min read
RS
Raju Shanigarapu

You're likely using Testcontainers wrong, and it's costing you valuable pipeline time and masking the true pain points in your architecture. Many engineering teams, in their zeal for "real" environments, wield Testcontainers as a blanket solution for every database, message queue, or external service in their integration suite. This overzealous adoption, while well-intentioned, often bloats test execution, turning what should be focused component tests into miniature, slow-spinning replicas of production that are barely faster than deploying to a staging environment.

Your "Fast" Integration Tests Are Still a Lie

Teams often pat themselves on the back for replacing shared development databases with Testcontainers. "Now our tests are isolated!" they proclaim. And technically, they are. But if your test class spins up PostgreSQL, Redis, Kafka, and an S3 mock for every single test method, you haven't solved the problem of slow feedback; you've merely localized it to your CI runner. We observed this exact pattern, where a suite of 50 integration tests, each launching 3-4 containers, pushed our GitHub Actions pipeline execution for that stage from a manageable 8 minutes to an agonizing 25 minutes. The isolation was there, but the speed was gone.

The Database Is Not The Bottleneck You Think It Is

The core fallacy is assuming that containerizing a database with Testcontainers automatically makes your test suite fast. While it provides a clean slate, the startup time of Docker containers, the network overhead, and the resource contention on your CI agent are very real costs. Before Testcontainers, teams relied on shared in-memory databases or even local database installations. The problem wasn't inherently the database type, but the statefulness and shared nature that led to flaky tests. Testcontainers solves the state and shared environment problem, but it introduces a new performance tax if not applied judiciously.

Testcontainers: The Isolation Illusion

True isolation in testing means a single unit of code is tested, free from side effects of other tests or external systems. Testcontainers offers environmental isolation, but if your "integration test" still talks to five different services (even if they're all Testcontainers-managed), you're not testing an integration point; you're testing a miniature system. This creates an illusion of thoroughness while actually obscuring which specific dependency is causing a failure. A failure in such a test could be due to the database, the message queue, or the interaction logic itself. Pinpointing the root cause becomes a debugging exercise in a transient, mini-production environment.

When Throwaway Containers Become a Drag

The "throwaway" nature of Testcontainers is its biggest strength and its greatest weakness. For a single, critical dependency in a focused component test, spinning up a PostgreSQLContainer is invaluable. It ensures you're testing against a real database engine, not an in-memory mock that might deviate in behavior. However, when every test class initializes a new set of containers, or even worse, every test method does, the cumulative startup time becomes prohibitive. The Docker daemon has its limits, and concurrently launching dozens of containers can easily exhaust CPU and memory resources on your CI agent. We found that for suites with hundreds of tests, a GenericContainer for a custom service, if not cached or reused carefully, could add 30-60 seconds per container to the overall execution, even on powerful runners.

The Right Way to Use Testcontainers (And It's Not For Everything)

Testcontainers shines brightest when it's used to isolate a single, critical external dependency for a specific component that absolutely needs to interact with a real instance of that dependency. Think of a UserRepository that needs to confirm SQL syntax and ORM mapping against a live PostgreSQL instance (version postgres:15.3). Or a KafkaProducer that needs to verify message serialization with an actual KafkaContainer (version confluentinc/cp-kafka:7.4.0). For everything else, there are faster, more effective strategies:

  1. Unit Tests: Mock out all dependencies. Blazing fast.
  2. Contract Tests: Use WireMock (version 3.x) for HTTP services or consumer-driven contract testing frameworks for message queues. Verify APIs, not implementations.
  3. Component Tests (with Testcontainers): Target only the specific component and its direct, non-mockable external dependency.

Here’s an example of using Testcontainers for a UserRepository component test. Notice the static container, ensuring it's spun up once for the entire class, not per method. This is crucial for performance.

package com.mendix.qa.repository;

import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;

/**
 * Represents a simple User entity for testing purposes.
 */
class User {
    private Long id;
    private String name;

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public User(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

/**
 * Simple repository to manage User entities.
 */
class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void save(User user) {
        if (user.getId() == null) {
            jdbcTemplate.update("INSERT INTO users (name) VALUES (?)", user.getName());
        } else {
            jdbcTemplate.update("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId());
        }
    }

    public Optional<User> findByName(String name) {
        List<User> users = jdbcTemplate.query("SELECT id, name FROM users WHERE name = ?",
                (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name")), name);
        return users.stream().findFirst();
    }

    public List<User> findAll() {
        return jdbcTemplate.query("SELECT id, name FROM users",
                (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name")));
    }

    public void deleteAll() {
        jdbcTemplate.update("DELETE FROM users");
    }
}

@Testcontainers
class UserRepositoryComponentTest {

    @Container
    private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15.3")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    private static UserRepository userRepository;
    private static JdbcTemplate jdbcTemplate; // For setup/teardown

    @BeforeAll
    static void setup() {
        DataSource dataSource = new DriverManagerDataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
        jdbcTemplate = new JdbcTemplate(dataSource);
        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL);");
        userRepository = new UserRepository(dataSource);
    }

    @BeforeEach
    void clearTable() {
        userRepository.deleteAll(); // Clean state before each test
    }

    @Test
    void testUserCanBeSavedAndFoundByName() {
        // Given
        User newUser = new User("Raju Shanigarapu");

        // When
        userRepository.save(newUser);
        Optional<User> foundUser = userRepository.findByName("Raju Shanigarapu");

        // Then
        assertTrue(foundUser.isPresent());
        assertEquals("Raju Shanigarapu", foundUser.get().getName());
    }

    @Test
    void testFindByNameReturnsEmptyOptionalForNonExistentUser() {
        // When
        Optional<User> foundUser = userRepository.findByName("NonExistentUser");

        // Then
        assertFalse(foundUser.isPresent());
    }

    @Test
    void testMultipleUsersCanBeSavedAndFound() {
        // Given
        userRepository.save(new User("Alice"));
        userRepository.save(new User("Bob"));

        // When
        List<User> allUsers = userRepository.findAll();

        // Then
        assertEquals(2, allUsers.size());
        assertTrue(allUsers.stream().anyMatch(u -> "Alice".equals(u.getName())));
        assertTrue(allUsers.stream().anyMatch(u -> "Bob".equals(u.getName())));
    }
}

This code snippet demonstrates a focused component test for UserRepository. It leverages a static Testcontainers PostgreSQLContainer to ensure the database starts once for the entire test class, significantly cutting down on startup overhead compared to re-initializing per test method. The beforeEach method handles data cleanup, ensuring isolation between tests without the cost of container re-creation. This pattern, applied across our core data access components, cut the startup time for our data-intensive integration suite from 12 minutes to under 3 minutes, without sacrificing the confidence of a real database.

Where This Breaks Down

Testcontainers is not a panacea. It demands a working Docker daemon, which can be a hurdle in some corporate environments or developer setups. If your tests require a complex, multi-container environment with specific network configurations and service discovery, Testcontainers can quickly become cumbersome. You'll end up writing more test setup code than actual test logic, managing DependsOn chains that mirror docker-compose.yaml. Furthermore, the resource consumption of multiple concurrent Testcontainers instances can overwhelm smaller CI runners or local machines. It’s a tool for specific, isolated dependencies, not for orchestrating full system-level environments. Using it to provision a full microservices landscape for "end-to-end" tests is an anti-pattern that leads to slow, flaky, and hard-to-debug tests.

Cut Your Startup Time, Not Your Scope

The goal of test automation is fast, reliable feedback. If your Testcontainers setup is making your tests slow, you've missed the point. You're trading one set of problems (shared state, environment drift) for another (slow pipelines, resource exhaustion). The answer isn't to abandon Testcontainers, but to use it surgically. Focus on testing the boundary between your code and a critical external system. Anything beyond that boundary should be mocked or contract-tested. This approach allowed us to reduce flaky tests in our repository layer, which were once notorious due to shared in-memory databases, from a 15% failure rate to virtually zero.

This week, audit your slowest integration test suite. Identify if Testcontainers is being used to spin up an entire system or just a critical dependency. Refactor one test class to use a targeted static Testcontainer setup for only the specific component under test, cutting off upstream dependencies with WireMock or other mocks. Measure the execution time difference. You'll be surprised.

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.