Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

spring test annotations

Tung Bui Thanh (TungDaDev)7 mins read

Spring test annotations.webp

# 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 context

postForEntity 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

AnnotationLoadsUse for
@WebMvcTestControllers, Filters, AdviceREST endpoint testing
@DataJpaTestJPA repos, EntityManagerRepository queries
@DataMongoTestMongoDB repos, MongoTemplateMongoDB operations
@DataRedisTestRedis repos, RedisTemplateRedis operations
@JsonTestObjectMapper, JsonComponentJSON serialization
@RestClientTestRestTemplate/RestClient, MockRestServiceServerHTTP client testing
@WebFluxTestWebFlux controllersReactive endpoints

# quick reference

AnnotationScopeSpeed
@SpringBootTestFull contextSlowest
@WebMvcTestWeb layer onlyFast
@DataJpaTestJPA layer onlyFast
@DataMongoTestMongoDB onlyFast
@MockitoBeanReplace bean with mock—
@MockitoSpyBeanWrap bean with spy—
@TestConfigurationTest-only beans—
@DynamicPropertySourceRuntime property override—
@ActiveProfilesActivate test profile—
@SqlExecute SQL before test—
@TestcontainersDocker containersSlow (first start)
@ContainerSingle container instance—
@WithMockUserMock 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 😎 👍🏻 🚀 🔥.