Chuyển đến nội dung chính

第 17 課:使用 JUnit 5 和 Mockito 進行單元測試

Spring Boot 4 中的 JUnit Jupiter 6。 Mockito — 模擬、存根、驗證。測試服務層、儲存庫層。 AssertJ 斷言。測試覆蓋率最佳實踐。

💻 程式設計 — 第 16 課 第 17 課:使用 JUnit 5 和 Mockito 進行單元測試

Spring Boot 4:從基礎到高級

第 5 部分:測試和程式碼質量

亞洲開發網

簡介

測驗是每個專業開發人員的必備技能。 Spring Boot 4 使用 JUnit Jupiter 6 並整合 Mockito 和 AssertJ 來輕鬆可靠地編寫單元測試。


1. 測試金字塔

        ╱  E2E Tests  ╲         ← Ít nhất, chậm nhất
       ╱ Integration    ╲
      ╱   Tests          ╲
     ╱ Unit Tests          ╲    ← Nhiều nhất, nhanh nhất
    ╱──────────────────────╲
  • 單元測試:測試 1 個類別/方法隔離,模擬依賴關係
  • 整合測試:一起測試多個元件,真實的資料庫
  • E2E 測試:測試從 HTTP 請求 → 回應的完整流程

2.JUnit Jupiter 6 基礎知識

2.1 主要註釋

class ProductServiceTest {

    @BeforeAll
    static void setUpAll() { /* Chạy 1 lần trước tất cả tests */ }

    @BeforeEach
    void setUp() { /* Chạy trước MỖI test */ }

    @Test
    @DisplayName("Should create product successfully")
    void shouldCreateProduct() { }

    @Test
    @Disabled("Not implemented yet")
    void futureFeature() { }

    @AfterEach
    void tearDown() { /* Chạy sau MỖI test */ }

    @AfterAll
    static void tearDownAll() { /* Chạy 1 lần sau tất cả tests */ }
}

2.2 參數化測試

@ParameterizedTest
@ValueSource(strings = {"", " ", "   "})
@DisplayName("Should reject blank product name")
void shouldRejectBlankName(String name) {
    var request = new CreateProductRequest(name, BigDecimal.TEN, "Category");
    assertThrows(ConstraintViolationException.class,
        () -> productService.create(request));
}

@ParameterizedTest
@CsvSource({
    "100, 10, 90",
    "200, 50, 150",
    "0, 0, 0"
})
void shouldCalculateDiscount(int price, int discount, int expected) {
    assertEquals(expected, calculator.applyDiscount(price, discount));
}

3. Mockito — 模擬依賴項

3.1 使用@ExtendWith 設定

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {

    @Mock
    private ProductRepository productRepository;

    @Mock
    private CategoryRepository categoryRepository;

    @InjectMocks
    private ProductService productService;
}

3.2 存根 — 定義模擬行為

@Test
@DisplayName("Should return product when found by ID")
void shouldReturnProductWhenFound() {
    // Given
    Long productId = 1L;
    Product product = Product.builder()
        .id(productId)
        .name("Spring Boot Book")
        .price(new BigDecimal("450000"))
        .build();

    when(productRepository.findById(productId))
        .thenReturn(Optional.of(product));

    // When
    ProductResponse result = productService.getProduct(productId);

    // Then
    assertThat(result.id()).isEqualTo(productId);
    assertThat(result.name()).isEqualTo("Spring Boot Book");
    assertThat(result.price()).isEqualByComparingTo("450000");
}

3.3 驗證-檢查模擬是否被正確調用

@Test
@DisplayName("Should delete product and evict cache")
void shouldDeleteProduct() {
    // Given
    Long productId = 1L;
    when(productRepository.existsById(productId)).thenReturn(true);

    // When
    productService.deleteProduct(productId);

    // Then
    verify(productRepository).deleteById(productId);
    verify(productRepository, times(1)).existsById(productId);
    verify(productRepository, never()).findById(any());
}

3.4 參數捕獲

@Test
void shouldSaveProductWithCorrectData() {
    // Given
    var request = new CreateProductRequest("Book", new BigDecimal("100"), "Education");

    ArgumentCaptor<Product> productCaptor = ArgumentCaptor.forClass(Product.class);
    when(productRepository.save(any(Product.class)))
        .thenAnswer(inv -> {
            Product p = inv.getArgument(0);
            p.setId(1L);
            return p;
        });

    // When
    productService.create(request);

    // Then
    verify(productRepository).save(productCaptor.capture());
    Product savedProduct = productCaptor.getValue();
    assertThat(savedProduct.getName()).isEqualTo("Book");
    assertThat(savedProduct.getPrice()).isEqualByComparingTo("100");
}

4. AssertJ — 流暢的斷言

// Collection assertions
assertThat(products)
    .hasSize(3)
    .extracting(ProductResponse::name)
    .containsExactly("A", "B", "C");

// Exception assertions
assertThatThrownBy(() -> productService.getProduct(999L))
    .isInstanceOf(ResourceNotFoundException.class)
    .hasMessageContaining("Product")
    .hasMessageContaining("999");

// Soft assertions — báo tất cả lỗi cùng lúc
SoftAssertions.assertSoftly(softly -> {
    softly.assertThat(result.id()).isEqualTo(1L);
    softly.assertThat(result.name()).isEqualTo("Book");
    softly.assertThat(result.price()).isPositive();
});

5. 測試服務層模式

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private OrderRepository orderRepository;
    @Mock private ProductRepository productRepository;
    @Mock private ApplicationEventPublisher eventPublisher;
    @InjectMocks private OrderService orderService;

    @Test
    void shouldCreateOrderAndPublishEvent() {
        // Given
        var request = new CreateOrderRequest(1L, List.of(
            new OrderItemRequest(1L, 2),
            new OrderItemRequest(2L, 1)
        ));

        when(productRepository.findById(1L))
            .thenReturn(Optional.of(product(1L, "A", 100)));
        when(productRepository.findById(2L))
            .thenReturn(Optional.of(product(2L, "B", 200)));
        when(orderRepository.save(any(Order.class)))
            .thenAnswer(inv -> {
                Order o = inv.getArgument(0);
                o.setId(1L);
                return o;
            });

        // When
        OrderResponse result = orderService.createOrder(request);

        // Then
        assertThat(result).isNotNull();
        verify(orderRepository).save(any(Order.class));
        verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
    }

    private Product product(Long id, String name, int price) {
        return Product.builder()
            .id(id).name(name).price(new BigDecimal(price))
            .build();
    }
}

總結

  • 單元測試遵循Given-When-Then模式,使用@Mock和@InjectMocks來隔離依賴關係
  • JUnit Jupiter 6:@ParameterizedTest、@DisplayName、斷言 API
  • Mockito:when().thenReturn()用於存根,verify()檢查交互,ArgumentCaptor捕獲參數
  • AssertJ:集合、異常、軟斷言的流暢斷言

練習

1.為ProductService編寫單元測試:測試CRUD操作、模擬儲存庫、驗證足夠的場景(找到、找不到、驗證錯誤) 2. 為價格計算器編寫參數化測試:多種輸入/輸出組合 3. 測試 OrderService.createOrder():驗證訂單已儲存、事件已發布、庫存已減少。未找到產品時測試回滾