TungDaDev's Blog

Spring Test Annotations

Temp img.png
Published on
/5 mins read/

# giới thiệu

"Code without tests is broken by design" — nhưng viết đúng loại test ở đúng layer mới quan trọng. Spring Boot cung cấp test slices — load chỉ phần context cần thiết thay vì toàn bộ application. Kết quả: tests nhanh, focused, và reliable.

Senior developer không chỉ viết tests — họ thiết kế test strategy: unit tests cho logic, slice tests cho từng layer, integration tests cho critical paths. Spring Test annotations giúp bạn execute strategy đó hiệu quả.

# @SpringBootTest — full integration test

Load TOÀN BỘ Spring context. Dùng cho end-to-end tests cần tất cả beans wired together. Chậm nhất nhưng confident 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
       ResponseEntity<APIResponse<OrderDTO>> response = restTemplate.postForEntity(
           "/api/v1/orders", 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

# @WebMvcTest — controller layer only

Load CHỈ Web layer (controllers, filters, advice). Service/Repository beans phải mock. Fast, focused cho testing request mapping, validation, serialization.

@WebMvcTest(ProductController.class)
class ProductControllerTest {
 
   @Autowired
   private MockMvc mockMvc;
 
   @MockBean  // Mock service layer
   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

Load CHỈ JPA layer (EntityManager, Repositories, Flyway/Liquibase). Tự động configure in-memory DB hoặc real DB. Transactional by default (rollback after each 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");
   }
}

# @MockBean vs @SpyBean

@MockBean thay thế bean trong context bằng Mockito mock (mọi method return null/default). @SpyBean wrap real bean — real behavior by default, override specific methods khi cần.

@SpringBootTest
class OrderServiceIntegrationTest {
 
   @Autowired
   private OrderService orderService;
 
   @MockBean  // Hoàn toàn fake — không gọi external service
   private PaymentGateway paymentGateway;
 
   @SpyBean  // 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

Beans chỉ tồn tại trong test context — override production beans hoặc thêm test utilities.

@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 { ... }

# Testcontainers — real infrastructure in tests

Khi in-memory DB không đủ (PostgreSQL-specific features, MongoDB, Redis), dùng Testcontainers — Docker containers cho tests.

@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());
   }
}

# 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, MockRestServiceServerHTTP client testing
@WebFluxTestWebFlux controllersReactive endpoints

# quick reference

AnnotationScopeSpeed
@SpringBootTestFull contextSlow (5-30s)
@WebMvcTestWeb layer onlyFast (1-3s)
@DataJpaTestJPA layer onlyFast (2-5s)
@DataMongoTestMongoDB onlyFast (2-4s)
@MockBeanReplace bean with mock
@SpyBeanWrap 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 😎 👍🏻 🚀 🔥.