Testcontainers in Spring Boot: Real Database Integration Tests Without Mocks
In-memory databases and hand-rolled mocks give you fast tests, but they lie to you. H2 silently ignores PostgreSQL-specific SQL, and your mock UserRepository never tests the JPA query you spent an hour debugging. Testcontainers solves this by spinning up real Docker containers — PostgreSQL, Redis, Kafka, Elasticsearch — for the duration of your test suite and tearing them down afterward. This guide walks through the full Testcontainers setup for Spring Boot 3.x, from dependency configuration to parallel test execution in GitHub Actions CI.
Table of Contents
- Why Testcontainers Over H2 and Mocks
- Project Setup: Dependencies and Docker Requirements
- PostgreSQL Integration Tests with @DataJpaTest
- Singleton Container Pattern: Reusing Containers Across Tests
- Kafka Integration Testing with EmbeddedKafka vs Testcontainers
- Redis and Multi-Container Compose Tests
- Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers
- Running Testcontainers in GitHub Actions CI/CD
- Key Takeaways
1. Why Testcontainers Over H2 and Mocks
H2 covers basic CRUD but falls apart the moment you use a PostgreSQL-specific feature: JSONB columns, pg_trgm full-text indexes, advisory locks, or RETURNING clauses. Many teams only discover the mismatch during a production incident — not in CI. Similarly, mocking a JpaRepository method tests your test setup, not your query. A @Query annotation with a typo in the JPQL compiles fine and only fails at runtime.
Testcontainers wraps the Docker daemon via the Docker Java client. Each container defined in your test class starts before the test and stops after, leaving no state between runs. Since Spring Boot 3.1, the @ServiceConnection annotation auto-configures the DataSource, RedisConnectionFactory, or Kafka properties directly from the container — no manual property overrides required.
2. Project Setup: Dependencies and Docker Requirements
You need Docker running on the machine where tests execute — either your local workstation or the CI agent. Testcontainers communicates with Docker via the Unix socket or TCP, pulling images on demand and caching them locally. The Spring Boot parent BOM (3.1+) includes managed versions for all Testcontainers modules.
<!-- pom.xml -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.19.8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Core Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- PostgreSQL module -->
<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>
<!-- Spring Boot test integration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>3. PostgreSQL Integration Tests with @DataJpaTest
The @DataJpaTest slice loads only the JPA layer — entities, repositories, and data source configuration. By default it configures an in-memory H2 database. To replace H2 with a real PostgreSQL container, annotate the test class with @AutoConfigureTestDatabase(replace = NONE) and declare a PostgreSQLContainer bean with @ServiceConnection.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrderWithJsonbMetadata() {
Order order = new Order();
order.setStatus("PENDING");
order.setMetadata("{\"source\": \"web\", \"promoCode\": \"SAVE10\"}"); // JSONB
Order saved = orderRepository.save(order);
Optional<Order> found = orderRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getMetadata()).contains("promoCode");
}
@Test
void shouldFindOrdersByStatusUsingCustomQuery() {
orderRepository.saveAll(List.of(
new Order("PENDING", null),
new Order("SHIPPED", null),
new Order("PENDING", null)
));
List<Order> pending = orderRepository.findByStatus("PENDING");
assertThat(pending).hasSize(2);
}
}@ServiceConnection replaces the manual @DynamicPropertySource approach. It reads the container's mapped host/port and configures the Spring DataSource automatically. Requires Spring Boot 3.1+.
4. Singleton Container Pattern: Reusing Containers Across Tests
If every test class starts its own PostgreSQL container, your build spends minutes on container startup. The Singleton Container pattern declares the container as a static field in a shared base class, allowing JVM-scoped reuse across all test classes that extend it. Testcontainers' Ryuk side-container handles cleanup when the JVM exits.
// Shared base class — all integration tests extend this
public abstract class AbstractIntegrationTest {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // enable container reuse across runs
static {
POSTGRES.start();
}
}
// Test class — inherits the shared container
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest extends AbstractIntegrationTest {
@Autowired
ProductRepository productRepository;
@Test
void shouldCountProductsByCategory() {
// Container already running — no startup cost
productRepository.saveAll(List.of(
new Product("TV", "ELECTRONICS"),
new Product("Shirt", "CLOTHING"),
new Product("Phone", "ELECTRONICS")
));
assertThat(productRepository.countByCategory("ELECTRONICS")).isEqualTo(2);
}
}
Enable the container reuse feature by adding testcontainers.reuse.enable=true to ~/.testcontainers.properties on developer machines or setting it as an environment variable in CI. Without this property, withReuse(true) is silently ignored, and the container is still stopped after each test session — which is the safe default.
5. Kafka Integration Testing with EmbeddedKafka vs Testcontainers
Spring's @EmbeddedKafka is fast and requires no Docker, but it runs an older Kafka version and doesn't support all broker configurations. For production-fidelity, use KafkaContainer from Testcontainers, which runs the official Confluent Platform image.
@SpringBootTest
@Testcontainers
class OrderEventPublisherTest {
@Container
@ServiceConnection
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@Autowired
private OrderEventPublisher publisher;
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Test
void shouldPublishOrderCreatedEventToKafka() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
List<OrderEvent> received = new ArrayList<>();
// Consumer listening on the orders topic
kafkaTemplate.receive("orders", 0, 0, Duration.ofSeconds(5));
publisher.publishOrderCreated(new OrderCreatedEvent("order-123", "user-456"));
// Assert message arrived within 10 seconds
boolean messageReceived = latch.await(10, TimeUnit.SECONDS);
assertThat(messageReceived).isTrue();
assertThat(received).extracting(OrderEvent::getOrderId).contains("order-123");
}
}6. Redis and Multi-Container Compose Tests
Testcontainers provides a GenericContainer for Redis. For complex setups with multiple services, the DockerComposeContainer reads an existing docker-compose.yml file, which lets you reuse the same infrastructure definition for both local development and tests.
@SpringBootTest
@Testcontainers
class CacheServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379);
@Autowired
private ProductCacheService cacheService;
@Test
void shouldCacheProductLookup() {
cacheService.cacheProduct("prod-1", new Product("Laptop", 1299.00));
Optional<Product> cached = cacheService.getCachedProduct("prod-1");
assertThat(cached).isPresent();
assertThat(cached.get().getName()).isEqualTo("Laptop");
}
@Test
void shouldEvictCacheOnUpdate() {
cacheService.cacheProduct("prod-2", new Product("Mouse", 29.99));
cacheService.evict("prod-2");
Optional<Product> cached = cacheService.getCachedProduct("prod-2");
assertThat(cached).isEmpty();
}
}
// Multi-container with Docker Compose
@Testcontainers
class FullStackIntegrationTest {
@Container
static DockerComposeContainer<?> compose =
new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
.withExposedService("postgres_1", 5432, Wait.forListeningPort())
.withExposedService("redis_1", 6379, Wait.forListeningPort())
.withExposedService("kafka_1", 9092, Wait.forListeningPort());
}7. Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers
Spring Boot 3.1 introduced two major improvements: @ServiceConnection for zero-boilerplate property wiring, and @ImportTestcontainers for sharing container definitions across tests via a dedicated configuration class. Together they eliminate most of the repetitive infrastructure code.
// Centralized container configuration
public class TestContainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer("redis:7.2-alpine");
}
}
// Test class imports the shared config — no @Container declarations needed
@SpringBootTest
@ImportTestcontainers(TestContainersConfig.class)
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrderAndPublishEvent() {
OrderRequest req = new OrderRequest("user-1", List.of("sku-A", "sku-B"));
Order order = orderService.createOrder(req);
assertThat(order.getId()).isNotNull();
assertThat(order.getStatus()).isEqualTo("PENDING");
}
}8. Running Testcontainers in GitHub Actions CI/CD
GitHub Actions runners have Docker pre-installed. No special configuration is needed — Testcontainers detects the Docker socket automatically. For speed, use Maven's Surefire fork count to run test classes in parallel, and cache the Docker image layer in the Actions cache.
# .github/workflows/ci.yml
name: CI — Integration Tests
on: [push, pull_request]
jobs:
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Pull Docker images in parallel
run: |
docker pull postgres:16-alpine &
docker pull redis:7.2-alpine &
docker pull confluentinc/cp-kafka:7.6.1 &
wait
- name: Run integration tests
run: mvn verify -Pfailsafe -T 2
env:
TESTCONTAINERS_RYUK_DISABLED: false
For Ryuk (the container cleanup side-car), leave TESTCONTAINERS_RYUK_DISABLED as false. Ryuk ensures all containers are removed even if the JVM crashes, preventing orphaned containers that consume CI agent resources. If your CI environment does not allow privileged containers, set TESTCONTAINERS_RYUK_PRIVILEGED=false and use the unprivileged mode.
9. Key Takeaways
- Testcontainers eliminates H2/mock drift by testing against the same database engine as production.
- Use
@ServiceConnection(Spring Boot 3.1+) to remove boilerplate@DynamicPropertySourcewiring. - Apply the Singleton Container pattern via a shared base class to avoid per-class container startup cost.
- Enable
withReuse(true)andtestcontainers.reuse.enable=trueon developer machines for sub-second repeated test runs. - Use
@ImportTestcontainersto centralize container beans and share them across multiple test classes. - Pre-pull Docker images in CI with
docker pullbeforemvn verifyto reduce first-run latency. - Keep Ryuk enabled in CI to prevent orphaned containers draining runner resources.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices