# Testcontainers Spring Boot: Painless Integration Testing > Complete guide to configuring Testcontainers with Spring Boot 3.4. PostgreSQL, Redis, Kafka in Docker containers for reliable, reproducible integration tests. - Published: 2026-03-19 - Updated: 2026-03-31 - Author: SharpSkill - Tags: testcontainers, spring boot, integration testing, docker, postgresql - Reading time: 14 min --- Integration testing presents a major challenge in Spring Boot application development. Testing against a real PostgreSQL database or Kafka broker requires heavy infrastructure to maintain. Testcontainers solves this problem by spinning up Docker containers on demand during tests, guaranteeing isolated and reproducible environments. > **Spring Boot 3.4 and Testcontainers** > > Spring Boot 3.4 includes native Testcontainers support with auto-configuration. The `spring-boot-testcontainers` dependencies drastically simplify setup and enable container reuse between tests. ## Understanding Testcontainers and Spring Boot Integration Testcontainers provides a Java API for starting Docker containers during test execution. Instead of mocking external dependencies or maintaining shared test databases, each test run gets its own isolated instance. The architecture relies on three main components: the Testcontainers library that drives Docker, specialized modules for each technology (PostgreSQL, Redis, Kafka), and Spring Boot integration that automatically injects connection parameters. ```xml org.springframework.boot spring-boot-testcontainers test org.testcontainers postgresql test org.testcontainers junit-jupiter test org.testcontainers testcontainers-bom 1.20.4 pom import ``` The Testcontainers BOM ensures version consistency across all modules used in the project. ## Basic Configuration with PostgreSQL The most common use case involves testing with a real PostgreSQL database. Spring Boot 3.4 offers two approaches: the `@ServiceConnection` annotation for auto-configuration, or manual configuration via `@DynamicPropertySource`. ```java // UserRepositoryIntegrationTest.java @DataJpaTest @Testcontainers @AutoConfigureTestDatabase(replace = Replace.NONE) class UserRepositoryIntegrationTest { // Starts a PostgreSQL container before tests @Container @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer<>( DockerImageName.parse("postgres:16-alpine") ); @Autowired private UserRepository userRepository; @Test void shouldSaveAndRetrieveUser() { // Given: a user to persist User user = new User(); user.setEmail("test@example.com"); user.setName("Test User"); // When: save and retrieve User saved = userRepository.save(user); Optional found = userRepository.findById(saved.getId()); // Then: user is correctly persisted assertThat(found).isPresent(); assertThat(found.get().getEmail()).isEqualTo("test@example.com"); } @Test void shouldFindUserByEmail() { // Given: a user in database User user = new User(); user.setEmail("search@example.com"); user.setName("Search User"); userRepository.save(user); // When: search by email Optional found = userRepository.findByEmail("search@example.com"); // Then: user is found assertThat(found).isPresent(); assertThat(found.get().getName()).isEqualTo("Search User"); } } ``` The `@ServiceConnection` annotation automatically detects the container type and configures the corresponding Spring properties (`spring.datasource.url`, `spring.datasource.username`, etc.). This approach eliminates boilerplate configuration code. > **Container Lifecycle** > > With `@Container` on a static field, the container starts once before all tests in the class and stops after the last test. For a container per test, use a non-static instance field. ## Manual Configuration with @DynamicPropertySource Some scenarios require finer control over injected properties. The `@DynamicPropertySource` annotation allows explicit definition of configuration values. ```java // OrderRepositoryIntegrationTest.java @DataJpaTest @Testcontainers @AutoConfigureTestDatabase(replace = Replace.NONE) class OrderRepositoryIntegrationTest { @Container static PostgreSQLContainer postgres = new PostgreSQLContainer<>( DockerImageName.parse("postgres:16-alpine") ) // Specific container configuration .withDatabaseName("orders_test") .withUsername("test_user") .withPassword("test_password") // SQL initialization script .withInitScript("db/init-orders.sql"); // Manual injection of dynamic properties @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { // JDBC URL generated dynamically with mapped port registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); // Additional properties if needed registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); } @Autowired private OrderRepository orderRepository; @Autowired private EntityManager entityManager; @Test void shouldPersistOrderWithItems() { // Given: an order with items Order order = new Order(); order.setOrderNumber("ORD-2026-001"); order.setStatus(OrderStatus.PENDING); OrderItem item = new OrderItem(); item.setProductId(1L); item.setQuantity(2); item.setUnitPrice(BigDecimal.valueOf(29.99)); order.addItem(item); // When: save the order Order saved = orderRepository.save(order); entityManager.flush(); entityManager.clear(); // Then: order and its items are persisted Order found = orderRepository.findById(saved.getId()).orElseThrow(); assertThat(found.getItems()).hasSize(1); assertThat(found.getItems().get(0).getQuantity()).isEqualTo(2); } } ``` The `withInitScript` initialization script prepares the schema or inserts reference data before test execution. ## Full Spring Boot Integration Tests To test the entire application with all components loaded, `@SpringBootTest` replaces `@DataJpaTest`. This configuration starts the complete Spring context with the PostgreSQL container. ```java // UserServiceIntegrationTest.java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @Testcontainers class UserServiceIntegrationTest { @Container @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer<>( DockerImageName.parse("postgres:16-alpine") ); @Autowired private UserService userService; @Autowired private TestRestTemplate restTemplate; @Test void shouldCreateUserViaApi() { // Given: a creation request CreateUserRequest request = new CreateUserRequest( "api@example.com", "API User", "securePassword123" ); // When: call the REST API ResponseEntity response = restTemplate.postForEntity( "/api/users", request, UserResponse.class ); // Then: user is created successfully assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().email()).isEqualTo("api@example.com"); } @Test void shouldRetrieveUserById() { // Given: an existing user UserResponse created = userService.createUser( new CreateUserRequest("retrieve@example.com", "Retrieve User", "password") ); // When: retrieve by ID ResponseEntity response = restTemplate.getForEntity( "/api/users/" + created.id(), UserResponse.class ); // Then: user is returned assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().name()).isEqualTo("Retrieve User"); } } ``` The automatically configured `TestRestTemplate` points to the server started on a random port, avoiding port conflicts between parallel tests. ## Container Reuse Between Tests Starting a Docker container takes several seconds. To speed up test execution, Spring Boot 3.4 allows container reuse through centralized configuration. ```java // TestcontainersConfiguration.java @TestConfiguration(proxyBeanMethods = false) public class TestcontainersConfiguration { // Reusable container bean across all tests @Bean @ServiceConnection PostgreSQLContainer postgresContainer() { return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")) .withReuse(true) .withLabel("reuse.UUID", "e06d7a87-7d7d-472e-a047-7c2c6d4b5f7a"); } @Bean @ServiceConnection RedisContainer redisContainer() { return new RedisContainer(DockerImageName.parse("redis:7-alpine")) .withReuse(true) .withLabel("reuse.UUID", "b3c8f9d2-4a5e-4c8d-9f2a-1b3c5d7e9f0a"); } } ``` Tests import this configuration to share the same containers. ```java // ProductServiceIntegrationTest.java @SpringBootTest @Import(TestcontainersConfiguration.class) class ProductServiceIntegrationTest { @Autowired private ProductService productService; @Autowired private ProductRepository productRepository; @Test void shouldCacheProductDetails() { // Given: a product in database Product product = new Product(); product.setName("Cached Product"); product.setPrice(BigDecimal.valueOf(99.99)); productRepository.save(product); // When: two successive calls ProductDto first = productService.getProductById(product.getId()); ProductDto second = productService.getProductById(product.getId()); // Then: second call uses cache assertThat(first).isEqualTo(second); } } ``` To enable container reuse, add the configuration in `~/.testcontainers.properties`: ```properties # ~/.testcontainers.properties testcontainers.reuse.enable=true ``` > **Data Cleanup** > > With container reuse, data persists between executions. Use `@Sql` or `@BeforeEach` to clean tables before each test, or configure a different schema per test class. ## Testing with Redis and Distributed Cache Testcontainers supports Redis for testing cache functionality. The Redis module provides a preconfigured container ready to use. ```java // CacheServiceIntegrationTest.java @SpringBootTest @Testcontainers class CacheServiceIntegrationTest { @Container @ServiceConnection static RedisContainer redis = new RedisContainer( DockerImageName.parse("redis:7-alpine") ); @Autowired private CacheService cacheService; @Autowired private StringRedisTemplate redisTemplate; @Test void shouldStoreAndRetrieveFromCache() { // Given: a value to cache String key = "user:123"; String value = "{\"id\":123,\"name\":\"Cached User\"}"; // When: store in cache cacheService.put(key, value, Duration.ofMinutes(10)); // Then: value is retrievable String cached = cacheService.get(key); assertThat(cached).isEqualTo(value); } @Test void shouldExpireAfterTtl() throws InterruptedException { // Given: a value with short TTL String key = "expiring:key"; cacheService.put(key, "temporary", Duration.ofSeconds(1)); // When: wait for expiration Thread.sleep(1500); // Then: key has expired String cached = cacheService.get(key); assertThat(cached).isNull(); } @Test void shouldIncrementCounter() { // Given: a counter key String counterKey = "page:views:homepage"; // When: multiple increments Long first = redisTemplate.opsForValue().increment(counterKey); Long second = redisTemplate.opsForValue().increment(counterKey); Long third = redisTemplate.opsForValue().increment(counterKey); // Then: counter increments correctly assertThat(first).isEqualTo(1); assertThat(second).isEqualTo(2); assertThat(third).isEqualTo(3); } } ``` Spring Boot automatically detects the `RedisContainer` through `@ServiceConnection` and configures `spring.data.redis.host` and `spring.data.redis.port`. ## Testing with Kafka and Asynchronous Messaging Event-driven applications require testing with a real Kafka broker. Testcontainers provides a Kafka module that starts a single-node cluster suitable for testing. ```java // OrderEventIntegrationTest.java @SpringBootTest @Testcontainers @EmbeddedKafka(partitions = 1, topics = {"order-events"}) class OrderEventIntegrationTest { @Container @ServiceConnection static KafkaContainer kafka = new KafkaContainer( DockerImageName.parse("confluentinc/cp-kafka:7.6.0") ); @Autowired private KafkaTemplate kafkaTemplate; @Autowired private OrderEventConsumer orderEventConsumer; @Test void shouldPublishAndConsumeOrderEvent() throws Exception { // Given: an order event OrderEvent event = new OrderEvent( "ORD-2026-100", OrderEventType.CREATED, LocalDateTime.now() ); // When: publish to Kafka kafkaTemplate.send("order-events", event.orderId(), event).get(); // Then: event is consumed (with timeout) await() .atMost(Duration.ofSeconds(10)) .untilAsserted(() -> { assertThat(orderEventConsumer.getReceivedEvents()) .hasSize(1) .first() .extracting(OrderEvent::orderId) .isEqualTo("ORD-2026-100"); }); } } ``` ```java // OrderEventConsumer.java @Component public class OrderEventConsumer { private final List receivedEvents = new CopyOnWriteArrayList<>(); @KafkaListener(topics = "order-events", groupId = "test-group") public void consume(OrderEvent event) { receivedEvents.add(event); } public List getReceivedEvents() { return List.copyOf(receivedEvents); } public void clear() { receivedEvents.clear(); } } ``` The Awaitility library handles asynchronous assertions with timeout, avoiding fragile `Thread.sleep` calls in tests. ## Multi-Container Configuration with Docker Compose For complex applications requiring multiple interdependent services, Testcontainers supports Docker Compose files. ```yaml # src/test/resources/docker-compose-test.yml services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: testdb POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass ports: - "5432" redis: image: redis:7-alpine ports: - "6379" localstack: image: localstack/localstack:3.0 environment: SERVICES: s3,sqs DEFAULT_REGION: eu-west-1 ports: - "4566" ``` ```java // FullStackIntegrationTest.java @SpringBootTest @Testcontainers class FullStackIntegrationTest { @Container static DockerComposeContainer environment = new DockerComposeContainer<>( new File("src/test/resources/docker-compose-test.yml") ) .withExposedService("postgres", 5432) .withExposedService("redis", 6379) .withExposedService("localstack", 4566) .waitingFor("postgres", Wait.forListeningPort()) .waitingFor("redis", Wait.forListeningPort()) .waitingFor("localstack", Wait.forLogMessage(".*Ready\\.$", 1)); @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { // PostgreSQL configuration String postgresHost = environment.getServiceHost("postgres", 5432); Integer postgresPort = environment.getServicePort("postgres", 5432); registry.add("spring.datasource.url", () -> "jdbc:postgresql://" + postgresHost + ":" + postgresPort + "/testdb"); registry.add("spring.datasource.username", () -> "testuser"); registry.add("spring.datasource.password", () -> "testpass"); // Redis configuration String redisHost = environment.getServiceHost("redis", 6379); Integer redisPort = environment.getServicePort("redis", 6379); registry.add("spring.data.redis.host", () -> redisHost); registry.add("spring.data.redis.port", () -> redisPort); // LocalStack S3 configuration String localstackHost = environment.getServiceHost("localstack", 4566); Integer localstackPort = environment.getServicePort("localstack", 4566); registry.add("aws.s3.endpoint", () -> "http://" + localstackHost + ":" + localstackPort); } @Autowired private FileStorageService fileStorageService; @Test void shouldUploadFileToS3() { // Given: a file to upload byte[] content = "Test file content".getBytes(); String fileName = "test-file.txt"; // When: upload to S3 via LocalStack String url = fileStorageService.upload(fileName, content); // Then: file is accessible assertThat(url).contains(fileName); byte[] downloaded = fileStorageService.download(fileName); assertThat(downloaded).isEqualTo(content); } } ``` ## Best Practices and Performance Optimization Efficient Testcontainers execution requires some optimizations to reduce build times. ```java // AbstractIntegrationTest.java @SpringBootTest @Testcontainers @ActiveProfiles("test") public abstract class AbstractIntegrationTest { // Shared container across all inheriting classes @Container @ServiceConnection protected static PostgreSQLContainer postgres = new PostgreSQLContainer<>( DockerImageName.parse("postgres:16-alpine") ) .withReuse(true); @Autowired protected JdbcTemplate jdbcTemplate; @BeforeEach void cleanDatabase() { // Clean tables in order to respect FK constraints jdbcTemplate.execute("TRUNCATE TABLE order_items CASCADE"); jdbcTemplate.execute("TRUNCATE TABLE orders CASCADE"); jdbcTemplate.execute("TRUNCATE TABLE users CASCADE"); } } ``` ```java // UserIntegrationTest.java class UserIntegrationTest extends AbstractIntegrationTest { @Autowired private UserRepository userRepository; @Test void shouldCreateUser() { // PostgreSQL container already started via parent class User user = new User(); user.setEmail("inherited@test.com"); user.setName("Inherited Test"); User saved = userRepository.save(user); assertThat(saved.getId()).isNotNull(); } } ``` The abstract class centralizes container configuration and database cleanup, avoiding code duplication. ```properties # src/test/resources/application-test.properties # Test-specific configuration spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false # Disable Flyway/Liquibase if using ddl-auto spring.flyway.enabled=false # Reduced connection pool for tests spring.datasource.hikari.maximum-pool-size=5 spring.datasource.hikari.minimum-idle=2 ``` > **Lightweight Docker Images** > > Prefer Alpine images (`postgres:16-alpine`, `redis:7-alpine`) which are lighter and start faster. For testing, the feature difference from full images is negligible. ## Database Migration Testing Testcontainers excels at testing Flyway or Liquibase migrations against a real database. ```java // FlywayMigrationTest.java @DataJpaTest @Testcontainers @AutoConfigureTestDatabase(replace = Replace.NONE) class FlywayMigrationTest { @Container @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer<>( DockerImageName.parse("postgres:16-alpine") ); @Autowired private Flyway flyway; @Autowired private JdbcTemplate jdbcTemplate; @Test void shouldApplyAllMigrations() { // Given: migrations applied at startup // When: check state MigrationInfoService info = flyway.info(); // Then: all migrations are applied assertThat(info.pending()).isEmpty(); assertThat(info.applied()).isNotEmpty(); } @Test void shouldCreateExpectedTables() { // Given: migrations applied // When: query system tables List tables = jdbcTemplate.queryForList( "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'public' AND table_type = 'BASE TABLE'", String.class ); // Then: expected tables exist assertThat(tables).contains("users", "orders", "order_items", "products"); } @Test void shouldHaveCorrectColumnTypes() { // Given: users table created // When: verify schema List> columns = jdbcTemplate.queryForList( "SELECT column_name, data_type, is_nullable " + "FROM information_schema.columns " + "WHERE table_name = 'users'" ); // Then: columns have correct types assertThat(columns) .extracting(c -> c.get("column_name")) .contains("id", "email", "name", "created_at"); } } ``` These tests ensure SQL migrations work correctly before production deployment. ## Conclusion Testcontainers transforms Spring Boot integration testing by making tests reliable, reproducible, and independent of the local environment. Spring Boot 3.4's native support with `@ServiceConnection` significantly simplifies configuration, while container reuse optimizes execution times. **Testcontainers Spring Boot Checklist:** - ✅ Use `spring-boot-testcontainers` for native auto-configuration - ✅ Prefer `@ServiceConnection` over `@DynamicPropertySource` when possible - ✅ Enable `withReuse(true)` to speed up successive executions - ✅ Centralize configuration in an abstract class or `@TestConfiguration` - ✅ Clean data between tests with `@BeforeEach` or `@Sql` - ✅ Use Alpine images for faster startups - ✅ Test Flyway/Liquibase migrations against real PostgreSQL - ✅ Leverage Docker Compose for multi-service environments --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/spring-boot/testcontainers-spring-boot-integration-testing