概要
業務アプリケーションのテストで最初にぶつかる壁が「データベースや外部 API に依存するクラスをどうテストするか」です。依存先が動いていないとテストできない、テストのたびにデータを用意するのが面倒――こうした問題を解決するのが Mock(モック)の仕組みです。Mockito は Java のモックフレームワークとして最も広く使われており、JUnit 5 との統合も @ExtendWith(MockitoExtension.class) で簡潔に行えます。この記事では、UserRepository をモック化して UserService のビジネスロジックだけを検証する実務的なテストコードを題材に、when/thenReturn によるスタブ設定、verify による呼び出し検証、@Spy で一部だけ差し替えるパターンを整理します。Mock と Stub と Spy の違いが曖昧なまま使っている方にも、判断基準が見えてくる内容を目指しています。
使いどころ
UserService が UserRepository 経由でデータを取得するロジックを、データベース接続なしで単体テストする
外部決済 API を呼び出す注文処理クラスで、API の応答をスタブ化してタイムアウトやエラー応答時の分岐をテストする
既存のサービスクラスの一部メソッドだけを差し替え(@Spy)、残りは実装のまま動かして結合に近いテストを行う
リポジトリや通知クライアントの呼び出し回数を verify で確認し、二重送信や二重更新の回帰を防ぐ
例外系の戻り値や失敗通知をスタブ化し、普段は再現しにくい分岐を単体テストで固定する
コード例
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
record User(String id, String name, String email) {}
interface UserRepository {
Optional<User> findById(String id);
List<User> findAll();
void save(User user);
}
static class UserService {
private final UserRepository repository;
UserService(UserRepository repository) { this.repository = repository; }
User findById(String id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("ユーザーが見つかりません: " + id));
}
List<User> findActiveUsers() {
return repository.findAll().stream()
.filter(user -> user.email() != null && !user.email().isBlank())
.toList();
}
void register(String id, String name, String email) {
if (repository.findById(id).isPresent()) {
throw new IllegalStateException("ユーザーID が重複しています: " + id);
}
repository.save(new User(id, name, email));
}
}
@Mock UserRepository userRepository;
@InjectMocks UserService userService;
@Test
@DisplayName("存在するユーザーを ID で取得できる")
void findById_returnsUser() {
var expected = new User("U001", "田中太郎", "[email protected]");
when(userRepository.findById("U001")).thenReturn(Optional.of(expected));
User actual = userService.findById("U001");
assertEquals("田中太郎", actual.name());
verify(userRepository, times(1)).findById("U001");
}
@Test
@DisplayName("存在しないユーザーを取得すると例外が発生する")
void findById_throwsWhenNotFound() {
when(userRepository.findById("U999")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> userService.findById("U999"));
}
@Test
@DisplayName("メールアドレスを持つユーザーだけを返す")
void findActiveUsers_filtersBlankEmail() {
when(userRepository.findAll()).thenReturn(List.of(
new User("U001", "田中", "[email protected]"),
new User("U002", "鈴木", ""),
new User("U003", "佐藤", "[email protected]")
));
assertEquals(2, userService.findActiveUsers().size());
}
@Test
@DisplayName("既存ユーザーの登録を試みると例外が発生する")
void register_throwsWhenDuplicate() {
when(userRepository.findById("U001"))
.thenReturn(Optional.of(new User("U001", "田中", "[email protected]")));
assertThrows(IllegalStateException.class,
() -> userService.register("U001", "田中", "[email protected]"));
verify(userRepository, never()).save(any());
}
@Test
@DisplayName("新規ユーザーを正常に登録できる")
void register_savesNewUser() {
when(userRepository.findById("U100")).thenReturn(Optional.empty());
userService.register("U100", "山田花子", "[email protected]");
verify(userRepository).save(argThat(u -> "U100".equals(u.id()) && "山田花子".equals(u.name())));
}
}Version Coverage
record で User などの値オブジェクトを定義すると、テストデータの準備が簡潔になる。Mockito 5.x は Java 11 以上が必須で、Java 17 環境で広く利用されている。final クラスのモック化もデフォルトで有効。
// Java 17: record + @ExtendWith で簡潔に
record User(String id, String name) {}
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository userRepository;
@InjectMocks UserService userService;
@Test void findUser() {
when(userRepository.findById("U001"))
.thenReturn(Optional.of(new User("U001", "田中")));
User result = userService.findById("U001");
assertEquals("田中", result.name());
}
}Library Comparison
注意点
@Mock と @InjectMocks を使うには @ExtendWith(MockitoExtension.class) をテストクラスに付けること。付け忘れるとフィールドが null のまま NullPointerException になる
when/thenReturn で設定していないメソッドを呼ぶと、Mock はデフォルト値(null、0、false、空コレクション)を返す。意図せず null が返って後続で NullPointerException になるケースが多い
verify() はメソッドが「呼ばれたこと」を検証するが、戻り値の正しさは検証しない。ビジネスロジックの結果は別途 assertEquals や assertThat で確認する
@Spy は実オブジェクトの部分的な差し替えに使う。全メソッドを差し替えるなら @Mock で十分であり、@Spy の乱用はテストの意図を不明確にする
final クラスや static メソッドは標準の Mockito ではモック化できない。mockito-inline(Mockito 5 ではデフォルト)を使うか、設計を見直してインターフェースを導入する
モックの振る舞いを細かく設定しすぎると、実装変更のたびにテストも壊れやすくなる。外部との境界に絞って使うのが基本
同じテストで verify の回数や引数検証が増えすぎたら、テスト対象の責務過多や設計の密結合を疑う
FAQ
Mock は呼び出しの検証(verify)が主目的、Stub は固定値を返す設定(when/thenReturn)が主目的です。Spy は実オブジェクトの一部メソッドだけを差し替えます。
通常は when/thenReturn で十分です。@Spy では when を使うと実メソッドが一度呼ばれるため、副作用を避けたい場合は doReturn/when を使います。
Mockito 5 以降はデフォルトで対応しています。Mockito 4 以前では mockito-inline を依存に追加するか、インターフェースを導入します。
外部とのやり取りが仕様として重要な箇所に絞るのが基本です。内部ヘルパーメソッドまで verify し始めると、実装変更に弱いテストになります。
戻り値を数パターン返すだけで十分なら手書きスタブの方が意図が明確なことがあります。呼び出し回数や引数検証が必要になった時点で Mockito を検討すると整理しやすいです。