What Is Testcontainers and Why You Need It

Your CI pipeline goes green. You deploy to staging. The application crashes on startup because Flyway migrations fail against MySQL — but all your tests used H2. Your team has seen this before. Tests that pass on every machine, fail in production. Not because the business logic was wrong, but because the test infrastructure was fake.

This is the problem Testcontainers was built to solve.

This article covers what Testcontainers is, why traditional approaches to integration testing fall short, and how Testcontainers gives you test environments that match production — automatically, repeatably, with no shared test database to maintain.


What You’ll Learn

  • Why mocks and in-memory databases fail to catch real integration bugs
  • What Testcontainers is and how it works at a high level
  • The testing pyramid and where integration tests fit
  • How Testcontainers compares to alternatives like Docker Compose and shared test databases
  • The prerequisites you need before using Testcontainers

The Problem with Fake Infrastructure

Integration tests exist to verify that your code works correctly with external systems — databases, message brokers, caches, external APIs. The challenge is that real external systems are inconvenient in tests: they need to be installed, configured, seeded with data, and cleaned up after every test run.

The traditional workarounds create a different category of problem.

The H2 Problem

H2 is an in-memory Java database. It starts in milliseconds and requires no installation. For years, it was the default for Spring Boot integration tests.

The problem is that H2 is not PostgreSQL. It is not MySQL. It does not support the same SQL dialect, the same functions, the same constraint behavior, or the same locking semantics. When you test against H2, you are testing against a different database than the one your application will run against in production.

Real bugs that H2 cannot catch:

  • SQL dialect differences. RETURNING in PostgreSQL, JSON_EXTRACT in MySQL, vendor-specific window functions — H2 accepts or rejects SQL differently. A query that works in H2 may fail in PostgreSQL.
  • Migration script failures. Flyway and Liquibase migrations written for PostgreSQL often contain PostgreSQL-specific syntax. H2 compatibility mode covers some of it, but not all.
  • Constraint behavior. PostgreSQL enforces foreign keys, unique constraints, and deferred constraints differently than H2. Tests against H2 can pass while production insertions fail.
  • Connection pool behavior. HikariCP behaves differently with a real TCP connection to a remote database versus a JDBC URL to an in-memory store.

The moment you test against H2 instead of the real database, your tests become unreliable witnesses.

The Mock Problem

Mocking frameworks (Mockito, EasyMock) let you replace dependencies with stubs that return canned responses. Mocks are excellent for unit tests — testing a single class in isolation. For integration tests, they create a different problem: you are testing your code against a contract you wrote yourself.

When you mock a UserRepository, you decide what findByEmail() returns. If the real repository has a bug in its query, your mocked test will never catch it because the mock returns whatever you told it to return.

Mocks verify behavior against a specification. Integration tests verify behavior against the actual system. These are not the same thing.

The Shared Test Database Problem

Some teams run integration tests against a shared test database — a real PostgreSQL instance everyone points their tests at. This solves the dialect problem but creates a different category of failures:

  • Test interference. Two developers run tests simultaneously. Test A inserts a row that Test B’s query picks up. Both tests break intermittently and unpredictably.
  • State pollution. A test fails mid-run and leaves dirty data. The next test run sees unexpected state. You spend an afternoon debugging something that isn’t a real bug.
  • Environment drift. The shared test database schema falls out of sync with the application. Migrations don’t get applied. Tests fail for environmental reasons, not code reasons.
  • No CI parity. Your CI environment needs network access to the shared database. This couples your build pipeline to external infrastructure.

Shared test databases work until they don’t, and when they break they break in confusing ways.


What Testcontainers Is

Testcontainers is a Java library that lets you spin up real services — databases, message brokers, caches, web servers — as Docker containers during your tests, then tear them down automatically when the tests finish.

Instead of connecting to a shared database or an in-memory fake, your test starts a real PostgreSQL container, runs your tests against it, and stops the container when done. The container is isolated to your test run. It starts clean every time. No shared state, no dialect differences, no environment drift.

The official definition from the project: a library providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

Here is what a Testcontainers test looks like in its simplest form:

@Testcontainers
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @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);
    }

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldSaveAndRetrieveOrder() {
        Order order = new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(99.99));
        Order saved = orderRepository.save(order);

        Optional<Order> found = orderRepository.findById(saved.getId());
        assertThat(found).isPresent();
        assertThat(found.get().getStatus()).isEqualTo(OrderStatus.PENDING);
    }
}

This test:

  1. Starts a real PostgreSQL 16 container before the test class runs
  2. Wires the container’s JDBC URL, username, and password into Spring Boot’s properties
  3. Runs the test against the real database
  4. Stops and removes the container after the test class finishes

The database is not H2. It is not a shared instance. It is PostgreSQL 16, the same version as production, isolated to this test run.


How Testcontainers Works

Testcontainers communicates with the Docker daemon running on your machine (or in CI) to manage container lifecycle. When a test starts, Testcontainers:

  1. Pulls the image if not already cached locally
  2. Starts the container with the specified configuration
  3. Waits for the container to be ready (using a wait strategy you define)
  4. Maps the container’s port to a random available port on the host
  5. Returns the host and port so your test code can connect to it

After the test, the Ryuk sidecar container that Testcontainers starts automatically cleans up any containers that were not explicitly stopped. Even if your test JVM crashes, Ryuk cleans up. No leaked containers.

Your Test JVM
     │
     │ starts/stops containers
     ▼
Docker Daemon ──── PostgreSQL Container (port 5432 → random host port)
     │
     └──── Ryuk (cleanup sidecar)

Dynamic port mapping is a key detail. Testcontainers never uses fixed ports. If you start a PostgreSQL container, it might be accessible on port 54321 this run and 49876 next run. You always get the current mapped port via container.getMappedPort(5432). This avoids port conflicts between parallel test runs on the same machine.


The Testing Pyramid

Understanding where Testcontainers fits requires understanding the testing pyramid.

         ┌───────────┐
         │  System   │  ← Few, slow, expensive
         │  (E2E)    │
        ┌┴───────────┴┐
        │ Integration │  ← Some, moderately fast
        │   Tests     │
       ┌┴─────────────┴┐
       │  Unit Tests   │  ← Many, fast, cheap
       └───────────────┘

Unit tests test a single class in isolation. They are fast (milliseconds each), require no infrastructure, and form the bulk of your test suite. Testcontainers is not for unit tests.

Integration tests test that multiple components work together correctly — your service class with its repository, your repository with the real database, your Kafka consumer with the real broker. This is where Testcontainers shines. These tests are slower (seconds each) but catch a category of bugs that unit tests cannot.

System tests (end-to-end tests) test the entire deployed system from the outside. A full Testcontainers setup can support system testing by starting the entire application stack as containers.

Testcontainers primarily targets integration tests. It makes the integration test layer reliable without the operational burden of shared infrastructure.


Testcontainers vs the Alternatives

ApproachDialect MatchIsolatedCI-FriendlySetup Cost
H2 in-memoryNoYesYesZero
MocksN/AYesYesLow
Shared test DBYesNoHardHigh
Docker ComposeYesYesPossibleMedium
TestcontainersYesYesYesLow

Docker Compose is a legitimate alternative. You define your services in a docker-compose.yml, start them before running tests, and stop them after. Testcontainers can even start Docker Compose projects with DockerComposeContainer. The downside is that Docker Compose requires manual lifecycle management — you need a script to start it before tests and stop it after, and it does not integrate with JUnit’s test lifecycle.

Testcontainers integrates directly with JUnit 5. Containers start when the test class starts and stop when it ends. No external scripts, no manual cleanup.


What You Can Test with Testcontainers

Testcontainers has dedicated modules for the most common services:

CategoryExamples
SQL DatabasesPostgreSQL, MySQL, MariaDB, MS SQL Server, Oracle XE
NoSQLMongoDB, Cassandra, Neo4j, CockroachDB
Message BrokersApache Kafka, RabbitMQ, ActiveMQ, Apache Pulsar
CachesRedis (via GenericContainer), Memcached
SearchElasticsearch, OpenSearch
AWSLocalStack (S3, SQS, SNS, DynamoDB, Lambda)
AuthKeycloak (via community module)
API MockingWireMock
ResilienceToxiproxy (network fault injection)
BrowsersChrome, Firefox, Edge (via Selenium)
CustomAny Docker image via GenericContainer

The dedicated modules handle image configuration, port exposure, wait strategies, and provide typed accessors for connection details. GenericContainer lets you run any image that does not have a dedicated module.


Prerequisites

Before using Testcontainers you need:

Docker installed and running. Testcontainers uses the Docker daemon. Install Docker Desktop (Mac, Windows) or Docker Engine (Linux). Verify it works:

docker run --rm hello-world

If that command succeeds, Testcontainers will work on your machine.

Java 11 or higher. Testcontainers 1.20+ requires Java 11. This tutorial uses Java 21.

JUnit 5. Testcontainers has first-class JUnit 5 support. If you are on JUnit 4, the testcontainers module includes a JUnit 4 rule, but this series uses JUnit 5 exclusively.

Maven or Gradle. Dependencies are available from Maven Central.

You do not need to know Docker deeply. You need Docker running. Testcontainers manages the containers for you.


Common Pitfalls to Avoid

Using latest as the image tag. new PostgreSQLContainer<>("postgres:latest") will pull whatever PostgreSQL version is current at pull time. Your test suite may pass today and fail next month when PostgreSQL 17 is released and the behavior changes. Always pin versions: postgres:16-alpine.

Assuming Docker is available. Testcontainers will throw DockerClientException if Docker is not running when your tests start. Always verify Docker is running before running integration tests.

Treating Testcontainers as a unit test tool. Starting a container takes 5–30 seconds. That is fine for integration tests, which run less frequently. It is not acceptable for a unit test suite that needs to run in under 10 seconds. Keep unit tests and Testcontainers integration tests in separate test source sets or Maven profiles.


Summary

Testcontainers solves a real problem: integration tests that lie. H2 is not your production database. A Mockito stub is not your production Kafka broker. When tests pass against fake infrastructure and fail in production, the tests have not done their job.

Testcontainers runs real services in Docker containers, isolated per test run, automatically cleaned up, with no shared state and no dialect differences. It integrates directly with JUnit 5 so containers start and stop with your test lifecycle.

The next article covers how to add Testcontainers to a Spring Boot project — Maven BOM, module dependencies, and your first running test.

Next: Setting Up Testcontainers in a Java Project