Stop Mocking the Database: Spring Boot Tests with Testcontainers
- Published on
- Reading time
- 5 mins read
On this page
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=PostgreSQLemulates 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
JSONtype but nojsonb, and operators like->>and@>don't parse. A native query on ajsonbcolumn 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 lockedis a syntax error, andfor update nowaitis 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:
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:
@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:
@Testcontainersfinds the@Containerfields. Astaticone 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:
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:dindservice plus a couple of variables, and the Testcontainers docs have a ready-made.gitlab-ci.ymlsnippet. - 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,@Containerand@DynamicPropertySourceare all you need. - For a whole suite, use a singleton container in a base class and let Spring cache the context.