Spring Boot 3.2 + Testcontainers: Reliable Integration Testing with Real Dependencies
Without Testcontainers, your integration tests might pass locally with H2 but fail in CI when connecting to a mismatched PostgreSQL version. Production database migrations succeed in development but break when applied to a different database engine.
Prerequisites
- Java 17 or later
- Spring Boot 3.2.x
- Docker Engine 24.0+ with Docker Compose
- JUnit Jupiter 5.9+
- Testcontainers 1.19.3
Configuring Testcontainers for Database Testing
Testcontainers provides disposable database instances for integration tests. Add these dependencies to your pom.xml:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Disable the default datasource in test configuration:
# src/test/resources/application.properties
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.url=jdbc:tc:postgresql:15-alpine:///test?TC_DAEMON=true
spring.flyway.enabled=false
Writing a Containerized Integration Test
This test verifies database schema initialization using a real PostgreSQL instance. The container starts before tests and destroys itself afterward:
package com.example.orderservice;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void should_connect_to_live_database() {
Integer result = jdbcTemplate.queryForObject("SELECT 1", Integer.class);
assertThat(result).isEqualTo(1);
}
}
Run the test with Docker Desktop active. Testcontainers will download the PostgreSQL image on first execution and reuse it for subsequent runs.
Managing Container Lifecycles
Define a base test class to share containers across multiple test classes:
package com.example.shared;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
public abstract class BaseIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true);
@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);
}
}
Common Mistakes
Mistake 1: Non-static container field causing multiple instances
@Container // Missing static modifier
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>();
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>();
Testcontainers requires static fields for class-level containers. Non-static fields create new containers per test method.
Mistake 2: Hardcoded JDBC URL in test properties
spring.datasource.url=jdbc:postgresql://localhost:5432/test
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
}
Hardcoded URLs bypass container networking. Use @DynamicPropertySource to inject runtime connection details.
Mistake 3: Missing container reuse configuration
new PostgreSQLContainer<>("postgres:15-alpine");
new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true);
Without reuse, Testcontainers destroys containers after each test class. Enable reuse in .testcontainers.properties and chain withReuse(true).
Summary
- Replace in-memory databases with Testcontainers-managed PostgreSQL instances using
@Testcontainersand@Container - Inject dynamic connection properties using
@DynamicPropertySourceregistry - Enable container reuse with
.withReuse(true)andtestcontainers.reuse.enable=truein.testcontainers.properties
The author publishes Spring Boot starter templates at https://gumroad.com
Top comments (0)