Skip to content

Stop Mocking the Database: Spring Boot Tests with Testcontainers

Published on
Reading time
5 mins read

For a long time my default for Spring Boot repository tests was an in-memory H2 database. There is nothing to install, it's fast, and MODE=PostgreSQL makes it look close enough. H2 isn't a mock, but it plays the same role: a stand-in that behaves like the real database until it doesn't. The tests prove the code works on H2, while production runs on PostgreSQL.

Where H2 lies to you

All of this applies to H2 1.4.200, the version Spring Boot 2.5 manages:

  • Dialect. MODE=PostgreSQL emulates a handful of behaviors, not the database. Native queries, functions and casts that work on one can fail on the other.
  • JSONB. H2 has a JSON type but no jsonb, and operators like ->> and @> don't parse. A native query on a jsonb column can't be tested at all.
  • Constraints. Partial unique indexes aren't supported, so a rule the database enforces in production doesn't exist in your tests.
  • Locking. for update skip locked is a syntax error, and for update nowait is parsed but ignored. Queue-style "grab the next free row" code can't be tested.
  • Migrations. Flyway scripts written for PostgreSQL often don't run on H2, so tests end up with a Hibernate-generated schema or a second set of scripts. Either way, the real migrations go untested.

Here is a migration with two things H2 1.4.200 can't run, the jsonb column and the partial index:

V1__create_subscriptions.sql
create table subscriptions (
    id          bigserial primary key,
    customer_id bigint      not null,
    status      varchar(16) not null,
    attributes  jsonb       not null default '{}'
);

-- a customer can have many old subscriptions, but only one ACTIVE
create unique index one_active_subscription_per_customer
    on subscriptions (customer_id)
    where status = 'ACTIVE';

A real PostgreSQL with Testcontainers

Testcontainers starts a throwaway PostgreSQL in Docker for the tests. You need org.testcontainers:junit-jupiter and org.testcontainers:postgresql in test scope. Spring Boot 2.5 doesn't manage their versions, so import org.testcontainers:testcontainers-bom (1.16.2 at the time of writing).

With Flyway on the classpath, @DataJpaTest runs the real migration on startup, so this test checks the real constraint:

SubscriptionRepositoryTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class SubscriptionRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13.4");

    @DynamicPropertySource
    static void datasourceProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    SubscriptionRepository repository;

    @Test
    void allowsOnlyOneActiveSubscriptionPerCustomer() {
        repository.saveAndFlush(Subscription.active(42L));

        assertThatThrownBy(() -> repository.saveAndFlush(Subscription.active(42L)))
                .isInstanceOf(DataIntegrityViolationException.class);
    }
}

What each piece does:

  • @Testcontainers finds the @Container fields. A static one is started once before the class's tests and stopped after them.
  • @DynamicPropertySource (Spring 5.2.5+) points the DataSource at the container's random port. It takes suppliers, so the values are read once the container is running.
  • @AutoConfigureTestDatabase(replace = NONE) matters for @DataJpaTest. By default it swaps your DataSource for an embedded one, and that fails once H2 is gone from the classpath.

Make it fast: one container per test run

A static @Container field is only shared by the methods of one class. Move it into a base class and every test class starts and stops its own container. It can also break things: Spring caches application contexts between test classes, so a later class can get a cached context whose DataSource still points at an earlier, already stopped container.

The Testcontainers docs describe a singleton pattern for this. Start the container in a static initializer and let it live as long as the JVM:

PostgresTestBase.java
public abstract class PostgresTestBase {

    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:13.4");

    static {
        POSTGRES.start();
    }

    @DynamicPropertySource
    static void datasourceProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);
    }
}

There is no @Testcontainers or @Container here, so nothing stops the container between classes. Testcontainers' Ryuk container removes it when the test JVM exits. Test classes extend the base, and those with the same configuration can share a cached Spring context as well.

Practical notes

  • Pin the image to the PostgreSQL version you run in production, never latest.
  • CI needs a Docker daemon. On GitLab CI that means the docker:dind service plus a couple of variables, and the Testcontainers docs have a ready-made .gitlab-ci.yml snippet.
  • Keep unit tests database-free. Containers are for the tests that are actually about SQL, mappings and constraints.

Takeaways

  • H2's PostgreSQL mode is a compatibility layer, not PostgreSQL.
  • Test the database features you rely on (JSONB, partial indexes, locking, migrations) against the real engine.
  • For a single test class, @Testcontainers, @Container and @DynamicPropertySource are all you need.
  • For a whole suite, use a singleton container in a base class and let Spring cache the context.