Skip to main content
  1. Blog Posts/

Testcontainers: When Mocking Isn't Enough

·11 mins· loading · loading · ·
Engineering Testcontainers Testing Docker Spring-Boot Junit Reliability
Table of Contents

Sometimes integration testing can feel like a necessary evil. It’s the land of flaky CI builds, complex docker-compose.yml files managed outside the project, and the eternal debate of whether your mock truly behaves like the real thing. At one point, we’ve all been hit with a “it works on my machine” phenomenon, or cases where a test passes locally against an in-memory database like H2, only to fail spectacularly in staging against a real PostgreSQL instance due to a subtle dialect difference.

At that point, we should stop treating our test environments as a fragile afterthought, and start demanding more fidelity and reliability from our tests. This is where Testcontainers comes in.

What is Testcontainers?
#

Testcontainers is a library that helps you create and manage Docker containers for testing purposes. It provides a programmatic API to control real, ephemeral Docker containers from within your test code. It’s easier to think of it as a tool to automatically start and stop services (like databases or message brokers) in isolated environments while your tests are running. Not only does enable you to have a clean and controlled environment for your tests, it also ensures that your application is tested against the real services it will interact with in production.

The Core Philosophy: Test Against the Real Thing
#

At its heart, Testcontainers is a language-agnostic framework (with libraries for Java, Go, Python, and more) that bridges the gap between your test code and the Docker daemon. Instead of mocking a database connection or an S3 bucket, you programmatically start a real PostgreSQL database or a MinIO instance in a container.

This approach provides three powerful guarantees:

  1. High-Fidelity Testing: You eliminate entire classes of bugs that arise from discrepancies between mocks and real services. Your SQL queries, message queue interactions, and API calls are validated against the real thing.
  2. Total Isolation: Each test, or test suite, can get its own pristine, ephemeral container. There’s no risk of tests interfering with each other by leaving behind dirty data. When the test is done, the container vanishes without a trace.
  3. Consistency and Portability: The entire test environment is defined in code and checked into version control. This eradicates the “it works on my machine” problem. If the tests pass on your laptop, they will pass in the CI pipeline, because they are running against the exact same Docker image every single time.

The Building Blocks: Your Arsenal of Containers
#

Testcontainers provides a flexible API for managing any kind of containerized service.

The most fundamental building block is the GenericContainer. It allows you to start a container from any Docker image, giving you complete control over ports, environment variables, and startup conditions.

// A generic container for a simple web server
GenericContainer<?> webServiceContainer = new GenericContainer<>("nginx:latest")
        .withExposedPorts(80);

While GenericContainer is the universal tool, the real power for many developers comes from the specialized modules. These are pre-configured classes for common services that handle the boilerplate for you.

Practical Walkthrough: Your First Test
#

Let’s write a simple integration test for a Spring Boot application that uses a PostgreSQL database.

1. Add the Dependencies
#

First, you’ll need the core Testcontainers library, the JUnit integration, and the specific module for your service.

dependencies {
    implementation 'org.testcontainers:testcontainers:1.19.6'
    implementation 'org.testcontainers:postgresql:1.19.6'
    testImplementation 'org.testcontainers:junit-jupiter:1.19.6'
}

2. Integrate with JUnit 5
#

The @Testcontainers annotation activates the Testcontainers extension for JUnit 5. The @Container annotation is used to mark fields that should be managed as Testcontainers.

@Testcontainers
@SpringBootTest
class MyIntegrationTests {

    @Container
    // 'static' makes the container shared between all tests in this class
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15.2-alpine")
            .withDatabaseName("testdb")
            .withUsername("user")
            .withPassword("password");

    @Test
    void myFirstIntegrationTest() {
        // Your test logic here...
        // The postgres container is already started and running.
    }
}

This is the modern, declarative way to manage containers. The lifecycle is handled for you:

  • Because the field is static, the container will be started once before any tests in this class run.
  • It will be stopped and removed automatically after all tests in the class have finished.

3. Connect Your Application to the Container
#

A common (but slightly verbose) way is to use a DynamicPropertySource.

How does my application know how to connect to this container? The port is random!

This is the critical step. Testcontainers purposefully starts on a random, available port to avoid conflicts. You must dynamically configure your application to connect to it.

@Testcontainers
@SpringBootTest
class MyIntegrationTests {

    @Container
    static PostgreSQLContainer<?> postgres = ...

    // This method will be called before the application context is created
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Test
    void myFirstIntegrationTest() {
        // The Spring application context is now wired up
        // to use the running PostgreSQL container.
    }
}

Now, when your Spring application starts, it will use these dynamic properties, completely overriding anything in your application.properties file and connecting directly to the test container.

Mastering the Lifecycle: From Disposable to Reusable
#

Starting a fresh container for every test class is a great default, but for large test suites, the startup time can add up. The goal is to minimize resource usage and test execution time without sacrificing reliability. For this, we can move from class-level containers to a singleton pattern that provides a single, shared container for the entire test suite run.

Singleton Containers with a Context Initializer
#

An even more refined and self-contained approach is to manage the entire lifecycle of the singleton container within an ApplicationContextInitializer. This pattern is incredibly clean because it completely decouples your test classes from the container setup.

  1. Create a Self-Contained Initializer: This single class is now responsible for the provisioning: defining the container, starting it once, and injecting its properties into the Spring context.

    public class TestcontainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    
        private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15.2-alpine");
    
        static {
            // Start the container once for entire test suites
            postgres.start(); 
        }
    
        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            // Inject the container properties into the Spring environment
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                applicationContext,
                "spring.datasource.url=" + postgres.getJdbcUrl(),
                "spring.datasource.username=" + postgres.getUsername(),
                "spring.datasource.password=" + postgres.getPassword()
            );
        }
    }
    
  2. Apply to Your Tests: Now your actual test class is incredibly clean. They no longer need to extend a base class or even be aware that Testcontainers is being used. They just need to register the initializer.

    @SpringBootTest
    @ContextConfiguration(initializers = TestcontainerInitializer.class)
    class MyIntegrationTests {
    
        // Notice there is NO @Testcontainers or @Container annotation here.
        // No base class is needed. The test is completely clean.
    
        @Test
        void myTest() {
            // The Spring context starts, automatically configured by our
            // initializer to point to the singleton PostgreSQL container.
        }
    
        @Test
        void anotherTest() {
            // This test also uses the same, already-running container.
        }
    }
    

Speed Boost: Reusing Containers
#

For local development, you can tell Testcontainers not to stop containers when the tests are finished. This makes re-running tests almost instant.

Create a file named testcontainers.properties in your src/test/resources directory (classpath), or .testcontainers.properties in your home folder, and add:

testcontainers.reuse.enable=true

With this flag, Testcontainers will leave the container running after the JVM exits. The next time you run your tests, it will reconnect to the existing container instead of creating a new one.

Note

This is a feature primarily for local development to speed up your workflow. Do not enable this in CI environments, where you always want a guaranteed clean slate. For more details, see the official documentation on Container Reuse.

Running in Parallel
#

To speed up large test suites, you can run multiple containers at the same time. The @Testcontainers(parallel = true) annotation enables the parallel startup of all @Container-annotated fields in a class.

@Testcontainers(parallel = true)
class ParallelIntegrationTests {
    @Container
    static final PostgreSQLContainer<?> postgres = new ...

    @Container
    static final KafkaContainer kafka = new ...

    // Tests will only start after both postgres and kafka are running.
}

For CI environments, remember to pre-pull your Docker images as a separate build step. This prevents your tests from being slowed down by network latency while downloading large images like Kafka or Elasticsearch.

The Big Caveat
#

With all this power, it’s easy to think Testcontainers is the answer to everything. But it’s a specific tool for a specific job: for integration purposes.

When Not to Use Testcontainers
#

There are scenarios where it’s overkill. Consider a multi-module project where you have a shared library that contains pure business logic. This library has no Spring context, no web server, just plain old Java classes and methods.

In this case, using Testcontainers is the wrong tool for the job.

Your goal here is fast, focused unit testing. Setting up a Docker container to test a simple utility function would be incredibly slow and add unnecessary complexity. For these scenarios, traditional unit testing tools are far more appropriate:

  • JUnit and AssertJ for your assertions.
  • Mockito to mock any external dependencies or interfaces.
  • Even an H2 in-memory database can be acceptable here if you are only testing simple, standard SQL logic and not vendor-specific features.

The key is to match the tool to the task. Use Testcontainers when you need to verify the integration between your application and external, containerized services. Use mocks and unit tests for everything else.

When to Use Testcontainers
#

The traditional advice is to use mocks for unit tests and save containers for integration tests. While well-intentioned, this advice misses a crucial modern reality: the line between unit and integration testing has blurred. A better way to frame it is by understanding when a test needs to be solitary (using mocks to isolate logic) versus when it needs to be sociable (using real dependencies to validate interactions).

Testing the Data Access Layer (Repositories/DAOs)
#

The primary responsibility—the “unit of work”—of a repository class is to correctly map objects to the database and translate method calls into the correct SQL.

If you mock the database connection, you aren’t testing this core responsibility. You are simply assuming it works. By testing your JPA/Hibernate repository or your JDBC-based DAO against a real database via Testcontainers, you gain confidence in:

  • Correct Mapping: Are your @Column, @Entity, and @JoinColumn annotations correct?
  • Query Generation: Does the query generated by a method name like findByLastnameAndFirstNameOrderByLastnameAsc actually work?
  • Cascade and Fetching Behavior: Does CascadeType.PERSIST behave as you expect? Is your FetchType.LAZY configuration working correctly to avoid N+1 problems?

Testing a single repository class against a containerized database is a perfect example of a high-value sociable unit test.

Testing Libraries Whose Core Purpose Is Integration
#

Consider the “shared library” from our previous example. What if the purpose of that library is to provide a simplified client for an external service?

For instance, maybe you’ve written a library that offers a clean API for interacting with RabbitMQ, handling all the boilerplate of connection, channel creation, and message serialization.

How would you unit test this library? Mocking the underlying RabbitMQ client would be pointless; you would just be testing that your code calls the mock correctly. The real risk is whether your library can actually talk to RabbitMQ.

In this scenario, using a RabbitMQContainer to test your library’s functions is the most logical approach. Each test can verify a small unit of functionality (e.g., “does my publishJsonMessage method correctly send a message that can be consumed?”), but it does so against the real deal.

Uncovering Framework Magic and Hidden Bugs
#

Frameworks are powerful because they do a lot of “magic” for us: interpreting annotations, generating code at runtime, and handling complex protocols. A mock, however, is completely blind to this magic. This can create a dangerous gap between what your tests verify and what your code actually does in production.

This is a lesson many developers learn the hard way. For example, imagine you write a declarative REST client using Spring Cloud OpenFeign:

@FeignClient(name = "product-service")
public interface ProductClient {
    // Using the PATCH HTTP method
    @RequestMapping(method = RequestMethod.PATCH, value = "/products/{id}")
    void updateProductPartial(@PathVariable("id") Long id, Map<String, Object> fields);
}

If you write a unit test for this client and mock the underlying HTTP calls, you might never realize that Spring Cloud OpenFeign does not support the PATCH method out of the box. Your mock will happily accept the call, but in production, you’ll get a runtime error.

However, if you write a test with a MockServer container that simulates the real HTTP interactions, you will catch this issue early.

Final Takeaway
#

Stop mocking what truly matters. For the critical seams of your application like the database connections, the message queues, and the API interactions, let’s learn to embrace high-fidelity testing with Testcontainers. This is how you move from assuming your code works to knowing it works, building robust, production-ready applications without having to take a leap of faith.

Jevin Laudo
Author
Jevin Laudo
Backend engineer passionate about scalable systems, tech, and sharing what I learn.

Related

Networking 101
·12 mins· loading · loading
Engineering Cloud Networking Backend Infrastructure Engineering
The Basics
·2 mins· loading · loading
Engineering Introduction Cloud Self Growth Infrastructure
AWS CLF-C02: Unlocked!
·14 mins· loading · loading
Growth Cloud Self Growth Tech Culture Infrastructure