Testcontainers: Integration Tests with Real Databases
Back to Blog

Testcontainers: Integration Tests with Real Databases

6 min read
Read in Deutsch

The Problem with H2

At encircle360, we have been relying on Spring Boot for our backend services for years -- something we have covered extensively in our earlier posts about Spring Boot and Spring Boot 2.0. And as is common in most Spring Boot projects, we used H2 as an in-memory database for our integration tests for a long time. The idea sounds compelling: H2 starts quickly, requires no external infrastructure, and runs everywhere. In practice, however, this setup has caused us problems time and again.

The core problem is simple: H2 is not PostgreSQL. And when the production database is PostgreSQL, testing with H2 means testing against a different database than the one running in production. The SQL dialects differ. Features like JSONB columns, ON CONFLICT clauses, specific index types, or window functions behave differently in H2 or simply do not exist. Constraint validation, transaction isolation, type conversions -- subtle differences lurk everywhere.

The result: tests that pass but validate code that fails in production. Or the reverse: tests that fail even though the code is correct, because H2 does not support a SQL syntax that PostgreSQL handles just fine. Anyone using Flyway or Liquibase for database migrations knows the problem is even worse -- migration scripts written for PostgreSQL must be adapted for H2 or run in compatibility mode. This undermines the very purpose of the tests.

Testcontainers: The Real Database in Your Tests

Testcontainers solves this problem elegantly. The Java library starts a Docker container with the real database before the tests -- PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Kafka, or whatever the service uses in production. The container is ephemeral: it is started before the tests and removed afterward. Every test run begins with a clean instance.

The project has existed since 2015 and is now well established. The current version at the time of this article is 1.17.x, the documentation is solid, and the community continues to grow. For us, the switch was long overdue.

The prerequisite is simple: Docker must be installed on the machine. Since we already use Docker for local development and in our CI pipelines, this was not a hurdle. No databases need to be installed or configured locally -- Docker handles everything.

Setup with Gradle and Spring Boot

Integrating into an existing Spring Boot project with Gradle -- our build tool of choice, as we described in our post about Gradle -- is straightforward. You add the Testcontainers dependencies to your build.gradle:

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

The junit-jupiter module provides the JUnit 5 integration, while the postgresql module offers a specially preconfigured PostgreSQL container. For other databases, there are corresponding modules -- MySQL, MariaDB, Oracle, MSSQL, and many more.

JUnit 5: @Testcontainers and @Container

The integration with JUnit 5 is well-designed and minimally invasive. Two annotations are all it takes to couple the container lifecycle to the test lifecycle.

@Testcontainers on the test class activates the Testcontainers extension for JUnit 5. It takes care of starting fields annotated with @Container before the tests and stopping them afterward.

@Container marks the container field. If the field is static, the container is started once per test class (shared container). If it is non-static, a fresh container is started for each test method. For most integration tests, a shared container per class is the sensible compromise between isolation and speed.

Here is a complete example of a Spring Boot test class with PostgreSQL:

@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @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 shouldPersistAndRetrieveOrder() {
        Order order = new Order();
        order.setCustomerId("C-123");
        order.setStatus(OrderStatus.PENDING);
        order.setCreatedAt(LocalDateTime.now());

        Order saved = orderRepository.save(order);

        assertThat(saved.getId()).isNotNull();
        assertThat(orderRepository.findById(saved.getId()))
                .isPresent()
                .get()
                .extracting(Order::getCustomerId)
                .isEqualTo("C-123");
    }

    @Test
    void shouldHandleJsonbColumn() {
        Order order = new Order();
        order.setCustomerId("C-456");
        order.setMetadata("{\"source\": \"api\", \"priority\": \"high\"}");

        Order saved = orderRepository.save(order);

        // This test would fail with H2 because JSONB is not supported
        assertThat(orderRepository.findByMetadataContaining("api"))
                .hasSize(1);
    }
}

DynamicPropertySource: The Bridge to Spring

The key element is @DynamicPropertySource. This annotation, available since Spring Boot 2.2.6, solves a central problem: the PostgreSQL container is started on a random port -- a different one for each test run. The JDBC URL that Spring needs for the DataSource is therefore only known at runtime.

@DynamicPropertySource allows you to set Spring properties dynamically after the container has started but before the ApplicationContext is initialized. The method postgres::getJdbcUrl returns the actual JDBC URL of the running container -- including host, port, and database name. Spring thus receives exactly the connection details of the container.

Before @DynamicPropertySource, you had to use ApplicationContextInitializer or similarly cumbersome constructs. The current solution is far more elegant.

Flyway Migrations: Finally Against the Real Database

A particularly significant benefit shows up with database migrations. Our Flyway scripts are written for PostgreSQL -- with PostgreSQL-specific syntax, specific data types, and specific features. With H2, we had to either maintain separate migration scripts or use H2's compatibility mode, which was never fully compatible.

With Testcontainers, the exact same Flyway scripts run as in production. If a migration script works in the tests, it will also work in staging and production. This eliminates an entire class of errors that has caused us headaches time and again in the past.

Reusable Containers: Faster Local Feedback Cycles

A legitimate objection to Testcontainers: starting containers takes time. A PostgreSQL container needs two to five seconds to boot up, depending on the machine. With a large test suite spanning many test classes, this adds up.

For the CI pipeline, this is acceptable -- correctness trumps speed. For local development, Testcontainers has offered the Reusable Containers feature since version 1.15. To enable it, add the following to ~/.testcontainers.properties:

testcontainers.reuse.enable=true

And mark the container as reusable:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test")
        .withReuse(true);

The container stays alive after the test run and is reused in the next run, provided the configuration is identical. This reduces startup time to milliseconds. For the CI pipeline, you disable this feature -- there you want a clean container for every run.

Beyond PostgreSQL

Testcontainers is not limited to relational databases. The library offers modules for a wide range of infrastructure components:

  • Redis for cache tests
  • Kafka and RabbitMQ for messaging tests
  • Elasticsearch for search
  • LocalStack for AWS services (S3, SQS, DynamoDB)
  • GenericContainer for anything available as a Docker image

GenericContainer is particularly powerful: any Docker image can be started as a test container. We use this, among other things, to test against a MinIO instance that serves as an S3-compatible object store in some client projects.

Best Practices from Our Experience

After several months with Testcontainers, the following patterns have proven effective for us:

Shared container per test class: A static container used for all tests in a class. This saves startup time and provides sufficient isolation for most scenarios. Between tests, the database is reset via @Transactional with rollback or explicitly cleaned up via a @BeforeEach method.

Pinned image versions: Instead of postgres:latest, we use postgres:14-alpine -- the same version as in production. This way, we test not only against the same database type but against the same version. Subtle behavioral differences between PostgreSQL 13 and 14 are reliably caught.

Abstract base class: For projects with many integration tests, an abstract base class that defines the container and DynamicPropertySource is worthwhile. This avoids duplication and ensures all tests use the same configuration.

@SpringBootTest
@Testcontainers
abstract class AbstractIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

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

Concrete test classes inherit from this and immediately have access to a real PostgreSQL instance -- without any container configuration of their own.

Conclusion

Testcontainers has fundamentally changed our testing strategy. The switch from H2 to real databases in Docker containers eliminates an entire category of problems: false-positive tests, compatibility mode hacks, and separate migration scripts. The setup is minimal, the JUnit 5 integration is elegant, and the impact on test execution time is manageable with shared containers and reusable containers.

For us, Testcontainers is now standard in every new Spring Boot project. If you write integration tests -- and every team should -- you should ditch in-memory substitute databases and test against the real infrastructure instead. Docker makes it possible, Testcontainers makes it easy.

Patrick Hütter

Written by

Patrick Hütter

Founder & Software Architect

Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.