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: テストとコードの品質

xdev.asia

はじめに

テストはすべてのプロの開発者にとって必須のスキルです。 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 クラス/メソッドの分離、疑似依存関係
  • 統合テスト: 複数のコンポーネントを一緒にテストします。実際の DB
  • 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() をテストします。注文が保存され、イベントが発行され、在庫が減少したことを確認します。製品が見つからない場合のロールバックのテスト