Integration Testing
Integration Testing
Overview
Integration testing verifies that multiple components, modules, or systems work correctly together. Unlike unit tests that isolate individual classes, integration tests validate interactions with real dependencies like databases, message queues, HTTP APIs, and external services. These tests catch issues that unit tests miss: database constraints, transaction boundaries, serialization problems, network failures, and misconfigured integrations.
Modern integration testing leverages TestContainers to run ephemeral Docker containers for dependencies, ensuring tests use production-like infrastructure without complex local setup. Spring Boot Test provides comprehensive support for integration testing with auto-configuration, test slicing, and embedded servers. Well-designed integration tests balance realistic scenarios with reasonable execution time (5-10 seconds per test).
Key Concepts
- Real Dependencies - Use actual databases, message brokers, and services instead of mocks when testing integration
- TestContainers - Lightweight, disposable Docker containers for dependencies (PostgreSQL, Redis, Kafka, etc.)
- Spring Boot Test Slices - Focused test configurations (
@WebMvcTest,@DataJpaTest,@RestClientTest) - Test Isolation - Each test gets a clean database state; tests don’t affect each other
- Transaction Rollback - Spring’s
@Transactionalon test classes rolls back changes after each test - Database Migrations - Run Flyway/Liquibase migrations in tests to match production schema
- API Contract Testing - Verify REST API behavior, status codes, request/response formats
- Component Boundaries - Test integration points between your code and external systems
- Performance Expectations - Integration tests should complete in 5-10s; optimize with connection pooling
- Failure Scenarios - Test error cases: constraint violations, timeouts, connection failures
Best Practices
- Use TestContainers for Infrastructure - Run real databases/services in Docker; avoid H2/embedded substitutes
- Test Against Production Database - Use the same database engine in tests as production (PostgreSQL, MySQL, etc.)
- Run Database Migrations in Tests - Execute Flyway/Liquibase to ensure schema consistency
- Isolate Tests with Transactions - Use
@Transactionalor manual cleanup between tests - Test Database Constraints - Verify unique constraints, foreign keys, and cascading deletes work correctly
- Use Realistic Test Data - Create data that resembles production scenarios, not minimal fixtures
- Test Error Scenarios - Verify exception handling for constraint violations, timeouts, and connection failures
- Limit External API Calls - Mock external services; use WireMock or Mockoon for HTTP dependencies
- Parallelize Integration Tests - Configure TestContainers for parallel execution to reduce suite time
- Monitor Test Execution Time - Keep integration tests under 10 seconds; optimize slow queries
Code Examples
✅ TestContainers with PostgreSQL
@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@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);
}
@Autowired
private OrderRepository orderRepository;
@Autowired
private CustomerRepository customerRepository;
@Test
@Transactional
void shouldPersistOrderWithItems() {
// Given
var customer = customerRepository.save(
new Customer("john@example.com", "John Doe")
);
var order = new Order();
order.setCustomer(customer);
order.addItem(new OrderItem("PROD-1", 2, 10.0));
order.addItem(new OrderItem("PROD-2", 1, 25.0));
// When
var saved = orderRepository.save(order);
entityManager.flush();
entityManager.clear();
// Then
var retrieved = orderRepository.findById(saved.getId());
assertThat(retrieved).isPresent()
.hasValueSatisfying(o -> {
assertThat(o.getCustomer().getEmail()).isEqualTo("john@example.com");
assertThat(o.getItems()).hasSize(2);
assertThat(o.getTotalAmount()).isEqualTo(45.0);
});
}
@Test
@Transactional
void shouldEnforceCustomerForeignKeyConstraint() {
// Given
var order = new Order();
order.setCustomerId(999L); // Non-existent customer
// When/Then
assertThatThrownBy(() -> {
orderRepository.save(order);
entityManager.flush();
}).isInstanceOf(DataIntegrityViolationException.class)
.hasMessageContaining("foreign key constraint");
}
}
✅ Spring Boot Test Slices
// @DataJpaTest - Lightweight JPA repository testing
@DataJpaTest
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindUserByEmail() {
// Given
var user = new User("alice@example.com", "Alice");
entityManager.persist(user);
entityManager.flush();
// When
var found = userRepository.findByEmail("alice@example.com");
// Then
assertThat(found).isPresent()
.hasValueSatisfying(u -> assertThat(u.getName()).isEqualTo("Alice"));
}
}
// @WebMvcTest - REST controller testing without full context
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldCreateOrder() throws Exception {
// Given
var request = new CreateOrderRequest("CUST-123", List.of(
new OrderItemRequest("PROD-1", 2)
));
var order = new Order("ORDER-123", "CUST-123", OrderStatus.PENDING);
when(orderService.createOrder(any())).thenReturn(order);
// When/Then
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.orderId").value("ORDER-123"))
.andExpect(jsonPath("$.status").value("PENDING"));
}
}
✅ Full Integration Test with Multiple Services
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
);
@DynamicPropertySource
static void properties(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.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Value("${spring.kafka.topic.order-events}")
private String orderEventsTopic;
@Test
void shouldCreateOrderAndPublishEvent() throws Exception {
// Given
var request = new CreateOrderRequest("CUST-123", List.of(
new OrderItemRequest("PROD-1", 2, 10.0)
));
var eventCaptor = new ArrayBlockingQueue<OrderEvent>(1);
var consumer = createKafkaConsumer();
consumer.subscribe(Collections.singletonList(orderEventsTopic));
// When
var order = orderService.createOrder(request);
// Then - Order persisted
var savedOrder = orderRepository.findById(order.getId());
assertThat(savedOrder).isPresent()
.hasValueSatisfying(o -> {
assertThat(o.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(o.getItems()).hasSize(1);
});
// Then - Event published to Kafka
var records = consumer.poll(Duration.ofSeconds(5));
assertThat(records).hasSize(1);
var event = records.iterator().next().value();
assertThat(event.getOrderId()).isEqualTo(order.getId());
assertThat(event.getEventType()).isEqualTo("ORDER_CREATED");
}
}
✅ Database Migration Testing
@SpringBootTest
@Testcontainers
class FlywayMigrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void properties(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.clean-disabled", () -> false);
}
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldApplyAllMigrations() {
// When
flyway.migrate();
// Then
var info = flyway.info();
assertThat(info.all()).isNotEmpty();
assertThat(info.pending()).isEmpty();
// Verify schema structure
var tables = jdbcTemplate.queryForList(
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = 'public'",
String.class
);
assertThat(tables).contains("orders", "order_items", "customers");
}
@Test
void shouldHandleRepeatablyMigrations() {
// Clean and re-run migrations
flyway.clean();
flyway.migrate();
var firstCount = flyway.info().applied().length;
flyway.migrate(); // Run again
var secondCount = flyway.info().applied().length;
assertThat(firstCount).isEqualTo(secondCount);
}
}
❌ Using H2 Instead of Real Database
// ❌ BAD: Testing with H2 when production uses PostgreSQL
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
class OrderRepositoryTest {
// H2 has different SQL dialect, constraint behavior, and features
// Tests may pass with H2 but fail in production with PostgreSQL
}
// ✅ GOOD: Use TestContainers with production database
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
// Tests against actual PostgreSQL behavior
}
❌ Mocking Database Interactions
// ❌ BAD: Mocking JPA repository in "integration" test
@SpringBootTest
class OrderServiceTest {
@MockBean
private OrderRepository orderRepository;
@Test
void shouldSaveOrder() {
when(orderRepository.save(any())).thenReturn(new Order());
// This is a unit test, not an integration test
// Doesn't verify actual database interactions
}
}
// ✅ GOOD: Use real repository with TestContainers
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldSaveOrder() {
// Test actual database persistence
var order = orderService.createOrder(request);
assertThat(orderRepository.findById(order.getId())).isPresent();
}
}
Anti-Patterns
- Using H2/In-Memory Databases - Testing with different database than production; misses dialect-specific issues
- Mocking Everything - Calling it “integration test” but mocking all dependencies; it’s actually a unit test
- Shared Test State - Tests affecting each other due to lack of cleanup/transactions
- Skipping Database Migrations - Not running Flyway/Liquibase in tests; schema diverges from production
- Ignoring Constraints - Not testing unique constraints, foreign keys, or cascading deletes
- Manual Docker Setup - Requiring developers to manually start Docker containers instead of using TestContainers
- Slow Integration Tests - Tests taking 30+ seconds due to unnecessary full context loads
- No Error Scenario Testing - Only testing happy paths; ignoring constraint violations and timeouts
Testing Strategies
TestContainers Singleton Pattern
// Reuse containers across test classes for faster execution
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static final KafkaContainer KAFKA;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true);
POSTGRES.start();
KAFKA = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"))
.withReuse(true);
KAFKA.start();
}
@DynamicPropertySource
static void properties(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.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
}
}
// Test classes extend the base
@SpringBootTest
class OrderServiceIntegrationTest extends AbstractIntegrationTest {
// Automatically gets shared containers
}
Test Data Builders
// Create reusable test data builders
class TestDataBuilder {
public static Customer.CustomerBuilder aCustomer() {
return Customer.builder()
.email("test@example.com")
.name("Test Customer")
.status(CustomerStatus.ACTIVE);
}
public static Order.OrderBuilder anOrder() {
return Order.builder()
.customerId("CUST-123")
.status(OrderStatus.PENDING)
.createdAt(Instant.now());
}
public static OrderItem.OrderItemBuilder anOrderItem() {
return OrderItem.builder()
.productId("PROD-1")
.quantity(1)
.price(10.0);
}
}
// Usage in tests
@Test
void shouldProcessOrder() {
var customer = customerRepository.save(
aCustomer()
.email("john@example.com")
.name("John Doe")
.build()
);
var order = orderRepository.save(
anOrder()
.customerId(customer.getId())
.items(List.of(anOrderItem().quantity(2).build()))
.build()
);
orderService.processOrder(order.getId());
}
Parallel Test Execution
<!-- Maven Surefire parallel execution -->
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>classes</parallel>
<threadCount>4</threadCount>
<perCoreThreadCount>true</perCoreThreadCount>
</configuration>
</plugin>
References
- TestContainers Documentation
- Spring Boot Testing Guide
- Spring Boot Test Slices
- Integration Testing with Docker
- JUnit 5 User Guide
Related Skills
- testing-pyramid.md - Test distribution and pyramid strategy
- test-data-management.md - Test data builders and fixture management
- test-automation.md - CI/CD integration and parallel execution
- e2e-testing.md - End-to-end testing strategies
- testing-strategies.md - Overall testing strategy and approach