Spring BootのServiceをJUnitとMockitoでテストする方法を解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアがRepositoryのモックを学ぶイラスト。

Spring

Spring BootのServiceをJUnit・Mockitoでテストする方法|Repositoryをモックする基本

AIによる要約

Serviceの単体テストではSpring Boot全体を起動せず、JUnit 5とMockitoでRepositoryをモックします。@Mockで依存先を作り、@InjectMocksまたはコンストラクタでServiceへ渡し、戻り値だけでなく保存処理が正しい引数で呼ばれたかも確認します。成功ケース、対象なし、業務ルール違反を分けてテストし、DB制約やトランザクションは結合テストへ任せます。

新人SE
新人SE
Serviceをテストするなら、毎回@SpringBootTestを付けてDBまで起動する必要がありますか?
ポンコツSE
ポンコツSE
業務ロジックの単体テストなら、Serviceを普通のJavaクラスとして生成し、RepositoryをMockitoで置き換えれば十分なことが多いです。

Spring BootのServiceは、Repositoryからデータを取得し、条件を判定し、更新結果を保存する役割を持つことが多くあります。若手が初めてテストを書くと、Springを起動すること自体が目的になり、準備が大きくて遅いテストになりがちです。

DIで依存先をコンストラクタから受け取るServiceは、Springコンテナがなくても普通のJavaクラスとしてテストできます。この記事では注文取消Serviceを例に、Repositoryをモックした単体テストの基本を説明します。

この記事のポイント

  • Serviceの単体テストではSpringコンテナを起動しない
  • Repositoryの応答をMockitoのwhenで準備する
  • 成功、対象なし、業務ルール違反を別テストにする
  • 戻り値だけでなく、必要な保存処理が呼ばれたかを確認する

テスト対象のServiceを確認する

注文IDから注文を取得し、取消可能なら状態を変更するServiceを考えます。

@Service
public class OrderService {
    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void cancel(long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));

        order.cancel();
        orderRepository.save(order);
    }
}

Order#cancelは、すでに発送済みならOrderCannotBeCanceledExceptionを投げ、取消可能ならステータスをCANCELEDへ変更する想定です。Repositoryはインターフェースなので、テストではMockitoで置き換えられます。

public interface OrderRepository extends JpaRepository<Order, Long> {
}

JUnit 5とMockitoの基本構成

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService orderService;
}
  • @ExtendWith(MockitoExtension.class): JUnit 5でMockitoのアノテーションを有効にする
  • @Mock: Repositoryの代わりになるモックを作る
  • @InjectMocks: モックを使ってテスト対象のServiceを生成する

@SpringBootTestを付けていないため、Spring Boot全体やDBは起動しません。テスト対象と依存関係が小さく、失敗原因を追いやすい構成です。

コンストラクタで明示的に生成してもよい

@InjectMocksを使わず、@BeforeEachでServiceを生成すると、どの依存を渡したかが明確になります。

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    private OrderService orderService;

    @BeforeEach
    void setUp() {
        orderService = new OrderService(orderRepository);
    }
}

どちらを使うかはプロジェクトのテスト規約へ揃えます。依存先が増えすぎてセットアップが苦しくなるなら、テストの書き方ではなくServiceの責務が大きすぎないかも確認します。

成功ケースをテストする

Repositoryが注文を返すように準備し、取消後の状態と保存呼び出しを確認します。

@Test
void 取消可能な注文を取り消せる() {
    long orderId = 100L;
    Order order = Order.waitingForShipment(orderId);
    when(orderRepository.findById(orderId))
            .thenReturn(Optional.of(order));

    orderService.cancel(orderId);

    assertEquals(OrderStatus.CANCELED, order.getStatus());
    verify(orderRepository).findById(orderId);
    verify(orderRepository).save(order);
}

when(...).thenReturn(...)は、モックの振る舞いを準備します。verifyは、期待した依存先メソッドが呼ばれたかを確認します。

なお、JPAで取得した管理対象Entityは、トランザクション内で状態を変えるとダーティチェックにより更新されるため、明示的なsaveが不要な設計もあります。ここではRepository呼び出しのテスト例としてsaveするServiceを扱っています。実案件では既存Serviceとチーム方針に合わせ、不要なsaveをテストのためだけに追加しないでください。

Arrange、Act、Assertの順に、準備・実行・確認を分けるとテストを読みやすくできます。

対象が見つからないケースをテストする

@Test
void 注文が存在しなければ例外になる() {
    long orderId = 999L;
    when(orderRepository.findById(orderId))
            .thenReturn(Optional.empty());

    OrderNotFoundException exception = assertThrows(
            OrderNotFoundException.class,
            () -> orderService.cancel(orderId));

    assertEquals(orderId, exception.getOrderId());
    verify(orderRepository).findById(orderId);
    verify(orderRepository, never()).save(any(Order.class));
}

例外型だけでなく、調査やエラーレスポンスに使う注文IDも確認しています。また、対象がないのにsaveされていないことをnever()で確認します。

業務ルール違反をテストする

発送済み注文は取り消せない、という業務ルールも独立したテストにします。

@Test
void 発送済み注文は取り消せない() {
    long orderId = 200L;
    Order order = Order.shipped(orderId);
    when(orderRepository.findById(orderId))
            .thenReturn(Optional.of(order));

    assertThrows(
            OrderCannotBeCanceledException.class,
            () -> orderService.cancel(orderId));

    assertEquals(OrderStatus.SHIPPED, order.getStatus());
    verify(orderRepository, never()).save(any(Order.class));
}

正常系だけでは、「条件判定を削除してもテストが通る」状態になりかねません。業務上重要な分岐は、成功と失敗の両方をテストします。

ArgumentCaptorで保存内容を確認する

Service内で新しいEntityを作って保存する場合、引数が同じインスタンスではないため、ArgumentCaptorでRepositoryへ渡された値を確認できます。

@Test
void 注文登録時に入力内容を保存する() {
    OrderCreateCommand command = new OrderCreateCommand(10L, 3);
    when(orderRepository.save(any(Order.class)))
            .thenAnswer(invocation -> invocation.getArgument(0));

    orderService.create(command);

    ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
    verify(orderRepository).save(captor.capture());

    Order savedOrder = captor.getValue();
    assertEquals(10L, savedOrder.getProductId());
    assertEquals(3, savedOrder.getQuantity());
}

Captorは便利ですが、全呼び出しへ使うとテストが長くなります。戻り値やEntityの状態で十分確認できるなら、より単純な方法を選びます。

verifyを使いすぎない

すべてのprivate処理やgetter呼び出しまでverifyすると、動作が同じでも内部実装を整理しただけでテストが壊れます。verifyするのは、保存、外部通知、決済要求など、Serviceの外へ出る重要な副作用に絞ります。

// 意味のある確認
verify(orderRepository).save(order);
verify(notificationClient).sendCanceled(order.getId());

// 実装詳細へ寄りすぎる確認は避ける
// privateメソッドをモックする
// getterの呼び出し回数まで固定する

privateメソッドをモックしないとテストできない場合は、その処理が別クラスの責務ではないかを考えます。テスト都合だけでprivateをpublicへ変えるのも避けます。

モックするもの・しないもの

対象単体テストでの扱い理由
RepositoryモックするDBアクセスを切り離す
外部API Clientモックする通信と相手環境を切り離す
時刻取得Clockなどを注入するテスト結果を固定する
Entity・値オブジェクト実物を使う業務ルールを含むテスト対象だから
Form・DTO実物を使う単純な入力データだから

何でもモックにすると、実際の業務ロジックが動かず、Mockitoの設定だけを確認するテストになります。遅い、外部状態に依存する、今回のテスト対象ではない境界をモックします。

単体テストで確認できないこと

Mockitoを使った単体テストでは、Repositoryをモックしているため、次の動作は確認できません。

  • JPAのマッピングやSQLが正しいか
  • DBのNOT NULL、UNIQUE、外部キー制約が動くか
  • @Transactionalで実際にロールバックされるか
  • SpringのBean設定やAOPが正しいか
  • ControllerからJSONやFormが正しく渡るか

これらはRepositoryテストやSpringを起動する結合テストで確認します。単体テストと結合テストは優劣ではなく、見つけられる不具合が違います。

@SpringBootTestとの使い分け

観点JUnit + Mockito@SpringBootTest
主な目的Serviceの分岐・計算・依存呼び出しBean連携や実設定を含む動作
Spring起動しないする
速度速い比較的遅い
DB確認できない構成次第でできる
失敗原因絞りやすい複数層にまたがることがある

Serviceの条件分岐ごとに@SpringBootTestを使うのではなく、多数の単体テストで業務ロジックを確認し、主要な連携経路を少数の結合テストで確認すると役割が明確になります。

読みやすいテスト名と構造

テスト名は、メソッド名だけでなく条件と期待結果が分かる形にします。日本語名を許可するかはプロジェクト規約へ揃えます。

@Test
void cancel_注文が存在しない場合はOrderNotFoundExceptionになる() {
    // Arrange
    when(orderRepository.findById(999L)).thenReturn(Optional.empty());

    // Act & Assert
    assertThrows(
            OrderNotFoundException.class,
            () -> orderService.cancel(999L));
}

1つのテストに成功、失敗、通知、DB保存をすべて詰め込まず、原則として1つの条件を確認します。失敗したときに、どの仕様が壊れたか分かる粒度を目指します。

よくあるMockitoのつまずき

  • whenへ指定したIDと、Serviceが実際に渡すIDが違う
  • 対象なしのstubを省略し、テストの前提がコードから読み取れない
  • any()を使いすぎて、誤った引数でもテストが通る
  • モック対象をnewしてしまい、Mockitoのモックと別インスタンスになる
  • 不要なstubが大量にあり、テスト条件が読み取れない
  • staticやprivateを無理にモックし、設計上の依存が隠れる

エラーが出たらアノテーションを増やすのではなく、テスト対象Serviceがどのインスタンスを持ち、どの引数でモックを呼んでいるかをデバッガーで確認します。

現場レビューでよくある指摘

// レビューコメント例
Serviceの単体テストですが@SpringBootTestで全Beanを起動しています。
RepositoryをMockitoで置き換え、Springなしで確認できないか検討してください。

// レビューコメント例
正常系しかなく、対象なしでもsaveされないことを確認できません。
Optional.empty()と業務ルール違反のケースを追加してください。

// レビューコメント例
any()だけでverifyしているため、別の注文を保存しても通ります。
保存対象の状態または具体的なIDを確認してください。

提出前のセルフチェック

  • ServiceをSpringなしで生成できているか
  • Repositoryや外部Clientだけをモックしているか
  • 成功、対象なし、業務ルール違反を分けているか
  • 重要な保存や通知が正しい引数で呼ばれたか確認したか
  • 失敗時に不要な副作用が起きないことを確認したか
  • DB制約やトランザクションの確認を単体テストへ求めていないか

単体テスト設計を深める参考書

MockitoのAPIだけでなく、何を単体テストし、どこからを結合テストへ任せるかの判断軸を持つと、壊れにくく価値のあるテストを書きやすくなります。

書籍「単体テストの考え方/使い方」の表紙
商品画像:Amazon.co.jp
PR テストを学ぶ若手向け

単体テストの考え方/使い方

Vladimir Khorikov (著), 須田智之 (翻訳)

単体テストで「何を検証するか」の判断基準を学ぶ。

モックの使いどころや壊れにくいテストの考え方を整理できます。JUnitの構文を覚えた次に読む本です。

  • テスト対象の切り分けに迷っている
  • 保守しやすい単体テストを書きたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

Spring BootのServiceは、JUnit 5とMockitoを使えばSpringコンテナを起動せずに単体テストできます。Repositoryを@Mockで置き換え、成功、対象なし、業務ルール違反の条件ごとに、状態と重要な依存呼び出しを確認します。

単体テストではDB制約、JPAマッピング、トランザクションまでは確認できません。それらは結合テストへ任せ、Serviceの単体テストは業務上の判断を速く、具体的に検証する役割へ絞りましょう。

-Spring
-, , , , ,