spring test annotations
Tung Bui Thanh (TungDaDev)7 mins read

# giới thiệu
"Code without tests is broken by design" (câu của Jacob Kaplan-Moss, đồng tác giả Django), nhưng quan trọng hơn là viết đúng loại test ở đúng layer. Spring Boot có test slices: chỉ load phần context cần thiết thay vì cả application, nên test chạy nhanh, tập trung và đáng tin.
Senior thường nghĩ tới test strategy chứ không dừng ở viết test: unit test cho logic, slice test cho từng layer, integration test cho các critical path. Spring Test annotations giúp triển khai strategy đó gọn hơn.
# @SpringBootTest: full integration test
Load TOÀN BỘ Spring context. Dùng cho end-to-end test cần mọi bean wired với nhau. Chậm nhất nhưng cũng yên tâm nhất.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OrderIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@Test
void createOrder_endToEnd() {
// Given
CreateOrderRequest request = CreateOrderRequest.builder()
.productId(testProductId)
.quantity(2)
.build();
// When
// postForEntity chỉ nhận Class<T>; muốn generic type thì phải dùng exchange()
ResponseEntity<APIResponse<OrderDTO>> response = restTemplate.exchange(
"/api/v1/orders", HttpMethod.POST, new HttpEntity<>(request),
new ParameterizedTypeReference<>() {});
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getData().getStatus()).isEqualTo("PENDING");
// Verify DB
Order saved = orderRepository.findById(response.getBody().getData().getId()).orElseThrow();
assertThat(saved.getQuantity()).isEqualTo(2);
}
}
// WebEnvironment options
// MOCK (default): MockServlet, no real HTTP
// RANDOM_PORT: Real HTTP server on random port
// DEFINED_PORT: Real HTTP on server.port
// NONE: No web contextpostForEntity không có overload nhận ParameterizedTypeReference, nên với kiểu generic như APIResponse<OrderDTO> phải dùng exchange().
Nếu bạn đã lên Spring Boot 4, để ý vài thay đổi: @SpringBootTest không còn tự tạo bean TestRestTemplate hay MockMvc nữa. Muốn dùng TestRestTemplate thì thêm dependency spring-boot-resttestclient, gắn @AutoConfigureTestRestTemplate, và import từ package mới org.springframework.boot.resttestclient; muốn MockMvc thì gắn @AutoConfigureMockMvc. Boot 4 cũng có thêm RestTestClient, dùng được cho cả MockMvc lẫn server thật. Các annotation test slice cũng chuyển sang package dạng org.springframework.boot.<technology>.test, nên khi nâng cấp nhớ sửa lại import.
# @WebMvcTest: controller layer only
Chỉ load Web layer (controllers, filters, advice). Service/Repository bean phải mock. Nhanh, hợp để test request mapping, validation, serialization.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean // Mock service layer (Boot < 3.4: @MockBean)
private ProductService productService;
@Test
void getProduct_returns200() throws Exception {
when(productService.getById(any()))
.thenReturn(ProductDTO.builder().id(testId).name("Test").build());
mockMvc.perform(get("/api/v1/products/{id}", testId)
.header("Authorization", "Bearer " + validToken)
.header("X-Workspace-Id", workspaceId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name").value("Test"))
.andExpect(jsonPath("$.data.id").value(testId.toString()));
}
@Test
void createProduct_invalidRequest_returns400() throws Exception {
// Missing required field
String invalidJson = "{}";
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson)
.header("Authorization", "Bearer " + validToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").exists());
}
@Test
void listProducts_pagination() throws Exception {
when(productService.search(any(), any()))
.thenReturn(new PageImpl<>(List.of(testProduct), PageRequest.of(0, 20), 1));
mockMvc.perform(get("/api/v1/products")
.param("page", "0")
.param("size", "20")
.param("keyword", "laptop"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.content").isArray())
.andExpect(jsonPath("$.data.totalElements").value(1));
}
}# @DataJpaTest: repository layer only
Chỉ load JPA layer (EntityManager, Repositories, Flyway/Liquibase). Tự cấu hình in-memory DB hoặc DB thật. Mặc định chạy trong transaction và rollback sau mỗi test.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Use real PostgreSQL
@Import(JpaAuditingConfig.class)
@ActiveProfiles("test")
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager em;
@Test
void findByCode_existingProduct_returnsProduct() {
// Given
Product product = em.persistAndFlush(Product.builder()
.code("TEST-001").name("Test").price(BigDecimal.TEN)
.status(ProductStatus.ACTIVE).category(testCategory).build());
// When
Optional<Product> result = productRepository.findByCode("TEST-001");
// Then
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("Test");
}
@Test
void search_byKeywordAndStatus_returnsPaginated() {
// Given
em.persist(Product.builder().code("P1").name("Laptop Pro").status(ACTIVE)...build());
em.persist(Product.builder().code("P2").name("Laptop Air").status(ACTIVE)...build());
em.persist(Product.builder().code("P3").name("Phone").status(ACTIVE)...build());
em.flush();
// When
Page<Product> result = productRepository.search("laptop", ACTIVE, null,
PageRequest.of(0, 10));
// Then
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent()).extracting("name")
.containsExactlyInAnyOrder("Laptop Pro", "Laptop Air");
}
}# @MockitoBean vs @MockitoSpyBean
@MockitoBean thay bean trong context bằng Mockito mock (mọi method return null/default). @MockitoSpyBean wrap bean thật: mặc định chạy đúng logic thật, chỉ override method nào cần.
Hai annotation này thuộc Spring Framework 6.2 (package org.springframework.test.context.bean.override.mockito). Bản cũ @MockBean/@SpyBean của Spring Boot bị deprecated từ Boot 3.4 và bị xoá hẳn ở Boot 4.0. Khác biệt đáng chú ý: bản mới chỉ dùng được trên field của class test, không đặt trong class @Configuration được như bản cũ.
Một cái giá ít người để ý: Spring cache application context giữa các test class, nhưng tập mock/spy là một phần của "chìa khoá" cache. Mỗi class test mock một tổ hợp bean khác nhau là Spring phải dựng thêm một context mới, suite test chậm dần lúc nào không hay. Nên gom các mock hay dùng vào một base class chung.
@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@MockitoBean // Hoàn toàn fake — không gọi external service
private PaymentGateway paymentGateway;
@MockitoSpyBean // Real implementation, nhưng verify interactions
private NotificationService notificationService;
@Test
void createOrder_callsPaymentAndNotification() {
// Setup mock
when(paymentGateway.authorize(any())).thenReturn(PaymentResult.success());
// Execute
OrderDTO order = orderService.create(testRequest);
// Verify mock was called
verify(paymentGateway).authorize(argThat(req ->
req.getAmount().compareTo(expectedAmount) == 0));
// Verify spy (real method ran, but we can check it was called)
verify(notificationService).sendOrderConfirmation(eq(order.getId()));
}
}# @TestConfiguration: test-specific beans
Các bean chỉ tồn tại trong test context, dùng để override bean production hoặc thêm test utility.
@TestConfiguration
public class TestSecurityConfig {
// Override real security cho tests
@Bean
public SecurityFilterChain testSecurity(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a.anyRequest().permitAll()).build();
}
}
@TestConfiguration
public class TestDataConfig {
@Bean
public TestDataGenerator testDataGenerator(EntityManager em) {
return new TestDataGenerator(em);
}
}
// Import in test
@SpringBootTest
@Import(TestSecurityConfig.class)
class MyTest { ... }Cẩn thận với ví dụ TestSecurityConfig: nếu app đã có một SecurityFilterChain match mọi request, thêm chain thứ hai cũng match mọi request thì Spring Security 6.x báo lỗi lúc khởi động (UnreachableFilterChainException), vì chain đứng sau sẽ không bao giờ được chạy. Cách gọn hơn là tắt chain thật bằng profile, hoặc giữ nguyên security và dùng @WithMockUser / SecurityMockMvcRequestPostProcessors.jwt() trong test.
# Testcontainers: real infrastructure in tests
Khi in-memory DB không đủ (tính năng riêng của PostgreSQL, MongoDB, Redis), dùng Testcontainers: chạy Docker container ngay trong test.
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class ProductServiceIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@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);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private ProductService productService;
@Test
void createProduct_persistsToRealPostgres() {
ProductDTO created = productService.create(testRequest);
assertThat(created.getId()).isNotNull();
ProductDTO fetched = productService.getById(created.getId());
assertThat(fetched.getName()).isEqualTo(testRequest.getName());
}
}Từ Spring Boot 3.1 có cách gọn hơn @DynamicPropertySource: gắn @ServiceConnection lên field container, Boot tự đọc host/port/user/password từ container và cấu hình DataSource, Redis... (với Redis dùng GenericContainer thì ghi thêm @ServiceConnection(name = "redis")). @DynamicPropertySource vẫn cần cho các property không thuộc loại connection, như URL của một service giả lập.
# other test slices
| Annotation | Loads | Use for |
|---|---|---|
| @WebMvcTest | Controllers, Filters, Advice | REST endpoint testing |
| @DataJpaTest | JPA repos, EntityManager | Repository queries |
| @DataMongoTest | MongoDB repos, MongoTemplate | MongoDB operations |
| @DataRedisTest | Redis repos, RedisTemplate | Redis operations |
| @JsonTest | ObjectMapper, JsonComponent | JSON serialization |
| @RestClientTest | RestTemplate/RestClient, MockRestServiceServer | HTTP client testing |
| @WebFluxTest | WebFlux controllers | Reactive endpoints |
# quick reference
| Annotation | Scope | Speed |
|---|---|---|
| @SpringBootTest | Full context | Slowest |
| @WebMvcTest | Web layer only | Fast |
| @DataJpaTest | JPA layer only | Fast |
| @DataMongoTest | MongoDB only | Fast |
| @MockitoBean | Replace bean with mock | — |
| @MockitoSpyBean | Wrap bean with spy | — |
| @TestConfiguration | Test-only beans | — |
| @DynamicPropertySource | Runtime property override | — |
| @ActiveProfiles | Activate test profile | — |
| @Sql | Execute SQL before test | — |
| @Testcontainers | Docker containers | Slow (first start) |
| @Container | Single container instance | — |
| @WithMockUser | Mock security context | — |
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.