Spring BootIntegration Testing
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();
    }
}