Integration Testing
Integration tests validate that different components of your application work correctly together with real dependencies like databases, message queues, and external services.
Overview
Integration tests form the core of our testing strategy (Testing Honeycomb model). Unlike unit tests that mock dependencies, integration tests use real instances of databases, caches, and other infrastructure components to ensure the application works correctly in realistic scenarios.
The shift to integration-first testing became practical with TestContainers, which provides lightweight, disposable Docker containers for infrastructure dependencies. Before TestContainers, integration tests required manual database setup, were slow to execute, and were difficult to run in CI. Now, integration tests run nearly as fast as unit tests (2-5 minutes vs 30-60 seconds) while catching far more real bugs.
In modern microservices applications, most bugs occur at integration boundaries - where your code interacts with databases, message queues, or external services. Testing with real dependencies catches issues that mocks cannot detect:
- Database constraints: Unique constraints, foreign keys, check constraints only exist in real databases
- Transaction boundaries: Testing actual transaction rollback, isolation levels, deadlock scenarios
- Serialization errors: JSON serialization, date/time formatting, enum mappings fail with real data
- Query performance: N+1 queries, missing indexes, inefficient joins surface under realistic data volumes
- Connection pooling: Connection leaks, pool exhaustion, timeout configurations
Unit tests with mocks verify your business logic works in isolation. Integration tests verify it works with real infrastructure. Both are necessary, but integration tests catch more production bugs because production systems involve infrastructure interactions, not just isolated logic.
Applies to: Spring Boot · Angular · React · React Native · Android · iOS
Integration testing principles apply across all platforms, adapted to each platform's architecture and testing tools.
Core Principles
- Real Dependencies: Use actual databases, message queues, and caches via TestContainers
- Fast Enough: Integration tests should complete in seconds, not minutes
- Isolated: Each test runs in isolation with clean state
- Realistic Data: Use data that mirrors production scenarios
- Transaction Management: Test actual transaction boundaries and rollback behavior
- Database Constraints: Verify foreign keys, unique constraints, and indexes
TestContainers for Java/Spring Boot
Overview
TestContainers provides lightweight, disposable Docker containers for integration testing. It automatically manages container lifecycle - starting containers before tests, exposing ports for connectivity, and cleaning up after tests complete. This eliminates the "works on my machine" problem by ensuring every developer and CI environment tests against identical infrastructure.
How it works: TestContainers uses Docker to spin up real databases, message queues, or other services as containers. When your test class starts, TestContainers:
- Pulls the Docker image (e.g.,
postgres:16-alpine) if not cached - Starts a container with a random available port
- Waits for the container to be healthy (ready to accept connections)
- Provides connection details (JDBC URL, ports) to your Spring Boot application via dynamic properties
- Runs your tests against the real, containerized database
- Stops and removes the container when tests finish
This happens automatically - you just annotate your test class and TestContainers handles everything. The first run downloads images (1-2 minutes), but subsequent runs use cached images and start in seconds.
Dependencies
Add TestContainers dependencies for each infrastructure component you need to test. The core library (testcontainers) provides Docker container lifecycle management, JUnit integration (junit-jupiter) enables annotations for test classes, and specific modules (postgresql, kafka, redis) provide pre-configured containers for popular services.
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
testImplementation 'org.testcontainers:junit-jupiter' // JUnit 5 integration
testImplementation 'org.testcontainers:postgresql' // PostgreSQL containers
testImplementation 'org.testcontainers:kafka' // Kafka containers
testImplementation 'org.testcontainers:redis' // Redis containers
}
PostgreSQL Integration Test
Spring Boot 3.1+ supports @ServiceConnection, which automatically wires container connection details without manual property configuration. Use @DynamicPropertySource only when @ServiceConnection isn't available for a given container type.
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;
@SpringBootTest
@Testcontainers
class PaymentIntegrationTest {
@Container
@ServiceConnection // Automatically configures spring.datasource.* properties
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private PaymentService paymentService;
@Autowired
private PaymentRepository paymentRepository;
@Test
void shouldProcessPaymentAndPersistToDatabase() {
// Arrange
PaymentRequest request = PaymentTestBuilder.aPayment()
.withAmount(new BigDecimal("100.00"))
.withCurrency("USD")
.withRecipient("John Doe")
.build();
// Act
PaymentResponse response = paymentService.processPayment(request);
// Assert: Verify response
assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
assertThat(response.getTransactionId()).isNotNull();
// Assert: Verify persistence
Optional<Payment> savedPayment = paymentRepository.findById(response.getTransactionId());
assertThat(savedPayment).isPresent();
assertThat(savedPayment.get().getAmount()).isEqualByComparingTo(new BigDecimal("100.00"));
assertThat(savedPayment.get().getAuditLog()).isNotEmpty();
}
@Test
void shouldRollbackTransactionOnFailure() {
// Arrange
PaymentRequest request = PaymentTestBuilder.aPayment()
.withAmount(new BigDecimal("100.00"))
.build();
// Force a failure in the transaction
when(auditService.logPayment(any())).thenThrow(new RuntimeException("Audit failed"));
// Act & Assert
assertThatThrownBy(() -> paymentService.processPayment(request))
.isInstanceOf(RuntimeException.class);
// Assert: Verify rollback - payment should not be in database
long count = paymentRepository.count();
assertThat(count).isZero();
}
@Test
void shouldEnforceUniqueConstraint() {
// Arrange
Payment payment1 = createPayment("TXN-123", new BigDecimal("100.00"));
Payment payment2 = createPayment("TXN-123", new BigDecimal("200.00"));
// Act
paymentRepository.save(payment1);
// Assert: Duplicate transaction ID should fail
assertThatThrownBy(() -> paymentRepository.save(payment2))
.isInstanceOf(DataIntegrityViolationException.class)
.hasMessageContaining("unique constraint");
}
}
Shared Container Configuration
For faster test execution, reuse containers across multiple tests:
// Base test class
@SpringBootTest
@Testcontainers
public abstract class BaseIntegrationTest {
// Shared container - starts once for all tests
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // Reuse container across test runs
@Autowired
protected PaymentRepository paymentRepository;
@Autowired
protected AuditLogRepository auditLogRepository;
@BeforeEach
void cleanDatabase() {
// Clean tables before each test for isolation
auditLogRepository.deleteAll();
paymentRepository.deleteAll();
}
}
// Individual test classes extend base
class PaymentIntegrationTest extends BaseIntegrationTest {
@Autowired
private PaymentService paymentService;
@Test
void shouldProcessPayment() {
// Test implementation
}
}
Multiple Containers
@SpringBootTest
@Testcontainers
class PaymentWithCacheIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Test
void shouldCachePaymentLookup() {
// First call: hits database
Payment payment1 = paymentService.getPayment(paymentId);
// Second call: hits cache
Payment payment2 = paymentService.getPayment(paymentId);
assertThat(payment1).isEqualTo(payment2);
// Verify cache hit using metrics or logs
}
}
REST API Integration Testing
Testing Controllers with MockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class PaymentControllerIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldCreatePaymentViaRestAPI() throws Exception {
// Arrange
PaymentRequest request = PaymentTestBuilder.aPayment()
.withAmount(new BigDecimal("100.00"))
.withCurrency("USD")
.build();
String requestJson = objectMapper.writeValueAsString(request);
// Act & Assert
mockMvc.perform(post("/api/payments")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("COMPLETED"))
.andExpect(jsonPath("$.transactionId").exists())
.andExpect(jsonPath("$.amount").value(100.00))
.andExpect(header().exists("Location"));
}
@Test
void shouldReturnBadRequestForInvalidPayment() throws Exception {
// Arrange
PaymentRequest request = PaymentTestBuilder.aPayment()
.withAmount(new BigDecimal("-100.00")) // Invalid amount
.build();
String requestJson = objectMapper.writeValueAsString(request);
// Act & Assert
mockMvc.perform(post("/api/payments")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("amount"))
.andExpect(jsonPath("$.errors[0].message").value("Amount must be positive"));
}
@Test
void shouldRetrievePaymentById() throws Exception {
// Arrange: Create payment first
Payment payment = createAndSavePayment();
// Act & Assert
mockMvc.perform(get("/api/payments/{id}", payment.getId())
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(payment.getId().toString()))
.andExpect(jsonPath("$.amount").value(payment.getAmount().doubleValue()))
.andExpect(jsonPath("$.status").value(payment.getStatus().toString()));
}
@Test
void shouldReturn404WhenPaymentNotFound() throws Exception {
UUID nonExistentId = UUID.randomUUID();
mockMvc.perform(get("/api/payments/{id}", nonExistentId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("Payment not found"));
}
}
Testing with RestTemplate (End-to-End HTTP)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class PaymentE2ETest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void shouldCreateAndRetrievePayment() {
// Create payment
PaymentRequest request = PaymentTestBuilder.aPayment().build();
ResponseEntity<PaymentResponse> createResponse = restTemplate.postForEntity(
"/api/payments",
request,
PaymentResponse.class
);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
UUID paymentId = createResponse.getBody().getTransactionId();
// Retrieve payment
ResponseEntity<PaymentResponse> getResponse = restTemplate.getForEntity(
"/api/payments/{id}",
PaymentResponse.class,
paymentId
);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().getTransactionId()).isEqualTo(paymentId);
assertThat(getResponse.getBody().getAmount()).isEqualByComparingTo(request.getAmount());
}
}
Database Integration Testing
Testing JPA Repositories
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
@DataJpaTest
@Testcontainers
class PaymentRepositoryIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private TestEntityManager entityManager;
@Autowired
private PaymentRepository paymentRepository;
@Test
void shouldFindPaymentsByStatus() {
// Arrange
Payment completed1 = createPayment(PaymentStatus.COMPLETED);
Payment completed2 = createPayment(PaymentStatus.COMPLETED);
Payment pending = createPayment(PaymentStatus.PENDING);
entityManager.persist(completed1);
entityManager.persist(completed2);
entityManager.persist(pending);
entityManager.flush();
// Act
List<Payment> completedPayments = paymentRepository.findByStatus(PaymentStatus.COMPLETED);
// Assert
assertThat(completedPayments).hasSize(2);
assertThat(completedPayments).extracting(Payment::getStatus)
.containsOnly(PaymentStatus.COMPLETED);
}
@Test
void shouldExecuteCustomQuery() {
// Arrange
Payment payment = createPayment(new BigDecimal("1000.00"));
entityManager.persist(payment);
entityManager.flush();
// Act
List<Payment> highValuePayments = paymentRepository.findHighValuePayments(
new BigDecimal("500.00")
);
// Assert
assertThat(highValuePayments).hasSize(1);
assertThat(highValuePayments.get(0).getAmount())
.isGreaterThanOrEqualTo(new BigDecimal("500.00"));
}
@Test
void shouldCascadeDeleteAuditLogs() {
// Arrange
Payment payment = createPayment();
AuditLog auditLog = createAuditLog(payment);
payment.addAuditLog(auditLog);
entityManager.persist(payment);
entityManager.flush();
Long paymentId = payment.getId();
entityManager.clear(); // Clear persistence context
// Act
paymentRepository.deleteById(paymentId);
entityManager.flush();
// Assert
assertThat(paymentRepository.findById(paymentId)).isEmpty();
// Verify cascade delete worked
assertThat(entityManager.find(AuditLog.class, auditLog.getId())).isNull();
}
}
Testing Database Migrations (Flyway)
@SpringBootTest
@Testcontainers
class FlywayMigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldApplyAllMigrations() {
// Verify Flyway migrations applied
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().toString())
.isEqualTo("3"); // Latest version
// Verify schema exists
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'payment'",
Integer.class
);
assertThat(count).isEqualTo(1);
}
@Test
void shouldHaveCorrectIndexes() {
// Verify performance indexes exist
List<String> indexes = jdbcTemplate.queryForList(
"SELECT indexname FROM pg_indexes WHERE tablename = 'payment'",
String.class
);
assertThat(indexes).contains(
"idx_payment_status",
"idx_payment_created_at",
"idx_payment_transaction_id"
);
}
}
Message Queue Integration Testing
Kafka with TestContainers
@SpringBootTest
@Testcontainers
class PaymentEventIntegrationTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.7.0")
);
@Autowired
private KafkaTemplate<String, PaymentEvent> kafkaTemplate;
@Autowired
private PaymentEventConsumer consumer;
@Test
void shouldPublishAndConsumePaymentEvent() throws Exception {
// Arrange
PaymentEvent event = new PaymentEvent(
UUID.randomUUID(),
new BigDecimal("100.00"),
PaymentStatus.COMPLETED
);
// Act
kafkaTemplate.send("payment-events", event.getPaymentId().toString(), event);
// Assert: Wait for consumer to process
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> {
assertThat(consumer.getProcessedEvents()).contains(event);
});
}
}
Performance in Integration Tests
Optimize Test Execution Time
// Use @DirtiesContext sparingly (rebuilds Spring context)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) // Only if necessary
// Prefer database cleanup over context reload
@BeforeEach
void cleanDatabase() {
paymentRepository.deleteAll(); // Fast cleanup
}
// Use test slices for faster tests
@DataJpaTest // Only loads JPA components, not full Spring context
@WebMvcTest(PaymentController.class) // Only loads web layer
// Reuse containers
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
Parallel Test Execution
// build.gradle
test {
maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
// Use JUnit parallel execution
systemProperty 'junit.jupiter.execution.parallel.enabled', 'true'
systemProperty 'junit.jupiter.execution.parallel.mode.default', 'concurrent'
}
Further Reading
- Testing Strategy - Overall testing approach and test pyramid
- Unit Testing - Unit testing patterns and isolation techniques
- Contract Testing - API contract validation between services
- E2E Testing - Full stack end-to-end testing
- Spring Boot Testing - Spring Boot specific testing strategies
External Resources:
- TestContainers Documentation - Lightweight Docker containers for testing
- Spring Boot Testing Guide - Official Spring Boot testing guide
Summary
Key Takeaways:
- TestContainers: Use real databases and infrastructure in tests via Docker containers
- Shared Containers: Reuse containers across tests for faster execution
- Database Cleanup: Clean state between tests for isolation
- API Testing: Use MockMvc or RestTemplate to test REST endpoints
- Transaction Testing: Verify actual transaction boundaries and rollback behavior
- Realistic Scenarios: Test with data that mirrors production
- Performance: Optimize test execution with parallel runs and container reuse