【Spring Boot】Mockitoを使ったサービス層のテスト

Java

こんにちは、かつコーチです。

@SpringBootTest@DataJpaTestは便利ですが、サービス層のビジネスロジックだけを検証したいときにDIコンテナやDBを毎回起動するのは、テストの速度と焦点の両面で無駄が出やすいです。
筆者が担当したプロジェクトでは、サービス層のテストをすべて@SpringBootTestベースで書いていたため、CIのテスト実行時間が10分を超えており、ここをMockito単体のテストに置き換えたところ1分半まで短縮できました。

この記事では、Springコンテナを一切起動せずにMockitoだけでサービス層をテストする方法と、@SpringBootTestベースとの使い分けの判断軸を解説します。

MockitoExtensionでコンテナレスのテストを書く

@ExtendWith(MockitoExtension.class)の役割

Mockitoの@Mock@InjectMocksをJUnit 5で有効化するには、テストクラスに@ExtendWith(MockitoExtension.class)を付けます。
このアプローチではSpringのDIコンテナを一切起動しないため、テストの実行時間はミリ秒単位に収まります。

package com.example.blog.service;

import com.example.blog.entity.Article;
import com.example.blog.exception.ArticleNotFoundException;
import com.example.blog.repository.ArticleRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {

    @Mock
    private ArticleRepository articleRepository;

    @InjectMocks
    private ArticleService articleService;

    @Test
    void findById_returnsArticle_whenExists() {
        Article article = new Article(1L, "テスト記事", "本文");
        given(articleRepository.findById(1L)).willReturn(Optional.of(article));

        Article result = articleService.findById(1L);

        assertThat(result.getTitle()).isEqualTo("テスト記事");
    }

    @Test
    void findById_throwsException_whenNotFound() {
        given(articleRepository.findById(999L)).willReturn(Optional.empty());

        assertThatThrownBy(() -> articleService.findById(999L))
                .isInstanceOf(ArticleNotFoundException.class)
                .hasMessage("記事が見つかりません: id=999");
    }
}

@InjectMocksは、@Mockで用意したモックをコンストラクタインジェクション経由で自動的に注入してくれます。
ArticleServiceがコンストラクタでArticleRepositoryを受け取る設計になっていれば、フィールドインジェクションの実装よりMockitoとの相性が良くなります。

BDDMockitoでgiven-when-thenの構文に揃える

Mockito.when(...)の代わりにBDDMockito.given(...)を使うと、テストコードが「準備(Given)→実行(When)→検証(Then)」というBDD(振る舞い駆動開発)のスタイルに揃います。

@Test
void createArticle_savesAndReturnsArticle() {
    // Given
    ArticleCreateRequest request = new ArticleCreateRequest("新規記事", "本文");
    Article savedArticle = new Article(1L, "新規記事", "本文");
    given(articleRepository.save(any(Article.class))).willReturn(savedArticle);

    // When
    Article result = articleService.create(request);

    // Then
    assertThat(result.getId()).isEqualTo(1L);
    verify(articleRepository).save(any(Article.class));
}

チーム開発ではwhen(...).thenReturn(...)given(...).willReturn(...)が混在すると可読性が下がるため、どちらかに統一するルールを決めておくのがおすすめです。

verifyとArgumentCaptorで呼び出し内容を検証する

メソッドが正しい引数で呼ばれたかを検証する

戻り値だけでなく「モックがどんな引数で呼ばれたか」まで検証したい場合は、ArgumentCaptor(呼び出し時の引数をキャプチャするクラス)を使います。

import org.mockito.ArgumentCaptor;
import org.mockito.Captor;

@Captor
private ArgumentCaptor<Article> articleCaptor;

@Test
void createArticle_setsPublishedFalseByDefault() {
    ArticleCreateRequest request = new ArticleCreateRequest("新規記事", "本文");
    given(articleRepository.save(any())).willReturn(new Article(1L, "新規記事", "本文"));

    articleService.create(request);

    verify(articleRepository).save(articleCaptor.capture());
    assertThat(articleCaptor.getValue().isPublished()).isFalse();
}

戻り値のモック設定だけでは検証しきれない「保存直前のオブジェクトの状態」まで確認できるのがArgumentCaptorの強みです。
特に、サービス層でデフォルト値を設定するようなロジックのリグレッション防止に有効です。

呼び出し回数・呼び出し順序の検証

verifyは呼び出し回数の検証にも使えます。

import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;

@Test
void deleteArticle_doesNotCallSaveWhenNotFound() {
    given(articleRepository.findById(999L)).willReturn(Optional.empty());

    assertThatThrownBy(() -> articleService.delete(999L))
            .isInstanceOf(ArticleNotFoundException.class);

    verify(articleRepository, never()).deleteById(any());
    verify(articleRepository, times(1)).findById(999L);
}

「存在しないIDの削除処理が呼ばれたとき、DBのdeleteByIdが実行されていないこと」まで確認することで、例外発生時にデータ整合性が壊れないことを保証できます。

@SpringBootTestベースとの使い分けの判断軸

サービス層のテストは、Mockitoベースと@SpringBootTestベースのどちらでも書けます。
それぞれのトレードオフを整理すると、以下のようになります。

観点Mockito単体(@ExtendWith(MockitoExtension.class)@SpringBootTest
実行速度数ミリ秒〜数十ミリ秒数百ミリ秒〜数秒(DIコンテナ起動を含む)
検証範囲サービスクラス単体のロジック実際のDI設定・トランザクション境界まで含む
DB接続不要(モック)必要(組み込みDBまたは実DB)
適する場面条件分岐・例外処理・引数検証など、ロジックの正確性を細かく検証したいとき@Transactionalの境界や@Asyncなど、フレームワークの機能込みで動作確認したいとき

筆者のチームでは、サービス層の分岐やエラー処理のパターン網羅は基本的にMockitoベースで書き、@Transactionalの伝播やAOP(アスペクト指向プログラミング)が絡む部分だけ@SpringBootTestベースの統合テストとして残す、という切り分けに落ち着いています。
すべてを統合テストで書こうとすると実行時間が肥大化し、逆にすべてをMockitoベースにすると「モック同士は整合するがフレームワーク込みで動くと壊れる」パターンを見逃すリスクがあるため、両方を組み合わせるのが現実的な落としどころです。

テストダブルの粒度を設計する

モックの過剰な検証がリファクタリング耐性を下げる

verifyを多用しすぎると、内部の実装詳細に強く依存したテストになり、リファクタリングのたびにテストが壊れる原因になります。

// ❌ 実装の呼び出し順序まで厳密に検証しすぎている例
@Test
void createArticle_tooStrictVerification() {
    articleService.create(request);

    InOrder inOrder = inOrder(articleRepository, notificationService);
    inOrder.verify(articleRepository).save(any());
    inOrder.verify(notificationService).notify(any());
    // 内部の呼び出し順序を変えただけでテストが壊れてしまう
}

呼び出し順序が結果に影響しない処理であれば、順序までは検証せず、最終的な戻り値や状態の検証にとどめるほうが、テストが実装の変更に対して堅牢になります。

スタブとモックの役割を意識する

Mockitoのgiven(...).willReturn(...)は「テストを成立させるためにダミーの戻り値を用意する」スタブとしての使い方です。
一方verify(...)は「呼び出されたこと自体を検証する」モックとしての使い方です。

戻り値を使わない副作用中心のメソッド(通知処理・ログ出力など)はverifyで検証し、戻り値を使うメソッドはスタブとして設定した上でアサーションで検証する、というように役割を意識して書き分けると、テストの意図が読み取りやすくなります。

まとめ

この記事のポイント

  • @ExtendWith(MockitoExtension.class) + @Mock / @InjectMocksでSpringコンテナを起動せずサービス層をテストできる
  • BDDMockito.given()でgiven-when-thenのスタイルに揃えると可読性が上がる
  • ArgumentCaptorで「どんな引数で呼ばれたか」まで検証できる
  • Mockitoベースと@SpringBootTestベースは、検証したい範囲に応じて使い分ける
  • verifyの多用は実装詳細への過剰な依存を招くため、検証範囲を意識して設計する

次に読むべき記事

テストを書いたあとは、いよいよ本番環境へのデプロイです。
「jarファイルにビルドしてデプロイする方法」で、Spring Bootアプリケーションの公開手順を解説しています。

タグ: Spring Boot, 上級者向け, テスト

タイトルとURLをコピーしました