H2 in-memory databases have one job: make your tests pass. They do that job, and then they lie to you about your actual database. Dialect differences, missing PostgreSQL features, JSON column behavior that doesn’t match production—by the time you notice the gap, you’re debugging a production incident instead of catching it in CI.
Testcontainers fixes this by spinning up real Docker containers during your tests. You get an actual PostgreSQL instance, actual Redis, actual Kafka. The tests take longer to start, but they stop lying. This guide covers how to set it up properly in Spring Boot, including the improvements in 3.1+ that make the configuration significantly cleaner.
Why H2 Falls Short
The appeal of H2 is obvious: zero setup, fast startup, no Docker required. But the problems compound as your application grows.
PostgreSQL-specific features break immediately. If you’re using jsonb columns, array_agg, full-text search, or any custom functions, H2 either doesn’t support them or emulates them poorly. Your migrations run differently because Flyway or Liquibase applies database-specific SQL. Constraint behavior diverges—H2’s handling of deferred constraints, partial indexes, and certain foreign key scenarios doesn’t match PostgreSQL.
The result is a test suite that green-lights code that will fail against the real database. I’ve seen this pattern cause production bugs more times than I can count.
Testcontainers sidesteps the problem entirely: your test gets a real PostgreSQL container, runs against it, and tears it down. The only prerequisite is Docker on the test machine (or CI runner).
Dependency Setup
Add the Testcontainers BOM to manage versions, then include the modules you need.
Maven:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.19.7</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Core -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit 5 integration -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Database modules -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- Kafka module -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Gradle:
dependencies {
testImplementation platform('org.testcontainers:testcontainers-bom:1.19.7')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:kafka'
}
Spring Boot 3.1+ also ships its own spring-boot-testcontainers module—more on that below.
The Basic Pattern: @Testcontainers and @Container
The JUnit 5 integration gives you two annotations that handle container lifecycle automatically.
@Testcontainers activates the extension. @Container marks fields that Testcontainers should manage—start before tests, stop after.
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-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 UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
User user = new User("alice@example.com", "Alice");
userRepository.save(user);
Optional<User> found = userRepository.findByEmail("alice@example.com");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Alice");
}
}
A few things worth noting here:
- The container is
staticso it’s shared across all test methods in the class, not restarted for each test. @DynamicPropertySourceinjects the container’s runtime connection details into the Spring context before it starts up.postgres:16-alpineis explicit about the version—usinglatestis a reliability hazard in CI.
The @DynamicPropertySource approach was the standard way to wire container properties into Spring Boot before 3.1. It works, but it’s boilerplate you have to repeat for every container. Spring Boot 3.1 fixed this.
Spring Boot 3.1+: @ServiceConnection
@ServiceConnection is the cleanest improvement to Testcontainers support in recent Spring Boot history. Instead of manually wiring JDBC URLs and credentials via @DynamicPropertySource, the annotation does it automatically for recognized container types.
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
User user = new User("alice@example.com", "Alice");
userRepository.save(user);
assertThat(userRepository.findByEmail("alice@example.com")).isPresent();
}
}
That’s it. Spring Boot detects the PostgreSQLContainer type, knows it maps to spring.datasource.*, and configures it automatically. No @DynamicPropertySource, no string property names to get wrong.
This works out of the box for PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Kafka, RabbitMQ, and several others. You need the spring-boot-testcontainers dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
Testing Redis
Redis tests follow the same pattern. Use GenericContainer if you want minimal dependencies, or the dedicated RedisContainer:
@SpringBootTest
@Testcontainers
class CacheServiceTest {
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer(
DockerImageName.parse("redis:7-alpine"));
@Autowired
private CacheService cacheService;
@Test
void shouldCacheAndRetrieveValue() {
cacheService.put("session:123", "user-data");
String cached = cacheService.get("session:123");
assertThat(cached).isEqualTo("user-data");
}
@Test
void shouldExpireEntries() throws InterruptedException {
cacheService.putWithTtl("temp:key", "value", Duration.ofSeconds(1));
Thread.sleep(1500);
assertThat(cacheService.get("temp:key")).isNull();
}
}
For the RedisContainer you need org.testcontainers:testcontainers (it’s in the core module, not a separate artifact). @ServiceConnection maps it to spring.data.redis.host and spring.data.redis.port automatically.
Testing Kafka
Kafka tests catch an entire class of problems that unit tests miss: message serialization, partition assignment, consumer group behavior, dead-letter topic routing. Running them against a real broker is worth the overhead.
@SpringBootTest
@Testcontainers
class OrderEventConsumerTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Autowired
private OrderEventConsumer consumer;
@Test
void shouldProcessOrderCreatedEvent() throws Exception {
OrderEvent event = new OrderEvent("order-42", OrderStatus.CREATED);
kafkaTemplate.send("orders", event.orderId(), event).get(5, TimeUnit.SECONDS);
// Give consumer time to process
await().atMost(10, TimeUnit.SECONDS)
.untilAsserted(() ->
assertThat(consumer.getProcessedOrders()).contains("order-42"));
}
}
The await() call is from Awaitility (org.awaitility:awaitility)—essential for async assertions. Don’t use Thread.sleep here; it makes tests flaky. Awaitility polls with a timeout and gives you a clean assertion failure if the condition isn’t met in time.
@ServiceConnection for Kafka sets spring.kafka.bootstrap-servers automatically from the container’s exposed port.
Sharing Containers Across Tests
Starting a new PostgreSQL container for each test class takes 2-4 seconds per container. On a test suite with 20 integration test classes, that’s a minute of startup time before a test runs. Two patterns address this.
Static containers per class (already shown above): declare the container static so it starts once per class. This is the minimum optimization.
Shared base class: extract the container declaration to a base class that all integration tests extend:
@SpringBootTest
@Testcontainers
public abstract class IntegrationTestBase {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer(
DockerImageName.parse("redis:7-alpine"));
}
class UserRepositoryTest extends IntegrationTestBase {
// postgres and redis containers are started once, shared across all subclasses
}
class OrderServiceTest extends IntegrationTestBase {
// same containers, already running
}
Because the containers are static on the base class, JUnit 5 keeps them alive for the JVM session. Spring Boot’s @SpringBootTest application context caching then reuses the same context across test classes that have identical configuration, so you pay the container startup cost once per JVM run.
Container reuse across JVM restarts (.withReuse(true)):
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
With reuse enabled, Testcontainers keeps the container running between test runs and reattaches to it on subsequent runs. This is useful during local development when you’re running tests repeatedly. Enable it in ~/.testcontainers.properties:
testcontainers.reuse.enable=true
Don’t rely on reuse in CI—ephemeral CI environments won’t have a running container to reattach to.
Common Pitfalls
Docker not available. Testcontainers fails immediately if Docker isn’t running or the socket isn’t accessible. In CI, make sure the job runner has Docker available. GitHub Actions works out of the box; some self-hosted runners need explicit Docker socket configuration. The error message is usually clear: Could not find a valid Docker environment.
Slow container startup on first pull. The first test run on a fresh machine pulls the Docker image, which can take 30-60 seconds depending on image size. CI should cache Docker images between runs. For GitHub Actions, use docker/setup-buildx-action or pre-pull images in a setup step.
Port conflicts. Testcontainers maps container ports to random available host ports by default, so port conflicts are rare. They become a problem if you’re using fixed port binding (withFixedExposedPort). Don’t use fixed ports unless you have a specific reason—random ports are safer and the @ServiceConnection / @DynamicPropertySource patterns handle them correctly.
Test pollution between methods. Containers are shared, so data written in one test is visible to the next. Use @Transactional on test classes that modify data (Spring rolls back after each test), or clean up explicitly with @BeforeEach. For Kafka tests, use unique topic names per test or consumer groups that won’t conflict.
Spring context reuse breaking with Testcontainers. If you have multiple test classes with different container configurations, Spring creates separate application contexts for each. This can multiply container startup time unexpectedly. Keep your @SpringBootTest configuration consistent across test classes—same properties, same context configuration—so Spring can cache and reuse the context.
Using latest image tags. Don’t. A CI run that fails because Postgres 17 shipped a behavior change you didn’t account for is a frustrating debugging session. Pin versions: postgres:16.3-alpine, redis:7.2-alpine.
Putting It Together
A realistic base configuration for a Spring Boot application with Postgres and Redis:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class IntegrationTestBase {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withInitScript("schema.sql"); // optional: run DDL before Spring starts
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer(
DockerImageName.parse("redis:7.2-alpine"));
@Autowired
protected TestRestTemplate restTemplate;
}
class UserApiTest extends IntegrationTestBase {
@Test
void shouldCreateUser() {
var request = new CreateUserRequest("bob@example.com", "Bob");
var response = restTemplate.postForEntity("/api/v1/users", request, UserResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().email()).isEqualTo("bob@example.com");
}
}
WebEnvironment.RANDOM_PORT starts a full embedded server on a random port, letting you test the full HTTP stack including filters, interceptors, and exception handlers. Combined with real infrastructure containers, these tests catch integration problems that no amount of mocking can surface.
The startup overhead is real—expect 5-10 seconds for the first test class in a suite. The tradeoff is tests you can trust. After a few production incidents traced back to H2 dialect differences, I’ve stopped treating that startup time as a cost and started treating it as insurance.
Related Articles
- Spring Boot REST API Best Practices for Production — The API design patterns that integration tests should be verifying.
- Spring Boot Caching with Redis — Testcontainers makes it easy to spin up a real Redis instance for cache integration tests.
- Spring Boot Security Best Practices — Test your security filters and JWT validation with full-stack Testcontainers integration tests.
- Providing a GraphQL Endpoint in Spring Boot — Integration-test your GraphQL resolvers against a real database.