Why this matters
- H2 in MySQL compatibility mode still diverges from PostgreSQL on JSON columns, locking, and sequence behaviour — bugs slip through slice tests and appear only in staging.
- Testcontainers starts disposable containers per test class or suite, giving you a faithful environment without maintaining shared test infrastructure.
- Combined with
@SpringBootTest, you verify the full request path: HTTP → service → repository → real database → response.
JUnit test
Kafka container
Assert events
Integration test building blocks
@SpringBootTest(webEnvironment = RANDOM_PORT)— boots the full application on a random port.@Testcontainers— JUnit 5 extension that manages container lifecycle.@Container+static— shared container across all tests in the class (faster startup).@DynamicPropertySource— injects container host/port into Spring's environment before context loads.TestRestTemplate/RestClient— send real HTTP requests to the running application.
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<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>
PostgreSQL integration test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class BookApiIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("bookstore_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureDatasource(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.flyway.enabled", () -> "true");
}
@Autowired
private TestRestTemplate restTemplate;
@Test
void createAndRetrieveBook_roundTrip() {
CreateBookRequest request = new CreateBookRequest(
"978-0134685991", "Effective Java", "Joshua Bloch", 45.99);
ResponseEntity<BookDetail> created = restTemplate.postForEntity(
"/api/books", request, BookDetail.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
ResponseEntity<BookDetail> fetched = restTemplate.getForEntity(
"/api/books/978-0134685991", BookDetail.class);
assertThat(fetched.getBody().title()).isEqualTo("Effective Java");
}
}
Redis cache integration test
@SpringBootTest
@Testcontainers
class BookCacheIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private BookService bookService;
@Autowired
private BookRepository bookRepository;
@Test
void cachedLookup_hitsRedisOnSecondCall() {
bookRepository.save(new Book("978-1", "Domain-Driven Design", "Eric Evans", 55.00));
BookDetail first = bookService.getByIsbn("978-1");
BookDetail second = bookService.getByIsbn("978-1");
assertThat(first).isEqualTo(second);
verify(bookRepository, times(1)).findByIsbn("978-1");
}
}
Kafka end-to-end test
@SpringBootTest
@Testcontainers
class OrderEventIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void configureKafka(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private OrderService orderService;
@Autowired
private Consumer<String, OrderPlacedMessage> testConsumer;
@Test
void placingOrder_publishesKafkaMessage() {
orderService.placeOrder(sampleRequest());
ConsumerRecords<String, OrderPlacedMessage> records =
testConsumer.poll(Duration.ofSeconds(5));
assertThat(records.count()).isGreaterThanOrEqualTo(1);
}
}
Quick recall
Everything you need if you only revisit this box.
@Testcontainers+@Container staticstarts real infrastructure in Docker.@DynamicPropertySourcewires container URLs into Spring before the context boots.@SpringBootTest(RANDOM_PORT)+TestRestTemplatetests the full HTTP stack.- Prefer Testcontainers over H2 when production uses PostgreSQL, Redis, or Kafka.
- Keep integration tests few and focused — slice tests cover most cases faster.
Test yourself
Answer these before moving on — recall is what makes it stick.