Back to Blog
Test AutomationIntegration TestingTest DesignTestcontainers

Testcontainers: Your 'Integrated' Tests Are Just Slow Unit Tests

Most teams laud Testcontainers for bringing 'real' services to integration tests. They're wrong. What you're often building are slow, brittle unit tests masquerading as integration, simply shifting the complexity of dependency management from explicit mocks to implicit infrastructure. Testcontainers makes it *too easy* to avoid designing truly isolated, fast feedback loops, leaving you with the illusion of confidence.

September 3, 2026
7 min read
RS
Raju Shanigarapu

We've been sold a lie: that spinning up every dependency in a Docker container for your "integration" tests automatically makes them better, more "real." It doesn't. More often than not, it creates bloated, slow, and tightly coupled tests that provide a false sense of security, effectively turning your fast unit tests into slow, brittle approximations of integration that hide deeper architectural issues.

The Siren Song of "Real" Dependencies

The appeal of Testcontainers is undeniable. Gone are the days of complex mock setups for databases or message queues; just spin up a real PostgreSQL 16.x or Kafka 3.5.x instance, and your tests interact with it directly. This perceived simplicity and "realism" is intoxicating. Developers embrace it because it feels like less effort than crafting thoughtful test doubles. QA architects see it as a way to get closer to production behavior without the pain of managing shared staging environments.

But this convenience often comes at a steep price. The ease of pulling down mongo:7.0 or redis:7.2 can mask a fundamental misunderstanding of what a good integration test should achieve. It encourages a "just run everything" mentality, blurring the lines between true integration points and internal component interactions that should be isolated.

The Cost of Convenience: Latency and Flakiness

Every container you spin up adds overhead. Docker daemon startup time, image pull times (even cached ones), container initialization, network setup—it all accumulates. I've seen microservice test suites that ran quickly with intelligent mocking balloon to many minutes because engineers decided to Testcontainers-ize every internal dependency. Being strategic about which dependencies truly need "real" containers wins most of that time back.

Beyond raw speed, Testcontainers can introduce flakiness. Network glitches, container startup race conditions, resource contention on the CI runner, or subtle differences in containerized service configurations can lead to intermittent failures that are maddeningly hard to debug. When your integration test fails because the Kafka container took an extra second to become ready, that's not a bug in your application; it's a bug in your test strategy. Meticulously reviewing where Testcontainers is used, and replacing unnecessary container dependencies with targeted test doubles, takes much of that flakiness out.

Testcontainers as a Crutch: Where Are Your Test Doubles?

The core issue is often a failure to distinguish between different levels of testing and the appropriate tools for each. Testcontainers is excellent for verifying interactions at the boundaries of your system with external dependencies—like ensuring your Kafka producer correctly sends messages, or your service correctly persists data to a database. It's an integration test in the purest sense, verifying the contract between your service and a third-party system.

However, many teams extend this philosophy to internal service-to-service communication. If your OrderProcessor calls a PricingService, using Testcontainers to spin up a full instance of the PricingService just to test the OrderProcessor is an anti-pattern. You're not testing the integration with an external system; you're testing the integration with another part of your own system. This is where well-designed test doubles—mocks, stubs, fakes—shine. They provide fast, deterministic control over the dependency's behavior, allowing you to isolate the unit of work you're truly interested in.

Here's an example demonstrating how to judiciously use Testcontainers for a true external dependency (Kafka) while employing WireMock for an internal service dependency, providing both speed and realism where it matters:

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.web.reactive.function.client.WebClient;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;

import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;

import static org.assertj.core.api.Assertions.assertThat;

// DTOs for the example
class Order {
    private String id;
    private String productId;

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getProductId() { return productId; }
    public void setProductId(String productId) { this.productId = productId; }
}

class PricingResponse {
    private double price;

    public PricingResponse() {}
    public PricingResponse(double price) { this.price = price; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
}

// The service under test
class OrderProcessorService {
    private final KafkaProducer<String, String> kafkaProducer;
    private final WebClient pricingServiceClient;

    public OrderProcessorService(KafkaProducer<String, String> kafkaProducer, WebClient pricingServiceClient) {
        this.kafkaProducer = kafkaProducer;
        this.pricingServiceClient = pricingServiceClient;
    }

    public void processOrder(Order order) {
        PricingResponse pricing = pricingServiceClient.get()
                .uri("/pricing/{productId}", order.getProductId())
                .retrieve()
                .bodyToMono(PricingResponse.class)
                .block();

        kafkaProducer.send(new ProducerRecord<>("order-events", order.getId(),
                "Order processed with price: " + pricing.getPrice()));
        kafkaProducer.flush();
    }
}

// The integration test
@Testcontainers
public class OrderProcessorIntegrationTest {

    // Testcontainers for the real Kafka dependency
    @Container
    private static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));

    // WireMock for the internal pricing service dependency
    @RegisterExtension
    static WireMockExtension wireMockExtension = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort())
            .build();

    private OrderProcessorService orderProcessorService;
    private KafkaConsumer<String, String> kafkaConsumer;

    @BeforeEach
    void setup() {
        Map<String, String> consumerProps = new HashMap<>();
        consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
        consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group-" + UUID.randomUUID());
        consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        kafkaConsumer = new KafkaConsumer<>(consumerProps);
        kafkaConsumer.subscribe(Collections.singletonList("order-events"));

        Map<String, String> producerProps = new HashMap<>();
        producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
        producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps);

        WebClient pricingServiceClient = WebClient.builder()
                .baseUrl(wireMockExtension.baseUrl())
                .build();

        orderProcessorService = new OrderProcessorService(producer, pricingServiceClient);
    }

    @AfterEach
    void tearDown() {
        if (kafkaConsumer != null) {
            kafkaConsumer.close();
        }
    }

    @Test
    void shouldProcessOrderAndPublishToKafka() {
        String productId = "PROD-123";
        double expectedPrice = 99.99;

        wireMockExtension.stubFor(get(urlEqualTo("/pricing/" + productId))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        .withBody("{\"price\":" + expectedPrice + "}")));

        Order order = new Order();
        order.setId("ORDER-ABC");
        order.setProductId(productId);

        orderProcessorService.processOrder(order);

        ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(10));
        assertThat(records).hasSize(1);
        ConsumerRecord<String, String> record = records.iterator().next();
        assertThat(record.key()).isEqualTo(order.getId());
        assertThat(record.value()).contains("Order processed with price: " + expectedPrice);

        wireMockExtension.verify(1, getRequestedFor(urlEqualTo("/pricing/" + productId)));
    }
}

This approach maintains the realism for the critical integration with Kafka (ensuring messages are correctly serialized and sent) while keeping the test fast and deterministic for the internal service call using WireMock. The confluentinc/cp-kafka:7.4.0 image offers a reliable, production-like Kafka environment for this specific boundary test.

When Testcontainers Shines (And When It Doesn't)

Testcontainers excels at integration tests that verify the actual contracts between your application and external, third-party systems (databases, message brokers, external APIs you don't control, specialized caches like Redis). It's invaluable for testing database schema migrations, ensuring your application speaks the correct dialect of SQL, or that Kafka topics are correctly produced/consumed.

It falters when you try to use it to replace good modular design and proper test isolation for internal components. If your service requires spinning up three other internal microservices just to run its tests, that's not a Testcontainers problem; that's an architecture problem. Your service boundaries are too fuzzy, or your dependencies are too tight. A truly decoupled microservice should be testable largely in isolation, with its immediate dependencies mocked or stubbed.

The Architect's Dilemma: Testability vs. "Realism"

Architects constantly balance realism with testability. Testcontainers, when misused, pushes the needle too far towards "realism" at the expense of testability. It enables a lazy approach where instead of designing for testability (e.g., clear dependency injection, well-defined interfaces for external systems), you simply "Dockerize" everything. This can lead to services that are incredibly hard to test efficiently in isolation, ultimately slowing down development and increasing CI/CD pipeline times.

If your test suite becomes a sprawling network of interconnected Testcontainers, you've essentially rebuilt a miniature, ephemeral production environment for every test run. This might sound good on paper, but in practice, it's slow, resource-intensive, and prone to the same distributed system complexities you're trying to avoid in your tests.

Where This Breaks Down

Despite its strengths, Testcontainers isn't without its limitations. It inherently depends on a running Docker daemon, which can be a hurdle in certain environments or for developers without Docker Desktop. The resource consumption can be significant, especially with multiple complex containers, impacting local development machine performance and CI runner costs. Furthermore, for highly specialized or proprietary systems, a Testcontainers image might not exist, forcing you back to mocks or dedicated test environments. It's a powerful hammer, but not every problem is a nail.

Stop treating Testcontainers as a magic bullet for all your integration testing woes. This week, audit your existing Testcontainers usage. Identify any tests that spin up containers for internal service dependencies. Replace those with well-crafted WireMock stubs or other appropriate test doubles, and measure the impact on test execution time and stability.

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.