Spring Boot
Integration Testing
Integration tests verify that components work correctly together — service with database, controller with security filter, or the full application stack. Spring Boot provides @SpringBootTest for full context tests, test slices (@DataJpaTest, @WebMvcTest, @DataRedisTest) for partial context tests, and Testcontainers for real database instances. This entry covers each approach, transaction management in tests, and database state management.
@SpringBootTest — Full Context
@SpringBootTest loads the complete application context. Use it for end-to-end tests that verify the entire stack — HTTP through to the database. It is the slowest test type; limit it to integration scenarios that cannot be tested with a slice. Use RANDOM_PORT to avoid port conflicts in parallel test runs.
Java
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class OrderApiIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders_test")
.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 TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepo;
@Autowired
private UserRepository userRepo;
@BeforeEach
void setUp() {
orderRepo.deleteAll();
userRepo.deleteAll();
}
@Test
void placeOrder_returnsCreatedWithLocation() {
// Arrange — create a user first
User user = userRepo.save(buildUser("alice@example.com"));
String token = authenticate("alice@example.com", "password");
CreateOrderRequest request = buildCreateRequest();
// Act
ResponseEntity<OrderResponse> response =
restTemplate.exchange(
"/api/v1/orders",
HttpMethod.POST,
new HttpEntity<>(request, authHeaders(token)),
OrderResponse.class);
// Assert
assertThat(response.getStatusCode())
.isEqualTo(HttpStatus.CREATED);
assertThat(response.getHeaders().getLocation())
.isNotNull();
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().status())
.isEqualTo(OrderStatus.PENDING);
// Verify database state
assertThat(orderRepo.count()).isEqualTo(1);
}
@Test
void placeOrder_returns401_whenNotAuthenticated() {
ResponseEntity<Void> response =
restTemplate.postForEntity(
"/api/v1/orders",
buildCreateRequest(),
Void.class);
assertThat(response.getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
}@DataJpaTest — Repository Slice
@DataJpaTest loads only JPA-related beans — entities, repositories, and an in-memory or Testcontainers database. No web layer, no services, no security. Each test runs in a transaction that is rolled back after the test, keeping the database clean between tests.
Java
@DataJpaTest
@AutoConfigureTestDatabase(replace =
AutoConfigureTestDatabase.Replace.NONE) // use real DB, not H2
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", postgres::getJdbcUrl);
r.add("spring.datasource.username", postgres::getUsername);
r.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderRepository orderRepo;
@Autowired
private TestEntityManager em;
@Test
void findByIdAndUserId_returnsOrder_whenOwnerMatches() {
// Arrange — persist test data
Order order = em.persistAndFlush(
buildOrder(1L, OrderStatus.PENDING));
// Act
Optional<Order> found =
orderRepo.findByIdAndUserId(order.getId(), 1L);
// Assert
assertThat(found).isPresent();
assertThat(found.get().getUserId()).isEqualTo(1L);
}
@Test
void findByIdAndUserId_returnsEmpty_whenWrongUser() {
Order order = em.persistAndFlush(
buildOrder(1L, OrderStatus.PENDING));
Optional<Order> found =
orderRepo.findByIdAndUserId(order.getId(), 999L);
assertThat(found).isEmpty();
}
@Test
void findByStatus_returnsPaginatedResults() {
// Persist 5 PENDING and 3 SHIPPED orders
IntStream.range(0, 5).forEach(i ->
em.persist(buildOrder((long) i, OrderStatus.PENDING)));
IntStream.range(5, 8).forEach(i ->
em.persist(buildOrder((long) i, OrderStatus.SHIPPED)));
em.flush();
Page<Order> pending = orderRepo.findByStatus(
OrderStatus.PENDING,
PageRequest.of(0, 3, Sort.by("id")));
assertThat(pending.getTotalElements()).isEqualTo(5);
assertThat(pending.getContent()).hasSize(3);
assertThat(pending.getTotalPages()).isEqualTo(2);
}
@Test
void deleteExpiredOrders_removesOnlyExpiredRows() {
Order active = em.persistAndFlush(
buildOrderWithExpiry(null));
Order expired = em.persistAndFlush(
buildOrderWithExpiry(Instant.now().minusSeconds(3600)));
em.flush();
int deleted = orderRepo.deleteExpiredBefore(Instant.now());
assertThat(deleted).isEqualTo(1);
assertThat(orderRepo.findById(active.getId())).isPresent();
assertThat(orderRepo.findById(expired.getId())).isEmpty();
}
}Testcontainers Integration
Testcontainers starts real Docker containers for databases, message brokers, and other services during tests. It guarantees test fidelity — the same database engine, version, and collation as production. Use @Container as a static field to share one container across all tests in a class.
Java
// ── Shared base class — reuse containers across test classes ──────────
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
abstract class BaseIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // reuse across test runs (faster)
@Container
static final GenericContainer<?> REDIS =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true);
@Container
static final KafkaContainer KAFKA =
new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"))
.withReuse(true);
@DynamicPropertySource
static void configureProperties(
DynamicPropertyRegistry registry) {
// PostgreSQL
registry.add("spring.datasource.url",
POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username",
POSTGRES::getUsername);
registry.add("spring.datasource.password",
POSTGRES::getPassword);
// Redis
registry.add("spring.data.redis.host",
REDIS::getHost);
registry.add("spring.data.redis.port",
() -> REDIS.getMappedPort(6379));
// Kafka
registry.add("spring.kafka.bootstrap-servers",
KAFKA::getBootstrapServers);
}
}
// ── Test class extending the base ────────────────────────────────────
class UserIntegrationTest extends BaseIntegrationTest {
@Autowired private TestRestTemplate restTemplate;
@Autowired private UserRepository userRepo;
@Test
void register_createsUserAndSendsEmail() { ... }
}
// ── Kafka integration test ─────────────────────────────────────────────
class OrderEventIntegrationTest extends BaseIntegrationTest {
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
@Autowired
private OrderRepository orderRepo;
@Test
void orderPlacedEvent_triggersInventoryReservation()
throws Exception {
// Publish event
OrderPlacedEvent event = new OrderPlacedEvent(
1L, 1L, new BigDecimal("99.99"),
List.of(101L), Instant.now());
kafkaTemplate.send("orders.placed",
String.valueOf(event.orderId()), event);
// Wait for consumer to process
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() ->
assertThat(reservationRepo.findByOrderId(1L))
.isPresent());
}
}Database State Management
Tests that share a database must manage state carefully to avoid interference. Use @Transactional to roll back after each test, @Sql to load fixtures, or @DirtiesContext (sparingly) to reset the context. Prefer transaction rollback over deleteAll() for speed.
Java
// ── Option 1: @Transactional rollback (fastest) ──────────────────────
@DataJpaTest
@Transactional // each test rolls back automatically
class ProductRepositoryTest {
@Autowired private ProductRepository productRepo;
@Autowired private TestEntityManager em;
@Test
void findByCategory_returnsMatchingProducts() {
em.persist(buildProduct("Widget", "electronics"));
em.persist(buildProduct("Gadget", "electronics"));
em.persist(buildProduct("Book", "books"));
em.flush();
List<Product> electronics =
productRepo.findByCategory("electronics");
assertThat(electronics).hasSize(2);
}
// Database rolled back automatically after this test
}
// ── Option 2: @Sql — load fixtures before tests ───────────────────────
@SpringBootTest
@ActiveProfiles("test")
@Sql(scripts = "/sql/test-users.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/cleanup.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class UserServiceIntegrationTest {
@Autowired private UserService userService;
@Test
void findById_returnsUser_fromFixture() {
UserResponse user = userService.findById(1L);
assertThat(user.email()).isEqualTo("fixture@example.com");
}
}
// ── /sql/test-users.sql ───────────────────────────────────────────────
// INSERT INTO users (id, name, email, password, status)
// VALUES (1, 'Fixture User', 'fixture@example.com',
// '$2a$12$hash', 'ACTIVE');
// ── Option 3: @BeforeEach deleteAll (simple but slower) ───────────────
@SpringBootTest
class OrderIntegrationTest {
@Autowired private OrderRepository orderRepo;
@BeforeEach
void cleanDatabase() {
orderRepo.deleteAll();
}
}
// ── Option 4: Separate schema per test class (Flyway) ─────────────────
// application-test.yml:
// spring:
// flyway:
// schemas: test_${random.uuid} # unique schema per test run
// datasource:
// url: jdbc:postgresql://localhost/testdb?currentSchema=...
// ── @Sql on a test class with annotations ────────────────────────────
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Sql(scripts = "/sql/cleanup.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public @interface CleanDatabase {}
@SpringBootTest
@CleanDatabase
class ProductIntegrationTest { ... }Test Slices — WebMvcTest and Others
Spring Boot test slices load a focused subset of the application context. @WebMvcTest loads the web layer only — controllers, filters, and security. @DataRedisTest loads Redis components only. @DataMongoTest loads MongoDB components. Slices are faster than @SpringBootTest and more focused than unit tests.
Java
// ── @WebMvcTest — controller layer only ──────────────────────────────
@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {
@Autowired
private MockMvc mockMvc;
// Mock all service dependencies — not loaded by @WebMvcTest
@MockBean private OrderService orderService;
@MockBean private JwtService jwtService;
@MockBean private CustomUserDetailsService userDetailsService;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(roles = "USER")
void findById_returns200_withOrderResponse()
throws Exception {
OrderResponse response = buildOrderResponse(42L);
when(orderService.findById(42L, any()))
.thenReturn(response);
mockMvc.perform(get("/api/v1/orders/42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.status")
.value("PENDING"));
}
@Test
void findById_returns401_withoutAuthentication()
throws Exception {
mockMvc.perform(get("/api/v1/orders/42"))
.andExpect(status().isUnauthorized());
}
}
// ── @DataRedisTest — Redis slice ──────────────────────────────────────
@DataRedisTest
@Testcontainers
class RefreshTokenRedisTest {
@Container
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.data.redis.host", redis::getHost);
r.add("spring.data.redis.port",
() -> redis.getMappedPort(6379));
}
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RefreshTokenRepository tokenRepo;
@Test
void save_persistsTokenWithTtl() {
RefreshToken token = new RefreshToken(
"token-hash-123", "user@example.com",
Instant.now().plusSeconds(3600));
tokenRepo.save(token);
assertThat(tokenRepo.findById("token-hash-123"))
.isPresent();
assertThat(redisTemplate.getExpire(
"refresh_tokens:token-hash-123")).isPositive();
}
}