Testing Spring Boot Applications With Testcontainers — Real Databases, Real Brokers, Real Tests
by Eric Hanson, Backend Developer at Clean Systems Consulting
Why H2 isn't enough
H2 is convenient — no Docker, no external dependencies, instant startup. It's also a different database than PostgreSQL. The differences that cause production failures after passing H2 tests:
- H2 doesn't support
tsvector,jsonb,LATERALjoins, window functions, advisory locks,ON CONFLICT DO UPDATE, orRETURNINGclauses in the same form as PostgreSQL - PostgreSQL's transaction isolation semantics differ from H2's
- Indexes defined in Flyway migrations work differently — H2 may not enforce the same constraint behavior
- PostgreSQL-specific query plans behave differently from H2's query optimizer
Testcontainers starts a real PostgreSQL (or MySQL, or any database) in Docker for the duration of the test suite. The test runs against the same database engine as production. What passes in CI passes in production.
Setup
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Spring Boot 3.1+ provides a TestcontainersPropertySourceFactoryBean that integrates Testcontainers with Spring's property system. Spring Boot 3.4+ supports @ServiceConnection for zero-configuration Testcontainers wiring.
PostgreSQL with @DataJpaTest
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // reuse across test classes — faster CI
@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 OrderRepository orderRepository;
@Autowired TestEntityManager entityManager;
@Test
void findByStatusWithFullTextSearch_returnsMatchingOrders() {
// Uses PostgreSQL tsvector — would fail with H2
User user = entityManager.persistAndFlush(new User("alice@example.com"));
Order order = entityManager.persistAndFlush(
new Order(user, OrderStatus.PENDING, "special delivery instructions"));
entityManager.clear();
List<Order> results = orderRepository.searchByNotes("delivery");
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(order.getId());
}
@Test
void upsertOrder_handlesConflictCorrectly() {
// Tests ON CONFLICT DO UPDATE — PostgreSQL-specific
orderRepository.upsertOrder("ref-123", OrderStatus.PENDING, Money.of(100, "USD"));
orderRepository.upsertOrder("ref-123", OrderStatus.PROCESSING, Money.of(100, "USD"));
List<Order> orders = orderRepository.findByReference("ref-123");
assertThat(orders).hasSize(1);
assertThat(orders.get(0).getStatus()).isEqualTo(OrderStatus.PROCESSING);
}
}
@AutoConfigureTestDatabase(replace = NONE) prevents Spring Boot from substituting an embedded database. @DynamicPropertySource registers the container's connection details before the Spring context starts — ensuring Flyway migrations and Hibernate initialization use the container.
withReuse(true) keeps the container running between test classes and even between test runs. The container is identified by its configuration hash — if the configuration hasn't changed, the existing container is reused. This dramatically reduces test suite time when multiple test classes use PostgreSQL.
To enable reuse globally:
# ~/.testcontainers.properties (in home directory, not project)
testcontainers.reuse.enable=true
Shared container across the test suite
Starting a new container per test class adds 3–10 seconds per class. For a test suite with 20 JPA test classes, that's 60–200 seconds of container startup time. Share a single container:
// Base class — shared container
public abstract class PostgresIntegrationTest {
static final PostgreSQLContainer<?> postgres;
static {
postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
postgres.start();
}
@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);
}
}
// Test classes extend the base
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest extends PostgresIntegrationTest {
// Container is shared — no individual startup cost
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest extends PostgresIntegrationTest {
// Same container, same startup
}
The static initializer starts the container once per JVM. All test classes that extend PostgresIntegrationTest use it. JVM shutdown stops the container automatically via the Ryuk container that Testcontainers manages.
Spring Boot 3.1+ @ServiceConnection
Spring Boot 3.1 introduced @ServiceConnection — automatic property registration from a container:
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7-alpine"));
// No @DynamicPropertySource needed — @ServiceConnection handles it
@Autowired OrderService orderService;
@Test
void processOrder_persistsAndCachesResult() {
Order order = orderService.processOrder(new CreateOrderRequest(...));
assertThat(orderRepository.findById(order.getId())).isPresent();
assertThat(redisTemplate.hasKey("order:" + order.getId())).isTrue();
}
}
@ServiceConnection inspects the container type and automatically registers the appropriate Spring Boot properties. PostgreSQLContainer registers spring.datasource.*; RedisContainer registers spring.data.redis.*. No @DynamicPropertySource boilerplate.
Kafka with Testcontainers
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class OrderKafkaIntegrationTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
@Autowired OrderKafkaPublisher publisher;
@Autowired KafkaTemplate<String, Object> kafkaTemplate;
@Test
void publishOrderPlaced_consumedByInventoryConsumer() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<OrderPlacedEvent> receivedEvent = new AtomicReference<>();
// Register a test consumer
@KafkaListener(topics = "orders.placed", groupId = "test-consumer")
class TestConsumer {
public void consume(OrderPlacedEvent event) {
receivedEvent.set(event);
latch.countDown();
}
}
publisher.publishOrderPlaced(testOrder());
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(receivedEvent.get().orderId()).isEqualTo(testOrder().getId());
}
}
For testing Kafka consumers without a producer, use KafkaTestUtils:
@Test
void inventoryConsumer_reservesInventoryOnOrderPlaced() throws Exception {
String topic = "orders.placed";
OrderPlacedEvent event = new OrderPlacedEvent("ord-1", "user-1",
List.of(new OrderItem("product-1", 2)), Money.of(100, "USD"));
// Produce a test message directly
ProducerFactory<String, Object> producerFactory = new DefaultKafkaProducerFactory<>(
KafkaTestUtils.producerProps(kafka.getBootstrapServers()));
KafkaTemplate<String, Object> template = new KafkaTemplate<>(producerFactory);
template.send(topic, event.orderId(), event).get(5, TimeUnit.SECONDS);
// Wait for consumer to process
Awaitility.await()
.atMost(Duration.ofSeconds(10))
.pollInterval(Duration.ofMillis(500))
.until(() -> inventoryRepository.existsByOrderId("ord-1"));
InventoryReservation reservation = inventoryRepository.findByOrderId("ord-1");
assertThat(reservation.getProductId()).isEqualTo("product-1");
assertThat(reservation.getQuantity()).isEqualTo(2);
}
Awaitility polls a condition until it's true or a timeout expires — the right tool for asynchronous assertion in consumer tests. Avoid Thread.sleep() for consumer tests; the timing is non-deterministic and either too slow (wasting CI time) or too fast (flaky tests).
RabbitMQ with Testcontainers
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>rabbitmq</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class OrderRabbitMQIntegrationTest {
@Container
static RabbitMQContainer rabbitmq = new RabbitMQContainer(
DockerImageName.parse("rabbitmq:3.12-management-alpine"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.rabbitmq.host", rabbitmq::getHost);
registry.add("spring.rabbitmq.port", rabbitmq::getAmqpPort);
registry.add("spring.rabbitmq.username", () -> "guest");
registry.add("spring.rabbitmq.password", () -> "guest");
}
@Autowired OrderEventPublisher publisher;
@Autowired RabbitTemplate rabbitTemplate;
@Test
void publishedMessage_isConsumedAndProcessed() {
Order order = testOrder();
publisher.publishOrderPlaced(order);
Awaitility.await()
.atMost(Duration.ofSeconds(10))
.until(() -> shippingRepository.existsByOrderId(order.getId()));
ShipmentRecord record = shippingRepository.findByOrderId(order.getId());
assertThat(record.getStatus()).isEqualTo(ShipmentStatus.SCHEDULED);
}
}
Redis with Testcontainers
@SpringBootTest
@Testcontainers
class CacheIntegrationTest {
@Container
@ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>(
DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired ProductService productService;
@Autowired StringRedisTemplate redisTemplate;
@Test
void getProduct_cachesMissOnFirstCall_hitOnSecond() {
// First call — cache miss, loads from DB
Product first = productService.getProduct(1L);
// Verify cached
assertThat(redisTemplate.hasKey("products::1")).isTrue();
// Second call — cache hit (verify by checking query count or mock behavior)
Product second = productService.getProduct(1L);
assertThat(second).isEqualTo(first);
}
@Test
void updateProduct_evictsCache() {
productService.getProduct(1L); // populate cache
assertThat(redisTemplate.hasKey("products::1")).isTrue();
productService.updateProduct(1L, new UpdateProductRequest("new name", 2000L));
assertThat(redisTemplate.hasKey("products::1")).isFalse();
}
}
Container lifecycle and CI performance
Container startup time adds up. The strategies for keeping CI fast:
Use Alpine images. postgres:16-alpine is ~250MB; postgres:16 is ~400MB. Smaller images pull faster and start faster in CI environments where the image isn't cached.
Reuse containers. withReuse(true) and testcontainers.reuse.enable=true keep containers alive between test runs. Requires cleaning data between tests (transactions, truncation, or randomized test data) but eliminates per-class startup.
Share containers within a suite. The static base class pattern starts one container per JVM run. For a CI run with 50 test classes, the difference between one startup and 50 startups is significant.
Parallelize at the suite level, not at the container level. Run test suites in parallel across CI workers; each worker shares one container. Don't start multiple containers within a single test run for performance reasons — the overhead of coordination outweighs the parallelism benefit for typical test suites.
Cache Docker images in CI. Configure your CI pipeline to cache the Docker layer cache between runs. docker pull postgres:16-alpine on every CI run adds 30–60 seconds. A cached layer cache reduces this to near zero.
The complete picture: Testcontainers with shared containers, Alpine images, and CI layer caching adds 15–30 seconds to a test suite that previously used H2 — and catches entire categories of production bugs that H2 silently passes.