Testcontainers Spring Boot: bezbolesne testy integracyjne
Kompletny przewodnik po konfiguracji Testcontainers ze Spring Boot 3.4. PostgreSQL, Redis i Kafka w kontenerach Docker dla niezawodnych i powtarzalnych testów integracyjnych.

Testy integracyjne stanowią poważne wyzwanie w rozwoju aplikacji Spring Boot. Testowanie wobec rzeczywistej bazy PostgreSQL lub brokera Kafka wymaga utrzymywania ciężkiej infrastruktury. Testcontainers rozwiązuje ten problem, uruchamiając kontenery Docker na żądanie podczas testów, gwarantując izolowane i powtarzalne środowiska.
Spring Boot 3.4 zawiera natywne wsparcie Testcontainers z autokonfiguracją. Zależności spring-boot-testcontainers drastycznie upraszczają instalację i umożliwiają ponowne wykorzystanie kontenerów między testami.
Zrozumienie integracji między Testcontainers a Spring Boot
Testcontainers udostępnia API Java do uruchamiania kontenerów Docker podczas wykonywania testów. Zamiast mockować zewnętrzne zależności lub utrzymywać współdzielone testowe bazy danych, każde uruchomienie testów otrzymuje własną odizolowaną instancję.
Architektura opiera się na trzech głównych komponentach: bibliotece Testcontainers, która steruje Dockerem, wyspecjalizowanych modułach dla każdej technologii (PostgreSQL, Redis, Kafka) oraz integracji ze Spring Boot, która automatycznie wstrzykuje parametry połączenia.
<!-- pom.xml -->
<dependencies>
<!-- Main Testcontainers dependency -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- PostgreSQL module -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit 5 support -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<!-- Testcontainers BOM for version management -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>BOM Testcontainers zapewnia spójność wersji wszystkich modułów wykorzystywanych w projekcie.
Podstawowa konfiguracja z PostgreSQL
Najczęstszym przypadkiem użycia jest testowanie wobec rzeczywistej bazy PostgreSQL. Spring Boot 3.4 oferuje dwa podejścia: adnotację @ServiceConnection do autokonfiguracji lub konfigurację ręczną przez @DynamicPropertySource.
@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<User> 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<User> found = userRepository.findByEmail("search@example.com");
// Then: user is found
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Search User");
}
}Adnotacja @ServiceConnection automatycznie wykrywa typ kontenera i konfiguruje odpowiednie właściwości Springa (spring.datasource.url, spring.datasource.username itd.). Takie podejście eliminuje powtarzalny kod konfiguracyjny.
Z adnotacją @Container na polu statycznym kontener uruchamia się raz przed wszystkimi testami w klasie i zatrzymuje po ostatnim teście. Aby uzyskać kontener na test, należy użyć niestatycznego pola instancyjnego.
Ręczna konfiguracja z @DynamicPropertySource
Niektóre scenariusze wymagają precyzyjniejszej kontroli nad wstrzykiwanymi właściwościami. Adnotacja @DynamicPropertySource pozwala jawnie zdefiniować wartości konfiguracyjne.
@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);
}
}Skrypt inicjalizacyjny withInitScript przygotowuje schemat lub wstawia dane referencyjne przed wykonaniem testów.
Pełne testy integracyjne Spring Boot
Aby przetestować całą aplikację z załadowanymi wszystkimi komponentami, @SpringBootTest zastępuje @DataJpaTest. Ta konfiguracja uruchamia pełny kontekst Springa wraz z kontenerem PostgreSQL.
@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<UserResponse> 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<UserResponse> 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");
}
}Automatycznie skonfigurowany TestRestTemplate wskazuje na serwer uruchomiony na losowym porcie, co pozwala uniknąć konfliktów portów między równoległymi testami.
Gotowy na rozmowy o Spring Boot?
Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.
Ponowne wykorzystanie kontenerów między testami
Uruchomienie kontenera Docker zajmuje kilka sekund. Aby przyspieszyć wykonywanie testów, Spring Boot 3.4 umożliwia ponowne użycie kontenerów poprzez scentralizowaną konfigurację.
@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");
}
}Testy importują tę konfigurację, aby współdzielić te same kontenery.
@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);
}
}Aby włączyć ponowne wykorzystanie kontenerów, należy dodać konfigurację w ~/.testcontainers.properties:
# ~/.testcontainers.properties
testcontainers.reuse.enable=truePrzy ponownym użyciu kontenerów dane utrzymują się między uruchomieniami. Warto stosować @Sql lub @BeforeEach, aby czyścić tabele przed każdym testem, lub konfigurować inny schemat dla każdej klasy testowej.
Testy z Redis i rozproszonym cache
Testcontainers obsługuje Redis do testowania funkcjonalności pamięci podręcznej. Moduł Redis udostępnia preconfigurowany kontener gotowy do użycia.
@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 automatycznie wykrywa RedisContainer poprzez @ServiceConnection i konfiguruje spring.data.redis.host oraz spring.data.redis.port.
Testy z Kafką i komunikatami asynchronicznymi
Aplikacje sterowane zdarzeniami wymagają testów z rzeczywistym brokerem Kafka. Testcontainers udostępnia moduł Kafka, który uruchamia jednowęzłowy klaster odpowiedni do testów.
@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<String, OrderEvent> 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");
});
}
}@Component
public class OrderEventConsumer {
private final List<OrderEvent> receivedEvents = new CopyOnWriteArrayList<>();
@KafkaListener(topics = "order-events", groupId = "test-group")
public void consume(OrderEvent event) {
receivedEvents.add(event);
}
public List<OrderEvent> getReceivedEvents() {
return List.copyOf(receivedEvents);
}
public void clear() {
receivedEvents.clear();
}
}Biblioteka Awaitility obsługuje asercje asynchroniczne z timeoutem, eliminując kruche wywołania Thread.sleep w testach.
Konfiguracja wielokontenerowa z Docker Compose
Dla złożonych aplikacji wymagających wielu współzależnych usług Testcontainers obsługuje pliki Docker Compose.
# 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"@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);
}
}Zacznij ćwiczyć!
Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.
Dobre praktyki i optymalizacja wydajności
Wydajne uruchamianie Testcontainers wymaga kilku optymalizacji w celu skrócenia czasu budowania.
@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");
}
}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();
}
}Klasa abstrakcyjna centralizuje konfigurację kontenera oraz czyszczenie bazy danych, eliminując duplikację kodu.
# 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=2Warto preferować obrazy Alpine (postgres:16-alpine, redis:7-alpine), które są lżejsze i szybciej startują. W testach różnica funkcjonalna względem pełnych obrazów jest pomijalna.
Testy migracji bazy danych
Testcontainers świetnie nadaje się do testowania migracji Flyway lub Liquibase wobec rzeczywistej bazy danych.
@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<String> 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<Map<String, Object>> 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");
}
}Takie testy gwarantują, że migracje SQL działają poprawnie przed wdrożeniem na produkcję.
Podsumowanie
Testcontainers przekształca testy integracyjne Spring Boot, czyniąc je niezawodnymi, powtarzalnymi i niezależnymi od środowiska lokalnego. Natywne wsparcie Spring Boot 3.4 z @ServiceConnection znacząco upraszcza konfigurację, a ponowne wykorzystanie kontenerów optymalizuje czas wykonania.
Lista kontrolna Testcontainers Spring Boot:
- ✅ Używać
spring-boot-testcontainersdo natywnej autokonfiguracji - ✅ Preferować
@ServiceConnectionzamiast@DynamicPropertySource, gdy to możliwe - ✅ Włączać
withReuse(true), aby przyspieszyć kolejne uruchomienia - ✅ Centralizować konfigurację w klasie abstrakcyjnej lub
@TestConfiguration - ✅ Czyścić dane między testami za pomocą
@BeforeEachlub@Sql - ✅ Stosować obrazy Alpine dla szybszego startu
- ✅ Testować migracje Flyway/Liquibase wobec rzeczywistego PostgreSQL
- ✅ Wykorzystywać Docker Compose dla środowisk wieloserwisowych
Tagi
Udostępnij
Powiązane artykuły

Spring Modulith: Architektura Modularnego Monolitu Wyjaśniona
Naucz się Spring Modulith do budowy modularnych monolitów w Javie. Architektura, moduły, eventy asynchroniczne i testy z przykładami Spring Boot 3.

Rozmowa Spring Batch 5: Partycjonowanie, Chunki i Tolerancja Błędów
Opanuj rozmowy o pracę ze Spring Batch 5: 15 kluczowych pytań o partycjonowanie, przetwarzanie chunkowe i tolerancję błędów z przykładami w Java 21.

Rozmowa Spring Boot: Propagacja Transakcji
Opanuj propagację transakcji w Spring Boot: REQUIRED, REQUIRES_NEW, NESTED i więcej. 12 pytań rekrutacyjnych z kodem i typowymi pułapkami.