Gradle 依存関係の設定

JUnit 5 と Mockito を使うための最小限の build.gradle 設定を示します。Spring Boot プロジェクトでは spring-boot-starter-test が両者を一括で含んでいます。

// build.gradle (Gradle 8.x)
plugins {
    id 'java'
}

dependencies {
    // JUnit 5 (Jupiter)
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'

    // Mockito
    testImplementation 'org.mockito:mockito-core:5.11.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0'

    // AssertJ (推奨アサーションライブラリ)
    testImplementation 'org.assertj:assertj-core:3.25.3'
}

test {
    useJUnitPlatform()   // JUnit 5 を有効化する必須設定
}

ℹ️ Spring Boot の場合

testImplementation 'org.springframework.boot:spring-boot-starter-test' を追加するだけで JUnit 5・Mockito・AssertJ がすべて含まれます。

JUnit 5 主要アノテーション

JUnit 4 からの移行で最初に覚えるべきアノテーションをまとめます。

アノテーション役割JUnit 4 相当
@Testテストメソッドを宣言@Test
@BeforeEach各テストの前に実行@Before
@AfterEach各テストの後に実行@After
@BeforeAllクラス内で一度だけ実行(static 必須)@BeforeClass
@AfterAllクラス内で一度だけ実行(static 必須)@AfterClass
@DisplayNameテスト名を日本語で表示なし
@Disabledテストを一時的にスキップ@Ignore
@Tagテストをグループ化(CI フィルタに使用)@Category

基本的なテストクラスのサンプルです。

import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.*;

@DisplayName("CalculatorService のテスト")
class CalculatorServiceTest {

    CalculatorService sut;   // System Under Test

    @BeforeEach
    void setUp() {
        sut = new CalculatorService();   // 毎テスト前に初期化
    }

    @Test
    @DisplayName("正の整数同士の加算は正しい結果を返す")
    void add_positiveNumbers_returnsSum() {
        int result = sut.add(3, 5);
        assertThat(result).isEqualTo(8);
    }

    @Test
    @DisplayName("ゼロ除算は ArithmeticException をスロー")
    void divide_byZero_throwsException() {
        assertThatThrownBy(() -> sut.divide(10, 0))
            .isInstanceOf(ArithmeticException.class)
            .hasMessageContaining("zero");
    }
}

パラメータ化テスト

同じテストロジックを複数の入力値で実行する @ParameterizedTest は、境界値・同値分割を網羅する際に非常に効果的です。

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import static org.assertj.core.api.Assertions.*;

class PasswordValidatorTest {

    PasswordValidator validator = new PasswordValidator();

    // ─────────────────────────────────────────
    // @CsvSource : 複数の入力/期待値ペアを記述
    // ─────────────────────────────────────────
    @ParameterizedTest(name = "パスワード="{0}" → {1}")
    @CsvSource({
        "Abc1234!, true",    // 正常:8文字以上・大文字・数字・記号
        "abc1234!, false",   // NG:大文字なし
        "Abcdefg!, false",   // NG:数字なし
        "Abc1234,  false",   // NG:記号なし
        "Abc1!,    false"    // NG:7文字(最小8文字未満)
    })
    void validate_passwords(String password, boolean expected) {
        assertThat(validator.isValid(password)).isEqualTo(expected);
    }

    // ─────────────────────────────────────────
    // @MethodSource : 複雑なオブジェクトを渡す場合
    // ─────────────────────────────────────────
    @ParameterizedTest
    @MethodSource("provideUserAgeAndCategory")
    void categorize_user(int age, String expectedCategory) {
        assertThat(validator.categorize(age)).isEqualTo(expectedCategory);
    }

    static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments>
    provideUserAgeAndCategory() {
        return java.util.stream.Stream.of(
            Arguments.of(17, "MINOR"),
            Arguments.of(18, "ADULT"),
            Arguments.of(64, "ADULT"),
            Arguments.of(65, "SENIOR")
        );
    }
}

@Nested でテストを構造化する

@Nested を使うとテストクラスを階層化でき、条件ごとにグループを明確に分けられます。IDEのテスト結果ツリーが読みやすくなる利点もあります。

@DisplayName("OrderService")
class OrderServiceTest {

    OrderService sut;

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

    @Nested
    @DisplayName("注文作成")
    class CreateOrder {

        @Test
        @DisplayName("在庫がある場合は注文を作成できる")
        void whenInStock_createSucceeds() {
            Order order = sut.create("ITEM-001", 1);
            assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
        }

        @Test
        @DisplayName("在庫不足の場合は OutOfStockException をスロー")
        void whenOutOfStock_throwsException() {
            assertThatThrownBy(() -> sut.create("ITEM-999", 100))
                .isInstanceOf(OutOfStockException.class);
        }
    }

    @Nested
    @DisplayName("注文キャンセル")
    class CancelOrder {

        @Test
        @DisplayName("CREATED 状態の注文はキャンセル可能")
        void whenCreated_cancelSucceeds() {
            Order order = sut.create("ITEM-001", 1);
            sut.cancel(order.getId());
            assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
        }
    }
}

Mockito 基礎 — モックとスタブ

Mockito では外部依存(リポジトリ・外部API・メール送信など)をモックに置き換え、テスト対象のロジックだけを検証します。

ℹ️ @ExtendWith(MockitoExtension.class) を忘れずに

JUnit 5 で Mockito のアノテーション(@Mock@InjectMocks)を使うには、クラスに @ExtendWith(MockitoExtension.class) が必要です。

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("UserService のテスト")
class UserServiceTest {

    @Mock
    UserRepository userRepository;   // モック化

    @Mock
    EmailService emailService;       // モック化

    @InjectMocks
    UserService sut;                 // モックを自動注入

    @Test
    @DisplayName("登録済みユーザーIDでユーザー名を取得できる")
    void findName_existingUser_returnsName() {
        // ─ Arrange(スタブ定義)─
        User mockUser = new User("U001", "山田太郎");
        when(userRepository.findById("U001")).thenReturn(Optional.of(mockUser));

        // ─ Act ─
        String name = sut.findName("U001");

        // ─ Assert ─
        assertThat(name).isEqualTo("山田太郎");
    }

    @Test
    @DisplayName("存在しないユーザーIDは UserNotFoundException をスロー")
    void findName_nonExistingUser_throwsException() {
        when(userRepository.findById("X999")).thenReturn(Optional.empty());

        assertThatThrownBy(() -> sut.findName("X999"))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("User not found: X999");
    }

    @Test
    @DisplayName("ユーザー登録時にウェルカムメールが送信される")
    void register_sendsWelcomeEmail() {
        User newUser = new User("U002", "鈴木花子");
        when(userRepository.save(any(User.class))).thenReturn(newUser);

        sut.register("鈴木花子", "hanako@example.com");

        // emailService.send() が1回呼ばれたことを検証
        verify(emailService, times(1)).send(eq("hanako@example.com"), anyString());
    }
}

verify と ArgumentCaptor

verify でメソッドの呼び出し回数と引数を検証します。引数の詳細な内容を確認したい場合は ArgumentCaptor を使います。

@Test
@DisplayName("パスワードリセット時にトークンをメールで送信する")
void resetPassword_sendsTokenEmail() {
    // ─ Arrange ─
    User user = new User("U001", "山田太郎");
    user.setEmail("taro@example.com");
    when(userRepository.findById("U001")).thenReturn(Optional.of(user));

    // ─ Act ─
    sut.resetPassword("U001");

    // ─ ArgumentCaptor でメール引数を捕捉 ─
    ArgumentCaptor<String> toCaptor    = ArgumentCaptor.forClass(String.class);
    ArgumentCaptor<String> bodyCaptor  = ArgumentCaptor.forClass(String.class);

    verify(emailService).send(toCaptor.capture(), bodyCaptor.capture());

    // 送信先が正しいか
    assertThat(toCaptor.getValue()).isEqualTo("taro@example.com");
    // メール本文にトークンが含まれているか
    assertThat(bodyCaptor.getValue()).contains("reset-token");
}

@Test
@DisplayName("ユーザーが存在しない場合はメールを送らない")
void resetPassword_nonExistingUser_noEmailSent() {
    when(userRepository.findById("X999")).thenReturn(Optional.empty());

    assertThatThrownBy(() -> sut.resetPassword("X999"))
        .isInstanceOf(UserNotFoundException.class);

    // emailService は一切呼ばれないことを検証
    verifyNoInteractions(emailService);
}

スパイ(@Spy)の活用

スパイは実クラスのインスタンスをラップし、特定のメソッドだけをスタブに差し替えます。完全なモックが難しいクラス(レガシーコードなど)のテストに有効です。

@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {

    // 実クラスを使いつつ一部だけスタブ化
    @Spy
    NotificationService sut = new NotificationService();

    @Test
    @DisplayName("スパイで外部送信だけをスタブ化")
    void notify_callsInternalLogic() {
        // send() だけスタブ(実際の HTTP 送信を回避)
        doReturn(true).when(sut).sendExternalApi(anyString(), anyString());

        boolean result = sut.notify("USER-01", "テスト通知");

        assertThat(result).isTrue();
        // 内部のログ記録ロジックは実際に実行される
        verify(sut).logNotification("USER-01", "テスト通知");
    }
}

// ─────────────────────────────────────
// doReturn vs thenReturn の使い分け
// ─────────────────────────────────────
// スパイで実メソッドの呼び出しを避けたい場合は doReturn() を使う
// doReturn(value).when(spy).method();  // メソッドを呼ばずにスタブ
// when(spy.method()).thenReturn(value); // メソッドが一度実行される(副作用に注意)

実務でのベストプラクティス

⚠️ モックしすぎに注意

すべての依存をモックにすると「実装の詳細を検証するテスト」になりがちです。テストは「仕様(振る舞い)」を検証するものです。モックは外部 I/O(DB・API・メール)に留め、内部ロジックは実オブジェクトで検証するのが基本です。

⚠️ Mockito の静的メソッドモック

mockStatic() は使いやすいですが、静的メソッドへの依存はテスタビリティを下げます。静的メソッドを使う実装はインターフェースでラップして DI 注入できる設計に改善することを検討してください。

テスト名は「状態 → 操作 → 結果」パターンで

メソッド名は methodName_condition_expectedResult() の形式(例:findName_existingUser_returnsName)にするとテスト失敗時に何が壊れたか即座にわかります。

まとめ

本記事で解説したポイントを整理します。

  • @BeforeEach / @AfterEach でセットアップ・クリーンアップを確実に行い、テスト間の独立性を保つ
  • @ParameterizedTest + @CsvSource / @MethodSource で境界値・同値クラスを網羅的にテストする
  • @Nested でテストを「条件ごとのグループ」に整理して可読性を上げる
  • @Mock + @InjectMocks で外部 I/O を排除し、テスト対象ロジックを孤立検証する
  • ArgumentCaptor で「何が渡されたか」まで検証し、振る舞いをきめ細かく確認する
  • @Spy はレガシーコードのテストや一部だけスタブ化したい場合に使い、乱用は避ける