12.3 Test Strategy: Unit, Controller, and Integration Tests
In professional setups, unit, controller (API), and integration tests are clearly separated. Spring Boot integrates well with JUnit 5, Mockito, MockMvc, and Testcontainers.
Reference: Spring Boot 3.2.x, JUnit 5, Mockito
1. Test Layers Overview
| Type | Purpose | Tools | Notes |
|---|---|---|---|
| Unit | Validate single-class logic (e.g. Service) | JUnit 5, Mockito | Fast; external deps mocked |
| Controller | Validate HTTP request/response, serialization, validation | @WebMvcTest, MockMvc | Web layer only; Service mocked |
| Integration | Validate DB and full stack | @SpringBootTest, Testcontainers | Slower but realistic |
2. Unit Tests (Service)
Mock dependencies (Repository, external clients) with @Mock and inject the target with @InjectMocks.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository orderRepository;
@Mock ProductRepository productRepository;
@InjectMocks OrderService orderService;
@Test
@DisplayName("Throws when stock is insufficient")
void order_throwsWhenStockInsufficient() {
Product product = new Product("item1", 10);
when(productRepository.findByName("item1")).thenReturn(Optional.of(product));
assertThrows(BusinessException.class,
() -> orderService.createOrder(new OrderDto("item1", 100)));
}
}
3. Controller Tests (@WebMvcTest + MockMvc)
Load only the controller and mock the Service to verify HTTP status, headers, and JSON. Avoids full context startup.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mockMvc;
@MockBean UserService userService;
@Test
void getUser_returns200() throws Exception {
when(userService.findById(1L))
.thenReturn(new UserResponseDto(1L, "user@test.com", "User"));
mockMvc.perform(get("/api/users/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(1))
.andExpect(jsonPath("$.data.email").value("user@test.com"));
}
@Test
void createUser_validatesRequestBody() throws Exception {
String invalidJson = "{\"email\":\"not-an-email\", \"name\":\"\"}";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest());
}
}
@WebMvcTest(UserController.class): Loads only that controller and web layer.@MockBean: Registers a mock Service in the context.MockMvc: Simulates HTTP without a real server.
4. JPA/Repository Tests (@DataJpaTest)
Runs only JPA and repositories, typically against an in-memory DB (e.g. H2). Use Testcontainers for a real DB schema.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // when using Testcontainers
class OrderRepositoryTest {
@Autowired OrderRepository orderRepository;
@Test
void findByStatus_returnsFilteredList() {
orderRepository.save(new Order("item1", OrderStatus.ORDER));
orderRepository.save(new Order("item2", OrderStatus.CANCEL));
List<Order> orders = orderRepository.findByStatus(OrderStatus.ORDER);
assertThat(orders).hasSize(1).first().extracting(Order::getItemName).isEqualTo("item1");
}
}
5. Integration Tests (@SpringBootTest)
Starts the full application context and DB. Use Testcontainers for MySQL/PostgreSQL so all environments stay consistent.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test").withUsername("test").withPassword("test");
@DynamicPropertySource
static void setDatasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired TestRestTemplate restTemplate;
@Test
void orderFlow_createsOrderAndDecrementsStock() {
ResponseEntity<ApiResponse<OrderResponseDto>> res = restTemplate.postForEntity(
"/api/orders",
new OrderRequestDto("item1", 2),
new ParameterizedTypeReference<>() {});
assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(res.getBody().getData().getItemName()).isEqualTo("item1");
}
}
6. Summary
- Service logic:
@ExtendWith(MockitoExtension.class)+@Mock/@InjectMocks. - API contract and validation:
@WebMvcTest+MockMvc+@MockBean. - Repository and JPA:
@DataJpaTest(H2 or Testcontainers). - Full flow:
@SpringBootTest+ Testcontainers +TestRestTemplate.
Separating tests by layer makes failures easier to diagnose and allows CI to run fast unit/controller tests separately from slower integration tests.