
Introduction
Testing real application behavior is difficult when relying on mocks or in-memory databases. Although unit tests are useful, they don’t reflect real-world scenarios — H2 database behaves differently than PostgreSQL, and mocks don’t catch integration bugs. Testcontainers solves this by running real databases and services inside lightweight Docker containers during your tests.
In this comprehensive guide, you’ll learn how Testcontainers works, how to integrate it with Spring Boot 3, how to test against PostgreSQL, Redis, and Kafka, and how to optimize your test suite for CI/CD pipelines.
What Is Testcontainers?
Testcontainers is a Java library that provides throwaway Docker containers for testing. Instead of using mocks or embedded databases, your tests can spin up real services such as PostgreSQL, Redis, Kafka, MongoDB, or even complete microservices.
Key Benefits
- Runs tests against real dependencies with identical behavior to production
- Ensures stable, repeatable integration tests across all environments
- Cleans up containers automatically after tests complete
- Works with JUnit 5 (Jupiter) and JUnit 4
- Native Spring Boot 3.1+ support with
@ServiceConnection - Perfect for microservices and CI/CD pipelines
Because Testcontainers uses Docker, tests run the same way on every machine, reducing “it works on my machine” problems.
Project Setup
<!-- pom.xml -->
<properties>
<java.version>17</java.version>
<testcontainers.version>1.19.3</testcontainers.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.redis.testcontainers</groupId>
<artifactId>testcontainers-redis-junit</artifactId>
<version>1.6.4</version>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Testing with PostgreSQL
Entity and Repository
// entity/User.java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
// Constructors, getters, setters
}
// repository/UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByNameContainingIgnoreCase(String name);
@Query("SELECT u FROM User u WHERE u.createdAt > :since")
List<User> findUsersCreatedAfter(@Param("since") LocalDateTime since);
}
Basic Integration Test
// test/UserRepositoryTest.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
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;
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Container
@ServiceConnection // Spring Boot 3.1+ automatic configuration
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndFindUser() {
// Given
User user = new User();
user.setEmail("alice@example.com");
user.setName("Alice Smith");
// When
User saved = userRepository.save(user);
// Then
assertThat(saved.getId()).isNotNull();
Optional<User> found = userRepository.findByEmail("alice@example.com");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Alice Smith");
}
@Test
void shouldFindUsersByName() {
// Given
userRepository.save(createUser("john@example.com", "John Doe"));
userRepository.save(createUser("jane@example.com", "Jane Doe"));
userRepository.save(createUser("bob@example.com", "Bob Smith"));
// When
List<User> doeUsers = userRepository.findByNameContainingIgnoreCase("doe");
// Then
assertThat(doeUsers).hasSize(2);
assertThat(doeUsers).extracting(User::getName)
.containsExactlyInAnyOrder("John Doe", "Jane Doe");
}
@Test
void shouldFindUsersCreatedAfterDate() {
// Given
LocalDateTime yesterday = LocalDateTime.now().minusDays(1);
userRepository.save(createUser("new@example.com", "New User"));
// When
List<User> recentUsers = userRepository.findUsersCreatedAfter(yesterday);
// Then
assertThat(recentUsers).isNotEmpty();
}
private User createUser(String email, String name) {
User user = new User();
user.setEmail(email);
user.setName(name);
return user;
}
}
Legacy Approach with DynamicPropertySource
// For Spring Boot < 3.1 or when you need custom configuration
@SpringBootTest
@Testcontainers
class UserRepositoryLegacyTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withInitScript("db/init.sql"); // Optional init script
@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);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
}
// Tests...
}
Testing with Redis
// service/CacheService.java
@Service
public class CacheService {
private final StringRedisTemplate redisTemplate;
public CacheService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void put(String key, String value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}
public String get(String key) {
return redisTemplate.opsForValue().get(key);
}
public void delete(String key) {
redisTemplate.delete(key);
}
public void incrementCounter(String key) {
redisTemplate.opsForValue().increment(key);
}
public Long getCounter(String key) {
String value = redisTemplate.opsForValue().get(key);
return value != null ? Long.parseLong(value) : 0L;
}
}
// test/CacheServiceTest.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Testcontainers
class CacheServiceTest {
@Container
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private CacheService cacheService;
@Test
void shouldStoreAndRetrieveValue() {
// Given
String key = "test:key";
String value = "Hello, Redis!";
// When
cacheService.put(key, value, Duration.ofMinutes(5));
// Then
assertThat(cacheService.get(key)).isEqualTo(value);
}
@Test
void shouldDeleteValue() {
// Given
String key = "test:delete";
cacheService.put(key, "to-delete", Duration.ofMinutes(5));
// When
cacheService.delete(key);
// Then
assertThat(cacheService.get(key)).isNull();
}
@Test
void shouldIncrementCounter() {
// Given
String key = "test:counter";
// When
cacheService.incrementCounter(key);
cacheService.incrementCounter(key);
cacheService.incrementCounter(key);
// Then
assertThat(cacheService.getCounter(key)).isEqualTo(3L);
}
@Test
void shouldExpireValue() throws InterruptedException {
// Given
String key = "test:expire";
cacheService.put(key, "temporary", Duration.ofSeconds(1));
// When
Thread.sleep(1500); // Wait for expiration
// Then
assertThat(cacheService.get(key)).isNull();
}
}
Testing with Kafka
// producer/OrderEventProducer.java
@Service
public class OrderEventProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
public OrderEventProducer(KafkaTemplate<String, String> kafkaTemplate,
ObjectMapper objectMapper) {
this.kafkaTemplate = kafkaTemplate;
this.objectMapper = objectMapper;
}
public void sendOrderCreated(OrderEvent event) {
try {
String json = objectMapper.writeValueAsString(event);
kafkaTemplate.send("orders.created", event.orderId(), json);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize event", e);
}
}
}
// consumer/OrderEventConsumer.java
@Service
public class OrderEventConsumer {
private final List<OrderEvent> receivedEvents = new CopyOnWriteArrayList<>();
private final ObjectMapper objectMapper;
public OrderEventConsumer(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@KafkaListener(topics = "orders.created", groupId = "order-processor")
public void handleOrderCreated(String message) {
try {
OrderEvent event = objectMapper.readValue(message, OrderEvent.class);
receivedEvents.add(event);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize event", e);
}
}
public List<OrderEvent> getReceivedEvents() {
return new ArrayList<>(receivedEvents);
}
public void clear() {
receivedEvents.clear();
}
}
// dto/OrderEvent.java
public record OrderEvent(
String orderId,
String customerId,
BigDecimal amount,
LocalDateTime timestamp
) {}
// test/KafkaIntegrationTest.java
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.test.utils.KafkaTestUtils;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@SpringBootTest
@Testcontainers
class KafkaIntegrationTest {
@Container
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
registry.add("spring.kafka.consumer.auto-offset-reset", () -> "earliest");
registry.add("spring.kafka.consumer.group-id", () -> "test-group");
}
@Autowired
private OrderEventProducer producer;
@Autowired
private OrderEventConsumer consumer;
@BeforeEach
void setUp() {
consumer.clear();
}
@Test
void shouldProduceAndConsumeOrderEvent() {
// Given
OrderEvent event = new OrderEvent(
"order-123",
"customer-456",
new BigDecimal("99.99"),
LocalDateTime.now()
);
// When
producer.sendOrderCreated(event);
// Then - wait for async consumption
await()
.atMost(10, TimeUnit.SECONDS)
.untilAsserted(() -> {
assertThat(consumer.getReceivedEvents()).hasSize(1);
OrderEvent received = consumer.getReceivedEvents().get(0);
assertThat(received.orderId()).isEqualTo("order-123");
assertThat(received.customerId()).isEqualTo("customer-456");
assertThat(received.amount()).isEqualByComparingTo(new BigDecimal("99.99"));
});
}
@Test
void shouldHandleMultipleEvents() {
// Given
List<OrderEvent> events = List.of(
new OrderEvent("order-1", "cust-1", new BigDecimal("10.00"), LocalDateTime.now()),
new OrderEvent("order-2", "cust-2", new BigDecimal("20.00"), LocalDateTime.now()),
new OrderEvent("order-3", "cust-3", new BigDecimal("30.00"), LocalDateTime.now())
);
// When
events.forEach(producer::sendOrderCreated);
// Then
await()
.atMost(10, TimeUnit.SECONDS)
.untilAsserted(() -> {
assertThat(consumer.getReceivedEvents()).hasSize(3);
});
}
}
Shared Container Configuration
For faster test execution, share containers across test classes:
// test/config/TestContainersConfig.java
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
public class TestContainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // Reuse container across test runs
}
@Bean
GenericContainer<?> redisContainer() {
return new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
.withReuse(true);
}
}
// test/AbstractIntegrationTest.java
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@Import(TestContainersConfig.class)
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
// Shared test configuration
}
// test/UserServiceIntegrationTest.java
class UserServiceIntegrationTest extends AbstractIntegrationTest {
@Autowired
private UserService userService;
@Test
void shouldCreateUser() {
// Test using shared containers
}
}
Testing REST API with WebTestClient
// test/UserControllerIntegrationTest.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class UserControllerIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateUser() throws Exception {
String userJson = """
{
"email": "test@example.com",
"name": "Test User"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(userJson))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.email").value("test@example.com"))
.andExpect(jsonPath("$.name").value("Test User"));
}
@Test
void shouldGetUserById() throws Exception {
// First create a user
String createResponse = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"get@example.com\",\"name\":\"Get User\"}"))
.andReturn().getResponse().getContentAsString();
Long userId = JsonPath.parse(createResponse).read("$.id", Long.class);
// Then get the user
mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("get@example.com"));
}
@Test
void shouldReturn404ForNonExistentUser() throws Exception {
mockMvc.perform(get("/api/users/{id}", 99999))
.andExpect(status().isNotFound());
}
}
CI/CD Configuration
# .github/workflows/test.yml
name: Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: Run tests
run: mvn verify -Dspring.profiles.active=test
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: target/surefire-reports/
Common Mistakes to Avoid
1. Not Waiting for Container Startup
// BAD: No startup wait
@Container
static GenericContainer<?> app = new GenericContainer<>("myapp:latest")
.withExposedPorts(8080);
// GOOD: Wait for readiness
@Container
static GenericContainer<?> app = new GenericContainer<>("myapp:latest")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
2. Using Latest Tag
// BAD: Unpredictable behavior
new PostgreSQLContainer<>("postgres:latest")
// GOOD: Pinned version
new PostgreSQLContainer<>("postgres:16-alpine")
3. Not Cleaning Test Data
// BAD: Tests depend on each other
@Test
void test1() {
userRepository.save(user);
}
@Test
void test2() {
// Might see data from test1!
}
// GOOD: Clean up between tests
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
// Or use @Transactional for automatic rollback
@SpringBootTest
@Transactional
class UserServiceTest {}
4. Creating New Containers Per Test
// BAD: Slow - new container for each test method
@Test
void test1() {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(...)) {
postgres.start();
// Test
}
}
// GOOD: Shared static container
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(...);
5. Hardcoded Ports
// BAD: Port conflicts
registry.add("spring.data.redis.port", () -> 6379);
// GOOD: Dynamic port mapping
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
Performance Optimization Tips
- Use Alpine images — smaller download size and faster startup
- Enable container reuse — add
.withReuse(true)for local development - Share containers across test classes using configuration beans
- Use parallel test execution — each test class gets isolated containers
- Cache Docker layers in CI by preserving Docker daemon state
- Pull images in CI setup step to avoid test timeouts
Final Thoughts
Testcontainers brings realism to your Spring Boot testing strategy by running real services inside disposable Docker containers. It eliminates mock-heavy tests, simplifies setup, and ensures your integration tests behave consistently across environments.
Start with a simple database test using @ServiceConnection, then expand into caching with Redis, messaging with Kafka, or AWS services with LocalStack. The investment in proper integration tests pays off with fewer production bugs and higher confidence in deployments.
For more Spring Cloud patterns, read Service Discovery with Spring Cloud Eureka. For API gateway configuration, see API Routing with Spring Cloud Gateway. For official documentation, visit the Testcontainers website and Spring Boot Testcontainers documentation.