JUnit

JUnit 完全ガイド — Java単体テストフレームワークの全貌

第1章: JUnitの概要と歴史

1.1 JUnitとは何か

JUnitは、Javaプログラミング言語向けの単体テスト(ユニットテスト)フレームワークである。開発者がコードの個々のユニット(メソッドやクラス)を自動的にテストするための構造化された方法を提供し、ソフトウェアの品質保証において不可欠なツールとなっている。

JUnitの基本的な役割は以下の通りである。

  • テストの自動化: 手動テストに依存せず、コードによってテストを記述・実行できる
  • 回帰テストの容易化: コード変更後に既存の機能が正常に動作するかを即座に確認できる
  • 設計品質の向上: テスト可能なコードを書くことで、自然とモジュール性の高い設計が促進される
  • ドキュメントとしての役割: テストコードは、プロダクションコードの使い方や期待される振る舞いを示す「生きたドキュメント」となる
  • リファクタリングの安全網: 十分なテストがあれば、コードの構造を安心して改善できる

JUnitは単なるテスト実行ツールではなく、テスト駆動開発(TDD)や継続的インテグレーション(CI)の基盤となるフレームワークであり、Javaエコシステムにおいて最も広く使われているテストライブラリである。Maven Centralの統計によると、JUnitは最もダウンロードされているJavaライブラリの一つであり、事実上のJavaテスト標準として確立されている。

1.2 JUnitの歴史

JUnit 3(2000年頃)

JUnitは、Kent BeckとErich Gammaによって開発された。Kent BeckはSmalltalk向けのテストフレームワーク「SUnit」の開発者であり、Erich Gammaは「デザインパターン」(GoF本)の共著者として知られている。二人が飛行機の中で共同開発したというエピソードは有名である。

JUnit 3の主な特徴は以下の通りである。

// JUnit 3 のテスト例
import junit.framework.TestCase;

public class CalculatorTest extends TestCase {

    private Calculator calculator;

    // setUp メソッドで初期化
    protected void setUp() throws Exception {
        super.setUp();
        calculator = new Calculator();
    }

    // tearDown メソッドで後片付け
    protected void tearDown() throws Exception {
        super.tearDown();
        calculator = null;
    }

    // テストメソッドは "test" で始まる命名規約
    public void testAdd() {
        assertEquals(5, calculator.add(2, 3));
    }

    public void testSubtract() {
        assertEquals(1, calculator.subtract(3, 2));
    }

    public void testDivideByZero() {
        try {
            calculator.divide(10, 0);
            fail("ArithmeticException が発生するべき");
        } catch (ArithmeticException e) {
            // 期待通りの例外
        }
    }
}

JUnit 3では、テストクラスは TestCase を継承する必要があり、テストメソッドは test プレフィックスで命名する規約があった。テストスイートは TestSuite クラスを使って構成し、setUp()tearDown() メソッドでテストの前後処理を行った。この設計は当時としては画期的であったが、継承ベースのアプローチには制約もあった。

JUnit 4(2006年)

JUnit 4は、Java 5で導入されたアノテーション機能を活用し、大幅なアーキテクチャ変更を行った。

// JUnit 4 のテスト例
import org.junit.Test;
import org.junit.Before;
import org.junit.After;
import org.junit.Ignore;
import static org.junit.Assert.*;

public class CalculatorTest {

    private Calculator calculator;

    @Before
    public void setUp() {
        calculator = new Calculator();
    }

    @After
    public void tearDown() {
        calculator = null;
    }

    @Test
    public void addTwoPositiveNumbers() {
        assertEquals(5, calculator.add(2, 3));
    }

    @Test
    public void subtractNumbers() {
        assertEquals(1, calculator.subtract(3, 2));
    }

    @Test(expected = ArithmeticException.class)
    public void divideByZeroThrowsException() {
        calculator.divide(10, 0);
    }

    @Test(timeout = 1000)
    public void performanceSensitiveTest() {
        // 1秒以内に完了すべきテスト
        calculator.complexCalculation();
    }

    @Ignore("後で修正予定")
    @Test
    public void incompleteFeature() {
        // 一時的に無効化
    }
}

JUnit 4の主な改善点は以下の通りである。

  • アノテーションベース: @Test, @Before, @After, @BeforeClass, @AfterClass などのアノテーションでテストを定義
  • 継承不要: TestCase の継承が不要となり、POJOでテストを記述可能
  • 柔軟な命名: メソッド名に test プレフィックスが不要
  • 例外テスト: @Test(expected = ...) で例外の発生を宣言的にテスト可能
  • タイムアウト: @Test(timeout = ...) でテストのタイムアウトを設定可能
  • Assume: 前提条件をチェックして、条件を満たさない場合はテストをスキップ
  • Runnerの概念: @RunWith アノテーションでテスト実行方法をカスタマイズ(Parameterized, Suite, Categories など)
  • Ruleの概念: @Rule アノテーションでテストの前後処理を再利用可能な形で定義(TemporaryFolder, ExpectedException, Timeout など)

JUnit 4は10年以上にわたってJavaテストの標準であり続け、Mockito、Spring Test、Hamcrestなど多くのライブラリがJUnit 4との統合を提供した。

JUnit 5(2017年〜)

JUnit 5は、JUnit 4の後継として完全に書き直されたフレームワークである。最大の特徴は、モジュラーアーキテクチャの採用である。JUnit 5は単一のモノリシックなライブラリではなく、3つの主要サブプロジェクトから構成される。

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

この設計変更により、テストの「書き方」と「実行方法」が分離され、拡張性が大幅に向上した。

1.3 xUnitファミリーとの関係

JUnitは「xUnit」と総称されるテストフレームワークファミリーの一員である。xUnitの起源はKent Beckが1998年に開発したSmalltalk向けの「SUnit」にあり、そのコンセプトをJavaに移植したのがJUnitである。

xUnitファミリーの主なメンバーは以下の通りである。

フレームワーク言語備考
SUnitSmalltalkxUnitの始祖
JUnitJava最も広く普及
NUnitC# (.NET).NET向け
xUnit.netC# (.NET)NUnitの後継的存在
pytestPythonPython標準テストランナー
RSpecRubyBDDスタイル
PHPUnitPHPPHP向け
Google TestC++C++向け
JestJavaScriptReact/Node.js向け
go testGoGo標準ライブラリに内蔵
XCTestSwift/Objective-CApple プラットフォーム向け

xUnitファミリーに共通する概念は以下の通りである。

  • テストケース: 個々のテストメソッド
  • テストスイート: テストケースの集合
  • テストフィクスチャ: テストの前後処理(セットアップ・ティアダウン)
  • アサーション: テスト結果の検証
  • テストランナー: テストの実行エンジン

1.4 JUnit 5のアーキテクチャ概要

JUnit 5は以下の3つの主要コンポーネントから構成される。

JUnit Platform

JUnit Platformは、JVM上でテストフレームワークを実行するための基盤を提供する。テストの発見と実行のためのAPIを定義し、IDEやビルドツール(Maven、Gradle)との統合ポイントとなる。

主な責務は以下の通りである。

  • Launcher API: テストの発見・フィルタリング・実行のためのAPI
  • TestEngine SPI: テストエンジンの実装を可能にするサービスプロバイダインターフェース
  • ConsoleLauncher: コマンドラインからテストを実行するためのツール

JUnit Jupiter

JUnit Jupiterは、JUnit 5でテストを記述するためのプログラミングモデルと拡張モデルを提供する。新しいアノテーション(@Test, @ParameterizedTest, @Nested など)と、拡張機能(Extension API)を含む。

JUnit Vintage

JUnit Vintageは、JUnit 3およびJUnit 4で記述された既存のテストをJUnit 5プラットフォーム上で実行するためのTestEngineを提供する。これにより、既存のテストコードを書き換えることなく、段階的にJUnit 5に移行できる。

<!-- Maven での JUnit 5 基本依存関係 -->
<dependencies>
    <!-- JUnit Jupiter(テスト記述用) -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.4</version>
        <scope>test</scope>
    </dependency>

    <!-- JUnit Vintage(JUnit 3/4 との後方互換性) -->
    <dependency>
        <groupId>org.junit.vintage</groupId>
        <artifactId>junit-vintage-engine</artifactId>
        <version>5.11.4</version>
        <scope>test</scope>
    </dependency>
</dependencies>
// Gradle での JUnit 5 基本依存関係
dependencies {
    // JUnit Jupiter(テスト記述用)
    testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'

    // JUnit Vintage(JUnit 3/4 との後方互換性)
    testImplementation 'org.junit.vintage:junit-vintage-engine:5.11.4'
}

tasks.named('test') {
    useJUnitPlatform()
}

JUnit 5のアーキテクチャにより、サードパーティも独自のTestEngineを開発してJUnit Platformの上で実行できる。例えば、Spock FrameworkやKotlinTest(Kotest)などがこの仕組みを利用している。

第2章: JUnit 5のアーキテクチャ詳細

2.1 JUnit Platformの役割

JUnit Platformは、JUnit 5アーキテクチャの基盤層であり、テストフレームワークの実行基盤を提供する。JUnit Platformの最も重要な設計原則は、「テストの記述方法」と「テストの実行方法」の分離である。

JUnit Platformは以下のモジュールから構成される。

モジュール説明
junit-platform-commons共通ユーティリティとアノテーション
junit-platform-engineTestEngine SPIの定義
junit-platform-launcherテスト発見と実行のためのLauncher API
junit-platform-reportingテストレポート生成
junit-platform-runnerJUnit 4 Runner経由での実行(レガシー互換)
junit-platform-suite-apiテストスイート定義用アノテーション
junit-platform-suite-engineテストスイート実行エンジン
junit-platform-consoleコンソールベースのテストランチャー

JUnit Platformは、IDE(IntelliJ IDEA、Eclipse、VS Code)やビルドツール(Maven Surefire、Gradle)がテストを発見・実行するための統一的なインターフェースを提供する。これにより、テストエンジンの開発者はIDEやビルドツールとの統合を個別に実装する必要がない。

2.2 TestEngineインターフェース

TestEngineは、JUnit Platformの中核となるSPI(Service Provider Interface)である。テストの発見と実行を担当し、任意のテストフレームワークをJUnit Platform上で動作させるための拡張ポイントとなる。

package org.junit.platform.engine;

public interface TestEngine {

    /**
     * このエンジンの一意な識別子を返す。
     * 例: "junit-jupiter", "junit-vintage"
     */
    String getId();

    /**
     * テストを発見し、テストディスクリプタのツリーを構築する。
     * @param discoveryRequest テスト発見リクエスト
     * @param uniqueId このエンジンの一意なID
     * @return テストディスクリプタのルート
     */
    TestDescriptor discover(EngineDiscoveryRequest discoveryRequest,
                            UniqueId uniqueId);

    /**
     * 発見されたテストを実行する。
     * @param request 実行リクエスト(テストディスクリプタとリスナーを含む)
     */
    void execute(ExecutionRequest request);
}

JUnit 5にはデフォルトで2つのTestEngineが含まれている。

  • JupiterTestEngine: JUnit Jupiter(JUnit 5スタイル)のテストを実行
  • VintageTestEngine: JUnit 3/4のテストを実行

サードパーティのTestEngineの例としては以下がある。

  • Spock Framework: Groovyベースのテストフレームワーク
  • Kotest: Kotlin向けのテストフレームワーク
  • jqwik: プロパティベーステストエンジン
  • Cucumber: BDDフレームワーク

2.3 Launcherの仕組み

Launcherは、テストの発見・フィルタリング・実行を統合的に管理するAPIである。主にIDEやビルドツールから利用される。

import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.LauncherSession;
import org.junit.platform.launcher.TestPlan;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

import static org.junit.platform.engine.discovery.DiscoverySelectors.*;
import static org.junit.platform.engine.discovery.ClassNameFilter.*;

public class LauncherExample {

    public static void main(String[] args) {
        // テスト発見リクエストの構築
        LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
            .selectors(
                selectPackage("com.example.tests"),
                selectClass(CalculatorTest.class),
                selectMethod(CalculatorTest.class, "testAdd")
            )
            .filters(
                includeClassNamePatterns(".*Test"),
                includeClassNamePatterns(".*Tests")
            )
            .build();

        // Launcherの取得とテスト実行
        try (LauncherSession session = LauncherFactory.openSession()) {
            Launcher launcher = session.getLauncher();

            // テスト計画の取得
            TestPlan testPlan = launcher.discover(request);

            // テスト実行サマリーリスナー
            SummaryGeneratingListener listener = new SummaryGeneratingListener();
            launcher.registerTestExecutionListeners(listener);

            // テスト実行
            launcher.execute(request);

            // 結果サマリーの出力
            TestExecutionSummary summary = listener.getSummary();
            System.out.println("テスト実行数: " + summary.getTestsStartedCount());
            System.out.println("成功数: " + summary.getTestsSucceededCount());
            System.out.println("失敗数: " + summary.getTestsFailedCount());
            System.out.println("スキップ数: " + summary.getTestsSkippedCount());

            // 失敗の詳細出力
            summary.getFailures().forEach(failure -> {
                System.err.println("失敗: " + failure.getTestIdentifier().getDisplayName());
                failure.getException().printStackTrace();
            });
        }
    }
}

TestExecutionListener

TestExecutionListenerインターフェースを実装することで、テスト実行のイベントを監視・カスタマイズできる。

import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.engine.TestExecutionResult;

public class CustomTestListener implements TestExecutionListener {

    @Override
    public void executionStarted(TestIdentifier testIdentifier) {
        if (testIdentifier.isTest()) {
            System.out.println("[開始] " + testIdentifier.getDisplayName());
        }
    }

    @Override
    public void executionFinished(TestIdentifier testIdentifier,
                                   TestExecutionResult result) {
        if (testIdentifier.isTest()) {
            System.out.printf("[完了] %s - %s%n",
                testIdentifier.getDisplayName(),
                result.getStatus());
        }
    }

    @Override
    public void executionSkipped(TestIdentifier testIdentifier, String reason) {
        System.out.printf("[スキップ] %s - 理由: %s%n",
            testIdentifier.getDisplayName(), reason);
    }
}

2.4 Extension Model

JUnit 5のExtension Modelは、JUnit 4のRunnerとRuleの概念を統合・置き換えるものである。JUnit 4では、@RunWithで1つのRunnerしか指定できず、Ruleとの組み合わせに制約があった。JUnit 5では、複数のExtensionを自由に組み合わせることができる。

Extension Modelの主要なインターフェースは以下の通りである。

インターフェース用途
BeforeAllCallbackすべてのテスト前に実行
AfterAllCallbackすべてのテスト後に実行
BeforeEachCallback各テスト前に実行
AfterEachCallback各テスト後に実行
BeforeTestExecutionCallbackテスト実行直前
AfterTestExecutionCallbackテスト実行直後
TestExecutionConditionテスト実行の条件付け
ExecutionCondition実行条件の評価
ParameterResolverテストメソッドのパラメータ解決
TestInstanceFactoryテストインスタンスの生成
TestInstancePostProcessorテストインスタンスの後処理
TestWatcherテスト結果の監視
TestTemplateInvocationContextProviderテストテンプレートのコンテキスト提供

Extensionの登録方法は以下の3つがある。

// 1. @ExtendWith でクラスまたはメソッドレベルで登録
@ExtendWith(TimingExtension.class)
@ExtendWith(DatabaseExtension.class)
class MyTest {
    // ...
}

// 2. @RegisterExtension でフィールドレベルで登録(プログラマティック)
class MyTest {
    @RegisterExtension
    static DatabaseExtension db = DatabaseExtension.builder()
        .setUrl("jdbc:h2:mem:test")
        .build();
}

// 3. ServiceLoader 経由での自動登録
// META-INF/services/org.junit.jupiter.api.extension.Extension ファイルに記述

2.5 モジュール構成

JUnit 5の各モジュールとその役割を詳細に解説する。

junit-jupiter-api

テスト記述に使用するアノテーションとアサーションを含むAPI モジュール。テスト作成者が直接依存するモジュールである。

主な内容:

  • @Test, @ParameterizedTest, @RepeatedTest, @TestFactory
  • @BeforeEach, @AfterEach, @BeforeAll, @AfterAll
  • @DisplayName, @Nested, @Tag, @Disabled
  • Assertions クラス
  • Assumptions クラス
  • Extension API インターフェース

junit-jupiter-engine

JUnit Jupiterのテストを実行するTestEngine実装。ランタイム時に必要。

junit-jupiter-params

パラメータ化テスト機能を提供するモジュール。

主な内容:

  • @ParameterizedTest
  • @ValueSource, @EnumSource, @MethodSource, @CsvSource
  • ArgumentsProvider, ArgumentConverter

junit-platform-launcher

テストランチャーAPI。主にIDEやビルドツールから使用される。

junit-platform-suite

テストスイートの定義と実行を提供する。

import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.SelectPackages;
import org.junit.platform.suite.api.Suite;
import org.junit.platform.suite.api.IncludeTags;

@Suite
@SelectPackages("com.example.tests")
@IncludeTags("integration")
class IntegrationTestSuite {
    // テストスイートのマーカークラス(テストメソッドは不要)
}

@Suite
@SelectClasses({
    UserServiceTest.class,
    OrderServiceTest.class,
    PaymentServiceTest.class
})
class CriticalPathTestSuite {
    // 重要なテストのみを選択
}

依存関係の推奨構成

実際のプロジェクトでは、junit-jupiter アグリゲータモジュールを使用するのが最もシンプルである。このモジュールは junit-jupiter-api, junit-jupiter-engine, junit-jupiter-params を推移的に含む。

<!-- Maven: シンプルな構成 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
</dependency>

<!-- Maven: BOM(Bill of Materials)を使用した構成 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.junit</groupId>
            <artifactId>junit-bom</artifactId>
            <version>5.11.4</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
// Gradle: BOM を使用した構成
dependencies {
    testImplementation platform('org.junit:junit-bom:5.11.4')
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

tasks.named('test') {
    useJUnitPlatform()
}

junit-platform.properties による設定

src/test/resources/junit-platform.properties ファイルで JUnit Platform のグローバル設定を行える。

# テストインスタンスのライフサイクル(デフォルト: per_method)
junit.jupiter.testinstance.lifecycle.default = per_class

# テストメソッドの実行順序
junit.jupiter.testmethod.order.default = org.junit.jupiter.api.MethodOrderer$OrderAnnotation

# パラメータ化テストの表示名フォーマット
junit.jupiter.params.displayname.default = {displayName} [{index}] {argumentsWithNames}

# 並列テスト実行の設定
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4

# 拡張機能の自動検出
junit.jupiter.extensions.autodetection.enabled = true

# タイムアウト設定
junit.jupiter.execution.timeout.default = 30s
junit.jupiter.execution.timeout.testable.method.default = 10s

第3章: 基本的なテスト作成

3.1 @Test アノテーション

JUnit 5における最も基本的なアノテーションが @Test である。テストメソッドにこのアノテーションを付与することで、JUnit Jupiterがそのメソッドをテストとして認識し実行する。

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    @Test
    void additionShouldReturnCorrectSum() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result);
    }

    @Test
    void subtractionShouldReturnCorrectDifference() {
        Calculator calculator = new Calculator();
        int result = calculator.subtract(10, 4);
        assertEquals(6, result);
    }
}

JUnit 5の@Testアノテーション(org.junit.jupiter.api.Test)は、JUnit 4の@Testorg.junit.Test)とは異なるクラスであることに注意が必要である。JUnit 5の @Test には expectedtimeout パラメータが存在しない。これらの機能は assertThrows()assertTimeout() メソッドに置き換えられた。

3.2 テストクラスとテストメソッドの規約

JUnit 5では、テストクラスとテストメソッドに関して以下の規約がある。

テストクラスの規約:

  • トップレベルクラス、staticメンバークラス、または @Nested クラスでなければならない
  • 抽象クラスであってはならない
  • コンストラクタは1つだけ持つことができる
  • public である必要はない(パッケージプライベートで可)

テストメソッドの規約:

  • @Test, @RepeatedTest, @ParameterizedTest, @TestFactory, @TestTemplate のいずれかでアノテートされている
  • void を返す(@TestFactory は例外)
  • private であってはならない
  • static であってはならない(@TestFactory は例外的に可能な場合がある)
  • public である必要はない(パッケージプライベートで可)
// テストクラスはパッケージプライベートでOK
class StringUtilsTest {

    // テストメソッドもパッケージプライベートでOK
    @Test
    void shouldReturnTrueForEmptyString() {
        assertTrue(StringUtils.isEmpty(""));
    }

    @Test
    void shouldReturnTrueForNullString() {
        assertTrue(StringUtils.isEmpty(null));
    }

    @Test
    void shouldReturnFalseForNonEmptyString() {
        assertFalse(StringUtils.isEmpty("hello"));
    }
}

3.3 @DisplayName によるテスト名のカスタマイズ

@DisplayName アノテーションを使用すると、テスト結果レポートに表示されるテスト名をカスタマイズできる。日本語を含む自然言語での記述が可能であり、テストの意図をより明確に伝えることができる。

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("ユーザーサービスのテスト")
class UserServiceTest {

    @Test
    @DisplayName("正常なメールアドレスでユーザーを作成できること")
    void createUserWithValidEmail() {
        UserService service = new UserService();
        User user = service.createUser("taro@example.com", "太郎");
        assertNotNull(user);
        assertEquals("taro@example.com", user.getEmail());
    }

    @Test
    @DisplayName("無効なメールアドレスの場合、IllegalArgumentExceptionがスローされること")
    void throwExceptionForInvalidEmail() {
        UserService service = new UserService();
        assertThrows(IllegalArgumentException.class,
            () -> service.createUser("invalid-email", "太郎"));
    }

    @Test
    @DisplayName("重複するメールアドレスの場合、DuplicateEmailExceptionがスローされること")
    void throwExceptionForDuplicateEmail() {
        UserService service = new UserService();
        service.createUser("taro@example.com", "太郎");
        assertThrows(DuplicateEmailException.class,
            () -> service.createUser("taro@example.com", "花子"));
    }
}

JUnit 5.8以降では、@DisplayNameGeneration アノテーションを使用して、テスト名の自動生成方法をカスタマイズすることも可能である。

import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;

// メソッド名のアンダースコアをスペースに置換
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class User_registration_test {

    @Test
    void should_create_user_with_valid_email() {
        // テスト名は "should create user with valid email" と表示される
    }

    @Test
    void should_fail_when_email_is_null() {
        // テスト名は "should fail when email is null" と表示される
    }
}

カスタムDisplayNameGeneratorを作成することもできる。

class JapaneseDisplayNameGenerator extends DisplayNameGenerator.Standard {

    @Override
    public String generateDisplayNameForClass(Class<?> testClass) {
        return testClass.getSimpleName()
            .replaceAll("Test$", "のテスト")
            .replaceAll("([A-Z])", " $1").trim();
    }

    @Override
    public String generateDisplayNameForMethod(Class<?> testClass,
                                                Method testMethod) {
        return testMethod.getName()
            .replaceAll("_", " ")
            .replaceAll("([A-Z])", " $1").trim();
    }
}

3.4 @Disabled によるテストの無効化

@Disabled アノテーションを使用して、テストクラスまたはテストメソッドを一時的に無効化できる。無効化されたテストはスキップされるが、テスト結果レポートには記録される。

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class PaymentServiceTest {

    @Test
    void processPaymentSuccessfully() {
        // 通常のテスト
    }

    @Disabled("外部決済ゲートウェイのメンテナンス中のため一時無効化 - JIRA-1234")
    @Test
    void processPaymentWithExternalGateway() {
        // このテストはスキップされる
    }

    @Disabled("新機能の実装待ち - Sprint 15で対応予定")
    @Test
    void processRefund() {
        // このテストもスキップされる
    }
}

条件付きでテストを無効化する方法も用意されている。

import org.junit.jupiter.api.condition.*;

class ConditionalTest {

    // OS条件
    @Test
    @EnabledOnOs(OS.MAC)
    void onlyOnMacOs() { }

    @Test
    @DisabledOnOs(OS.WINDOWS)
    void notOnWindows() { }

    // JREバージョン条件
    @Test
    @EnabledOnJre(JRE.JAVA_17)
    void onlyOnJava17() { }

    @Test
    @EnabledForJreRange(min = JRE.JAVA_11, max = JRE.JAVA_21)
    void fromJava11to21() { }

    // システムプロパティ条件
    @Test
    @EnabledIfSystemProperty(named = "env", matches = "staging|production")
    void onlyInStagingOrProduction() { }

    // 環境変数条件
    @Test
    @EnabledIfEnvironmentVariable(named = "CI", matches = "true")
    void onlyInCI() { }

    // カスタム条件
    @Test
    @EnabledIf("customCondition")
    void enabledByCustomCondition() { }

    boolean customCondition() {
        return LocalDate.now().getDayOfWeek() != DayOfWeek.SUNDAY;
    }
}

3.5 テストのライフサイクル

JUnit 5では、テストの前後処理を行うための4つのライフサイクルアノテーションが提供される。

import org.junit.jupiter.api.*;
import java.sql.Connection;
import java.sql.DriverManager;

class DatabaseTest {

    private static Connection connection;
    private UserRepository repository;

    @BeforeAll
    static void initializeDatabase() {
        // テストクラス全体で1回だけ実行される
        // static メソッドでなければならない(PER_METHOD モードの場合)
        System.out.println("=== データベース接続を初期化 ===");
        connection = DriverManager.getConnection("jdbc:h2:mem:testdb");
        createTables(connection);
    }

    @AfterAll
    static void closeDatabase() {
        // テストクラス全体で1回だけ、全テスト完了後に実行される
        System.out.println("=== データベース接続を終了 ===");
        if (connection != null) {
            connection.close();
        }
    }

    @BeforeEach
    void setUp() {
        // 各テストメソッドの前に実行される
        System.out.println("--- テストデータを準備 ---");
        repository = new UserRepository(connection);
        repository.insertTestData();
    }

    @AfterEach
    void tearDown() {
        // 各テストメソッドの後に実行される
        System.out.println("--- テストデータをクリーンアップ ---");
        repository.deleteAllData();
    }

    @Test
    void findUserById() {
        User user = repository.findById(1L);
        assertNotNull(user);
        assertEquals("太郎", user.getName());
    }

    @Test
    void findAllUsers() {
        List<User> users = repository.findAll();
        assertEquals(3, users.size());
    }

    @Test
    void deleteUser() {
        repository.deleteById(1L);
        assertNull(repository.findById(1L));
    }
}

実行順序は以下の通りである。

@BeforeAll(1回)
  │
  ├─ @BeforeEach
  │    テストメソッド1
  │  @AfterEach
  │
  ├─ @BeforeEach
  │    テストメソッド2
  │  @AfterEach
  │
  ├─ @BeforeEach
  │    テストメソッド3
  │  @AfterEach
  │
@AfterAll(1回)

3.6 @TestInstance(PER_CLASS, PER_METHOD)

デフォルトでは、JUnit 5は各テストメソッドの実行前にテストクラスの新しいインスタンスを作成する(PER_METHODモード)。これにより、テスト間の状態の干渉を防いでいる。

@TestInstance(Lifecycle.PER_CLASS) を使用すると、テストクラスのインスタンスをすべてのテストメソッドで共有できる。

import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;

// PER_METHOD(デフォルト): 各テストメソッドごとに新しいインスタンスを作成
class PerMethodTest {
    private int counter = 0;

    @Test
    void firstTest() {
        counter++;
        assertEquals(1, counter); // 常に1
    }

    @Test
    void secondTest() {
        counter++;
        assertEquals(1, counter); // 新しいインスタンスなので常に1
    }
}

// PER_CLASS: テストクラスで1つのインスタンスを共有
@TestInstance(Lifecycle.PER_CLASS)
class PerClassTest {
    private int counter = 0;

    // PER_CLASSモードでは、@BeforeAll/@AfterAll を非staticにできる
    @BeforeAll
    void setUp() {
        System.out.println("非staticな@BeforeAll");
    }

    @Test
    void firstTest() {
        counter++;
        assertEquals(1, counter);
    }

    @Test
    void secondTest() {
        counter++;
        // 実行順序に依存するため注意が必要
        // assertEquals(2, counter); // 順序が保証されないため危険
    }

    @AfterAll
    void tearDown() {
        System.out.println("非staticな@AfterAll");
    }
}

PER_CLASS モードの主な用途は以下の通りである。

  • @BeforeAll / @AfterAll を非staticメソッドとして定義したい場合(Kotlinでは特に有用)
  • テストクラスレベルで高コストなリソースを共有したい場合
  • @Nested クラスで @BeforeAll / @AfterAll を使用したい場合

3.7 Assumptions(前提条件)

Assumptions クラスは、テストの前提条件をチェックするためのメソッドを提供する。前提条件が満たされない場合、テストは失敗ではなくスキップとなる。

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assumptions.*;

class EnvironmentDependentTest {

    @Test
    void testOnlyInDevelopmentEnvironment() {
        // 環境変数をチェック
        assumeTrue("DEV".equals(System.getenv("APP_ENV")),
            "開発環境でのみ実行");

        // 前提条件が満たされた場合のみ以下が実行される
        // ... テストロジック
    }

    @Test
    void testWithAssumingThat() {
        String env = System.getenv("APP_ENV");

        assumingThat("CI".equals(env), () -> {
            // CI環境でのみ実行されるアサーション
            assertEquals(30, calculateTimeout());
        });

        // この部分は常に実行される
        assertNotNull(getDefaultConfig());
    }
}

3.8 テストメソッドのパラメータインジェクション

JUnit 5では、テストメソッドのコンストラクタとメソッドにパラメータを注入できる。組み込みの ParameterResolver として以下が用意されている。

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.TestReporter;
import org.junit.jupiter.api.RepetitionInfo;

class ParameterInjectionTest {

    // TestInfo: テスト名やタグ情報にアクセス
    @Test
    @DisplayName("TestInfoの使用例")
    @Tag("important")
    void testWithTestInfo(TestInfo testInfo) {
        assertEquals("TestInfoの使用例", testInfo.getDisplayName());
        assertTrue(testInfo.getTags().contains("important"));
        System.out.println("テストメソッド: " + testInfo.getTestMethod().get().getName());
        System.out.println("テストクラス: " + testInfo.getTestClass().get().getSimpleName());
    }

    // TestReporter: テスト実行中にキーバリュー情報をレポート
    @Test
    void testWithTestReporter(TestReporter testReporter) {
        testReporter.publishEntry("status", "実行中");
        testReporter.publishEntry("timestamp", Instant.now().toString());
        // IDEやビルドツールのレポートに表示される
    }

    // RepetitionInfo: @RepeatedTest で使用
    @RepeatedTest(5)
    void repeatedTestWithInfo(RepetitionInfo repetitionInfo) {
        System.out.printf("実行 %d / %d%n",
            repetitionInfo.getCurrentRepetition(),
            repetitionInfo.getTotalRepetitions());
    }
}

第4章: アサーション

4.1 Assertions クラスの概要

JUnit 5の org.junit.jupiter.api.Assertions クラスは、テスト結果を検証するための静的メソッドを多数提供する。JUnit 4の Assert クラスの機能を継承しつつ、ラムダ式をサポートするなどの改善が加えられている。

すべてのアサーションメソッドには、失敗時に表示されるカスタムメッセージを指定するオーバーロードが用意されている。JUnit 5では、メッセージは最後の引数として指定する(JUnit 4では最初の引数であった)。

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

class AssertionBasicsTest {

    @Test
    void basicAssertions() {
        // メッセージなし
        assertEquals(4, 2 + 2);

        // 文字列メッセージ
        assertEquals(4, 2 + 2, "2 + 2 は 4 であるべき");

        // 遅延評価メッセージ(ラムダ式)
        // 失敗時にのみメッセージが生成されるため、パフォーマンスに優しい
        assertEquals(4, 2 + 2,
            () -> "計算結果が期待値と異なります: " + complexMessageGeneration());
    }
}

4.2 等価性のアサーション

class EqualityAssertionsTest {

    @Test
    void assertEqualsExamples() {
        // プリミティブ型
        assertEquals(42, calculateAge());
        assertEquals(3.14, calculatePi(), 0.01); // double の場合はデルタ指定
        assertEquals(3.14f, calculatePiFloat(), 0.01f); // float

        // オブジェクト型(equals() メソッドで比較)
        String expected = "Hello, World!";
        String actual = greet("World");
        assertEquals(expected, actual);

        // null の比較
        assertEquals(null, findUserByInvalidId());
    }

    @Test
    void assertNotEqualsExamples() {
        assertNotEquals(0, calculateNonZeroValue());
        assertNotEquals("admin", normalUser.getRole());
    }

    @Test
    void assertSameAndNotSame() {
        // assertSame: 参照の同一性(==)を検証
        String s1 = "hello";
        String s2 = "hello"; // 文字列プールにより同一参照
        assertSame(s1, s2);

        // assertNotSame: 参照が異なることを検証
        String s3 = new String("hello");
        assertNotSame(s1, s3); // 異なるオブジェクト
        assertEquals(s1, s3);  // ただし equals は true
    }

    @Test
    void assertArrayEquals() {
        int[] expected = {1, 2, 3, 4, 5};
        int[] actual = generateSequence(1, 5);
        assertArrayEquals(expected, actual);

        // 多次元配列
        int[][] expected2D = {{1, 2}, {3, 4}};
        int[][] actual2D = generateMatrix(2, 2);
        assertArrayEquals(expected2D, actual2D);

        // double配列(デルタ指定)
        double[] expectedDoubles = {1.0, 2.0, 3.0};
        double[] actualDoubles = {1.001, 1.999, 3.002};
        assertArrayEquals(expectedDoubles, actualDoubles, 0.01);
    }
}

4.3 真偽値と null のアサーション

class BooleanAndNullAssertionsTest {

    @Test
    void assertTrueAndFalse() {
        assertTrue(isValidEmail("user@example.com"));
        assertTrue(list.isEmpty(), "リストは空であるべき");

        assertFalse(isValidEmail("invalid"));
        assertFalse(user.isAdmin(), () -> "ユーザー " + user.getName() + " は管理者でないべき");
    }

    @Test
    void assertNullAndNotNull() {
        // nullチェック
        assertNull(findByInvalidId(-1), "存在しないIDではnullを返すべき");

        // 非nullチェック
        User user = createUser("太郎");
        assertNotNull(user, "ユーザーオブジェクトが作成されるべき");
        assertNotNull(user.getId(), "ユーザーIDが設定されるべき");
    }
}

4.4 例外のアサーション

JUnit 5では、assertThrowsassertDoesNotThrow を使用して、例外の発生/非発生を検証する。

class ExceptionAssertionsTest {

    @Test
    void assertThrowsExample() {
        // 例外がスローされることを検証
        Exception exception = assertThrows(
            IllegalArgumentException.class,
            () -> new User(null, "user@example.com")
        );

        // スローされた例外の詳細を検証
        assertEquals("名前はnullであってはならない", exception.getMessage());
        assertNull(exception.getCause());
    }

    @Test
    void assertThrowsExactType() {
        // 正確な例外型を検証(サブクラスは不合格)
        assertThrowsExactly(
            FileNotFoundException.class,
            () -> readFile("/nonexistent/path")
        );
    }

    @Test
    void assertThrowsWithInheritance() {
        // IOException またはそのサブクラスがスローされることを検証
        IOException exception = assertThrows(
            IOException.class,
            () -> readFile("/nonexistent/path")
        );
        assertTrue(exception instanceof FileNotFoundException);
    }

    @Test
    void assertDoesNotThrowExample() {
        // 例外がスローされないことを検証
        assertDoesNotThrow(() -> {
            calculator.add(Integer.MAX_VALUE, 0);
        });

        // 戻り値を取得することも可能
        String result = assertDoesNotThrow(
            () -> parseJson("{\"name\": \"太郎\"}")
        );
        assertNotNull(result);
    }
}

4.5 タイムアウトのアサーション

class TimeoutAssertionsTest {

    @Test
    void assertTimeoutExample() {
        // 指定時間内に完了することを検証
        // タイムアウトしても、実行は完了まで待つ
        assertTimeout(Duration.ofSeconds(5), () -> {
            // 5秒以内に完了すべき処理
            performDatabaseQuery();
        });

        // 戻り値を取得
        String result = assertTimeout(Duration.ofSeconds(2), () -> {
            return callExternalApi();
        });
        assertEquals("OK", result);
    }

    @Test
    void assertTimeoutPreemptivelyExample() {
        // 指定時間を超えた場合、即座に打ち切る
        // 注意: 別スレッドで実行されるため、ThreadLocal等に注意
        assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
            // 500ms以内に完了すべき処理
            quickOperation();
        });
    }
}

assertTimeoutassertTimeoutPreemptively の違いは重要である。assertTimeout は処理が完了するまで待ってからタイムアウトを判定するが、assertTimeoutPreemptively はタイムアウト時に即座に処理を中断する。後者は別スレッドで処理を実行するため、ThreadLocal を使用するコード(Spring のトランザクション管理など)では予期しない動作を引き起こす可能性がある。

4.6 assertAll(グループアサーション)

assertAll は、複数のアサーションをグループ化して実行する。通常のアサーションでは最初の失敗で処理が中断されるが、assertAll ではすべてのアサーションが実行され、すべての失敗がまとめてレポートされる。

class GroupAssertionTest {

    @Test
    void validateUserProperties() {
        User user = userService.findById(1L);

        // すべてのアサーションが実行され、失敗がまとめて報告される
        assertAll("ユーザー情報の検証",
            () -> assertNotNull(user, "ユーザーが存在すべき"),
            () -> assertEquals("太郎", user.getName(), "名前の検証"),
            () -> assertEquals("taro@example.com", user.getEmail(), "メールの検証"),
            () -> assertTrue(user.isActive(), "アクティブ状態の検証"),
            () -> assertEquals(25, user.getAge(), "年齢の検証")
        );
    }

    @Test
    void nestedGroupAssertions() {
        Address address = addressService.findByUserId(1L);

        // ネストされたグループアサーション
        assertAll("住所情報の検証",
            () -> assertAll("都道府県と市区町村",
                () -> assertEquals("東京都", address.getPrefecture()),
                () -> assertEquals("渋谷区", address.getCity())
            ),
            () -> assertAll("郵便番号と番地",
                () -> assertEquals("150-0001", address.getZipCode()),
                () -> assertNotNull(address.getStreet())
            )
        );
    }
}

4.7 コレクションのアサーション

class CollectionAssertionsTest {

    @Test
    void assertIterableEquals() {
        // Iterableの内容が等しいことを検証(順序も考慮)
        List<String> expected = List.of("apple", "banana", "cherry");
        List<String> actual = fruitService.getAllFruits();
        assertIterableEquals(expected, actual);
    }

    @Test
    void assertLinesMatch() {
        // 文字列リストのパターンマッチング
        List<String> expectedPatterns = List.of(
            "starting.*",           // 正規表現パターン
            "processing \\d+ items", // 正規表現パターン
            ">> skip >>",           // 任意の行数をスキップ
            "completed successfully" // 完全一致
        );
        List<String> actualLog = getLogLines();
        assertLinesMatch(expectedPatterns, actualLog);
    }
}

4.8 AssertJとの連携

JUnit 5の標準アサーションに加え、AssertJライブラリを使用することでより流暢で読みやすいアサーションを記述できる。AssertJはJUnitの代替ではなく補完的に使用する。

<!-- Maven: AssertJ 依存関係 -->
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
import static org.assertj.core.api.Assertions.*;

class AssertJExamplesTest {

    @Test
    void stringAssertions() {
        String result = greeting("World");

        assertThat(result)
            .isNotNull()
            .isNotEmpty()
            .startsWith("Hello")
            .endsWith("World!")
            .contains("World")
            .hasSize(13)
            .matches("Hello, \\w+!");
    }

    @Test
    void collectionAssertions() {
        List<User> users = userService.findAll();

        assertThat(users)
            .isNotEmpty()
            .hasSize(3)
            .extracting(User::getName)
            .containsExactly("太郎", "花子", "次郎")
            .doesNotContain("三郎");

        assertThat(users)
            .filteredOn(User::isActive)
            .hasSize(2)
            .extracting(User::getEmail)
            .allMatch(email -> email.contains("@"));
    }

    @Test
    void exceptionAssertions() {
        assertThatThrownBy(() -> divideByZero())
            .isInstanceOf(ArithmeticException.class)
            .hasMessage("/ by zero")
            .hasNoCause();

        assertThatCode(() -> safeOperation())
            .doesNotThrowAnyException();
    }

    @Test
    void objectAssertions() {
        User user = new User("太郎", "taro@example.com", 25);

        assertThat(user)
            .hasFieldOrPropertyWithValue("name", "太郎")
            .hasFieldOrPropertyWithValue("age", 25)
            .extracting(User::getEmail)
            .isEqualTo("taro@example.com");
    }

    @Test
    void softAssertions() {
        // assertAll の AssertJ版
        User user = userService.findById(1L);

        SoftAssertions.assertSoftly(softly -> {
            softly.assertThat(user.getName()).isEqualTo("太郎");
            softly.assertThat(user.getEmail()).contains("@");
            softly.assertThat(user.getAge()).isBetween(18, 65);
            softly.assertThat(user.isActive()).isTrue();
        });
    }
}

4.9 Hamcrestとの連携

Hamcrestは、マッチャーベースのアサーションライブラリである。JUnit 4では標準で統合されていたが、JUnit 5ではオプションの依存関係となった。

<!-- Maven: Hamcrest 依存関係 -->
<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest</artifactId>
    <version>3.0</version>
    <scope>test</scope>
</dependency>
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

class HamcrestExamplesTest {

    @Test
    void stringMatchers() {
        String result = "Hello, World!";

        assertThat(result, is("Hello, World!"));
        assertThat(result, startsWith("Hello"));
        assertThat(result, containsString("World"));
        assertThat(result, not(emptyOrNullString()));
    }

    @Test
    void collectionMatchers() {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);

        assertThat(numbers, hasSize(5));
        assertThat(numbers, hasItem(3));
        assertThat(numbers, hasItems(1, 3, 5));
        assertThat(numbers, everyItem(greaterThan(0)));
        assertThat(numbers, not(hasItem(0)));
    }

    @Test
    void combinedMatchers() {
        User user = new User("太郎", 25);

        assertThat(user.getName(), allOf(
            notNullValue(),
            not(emptyString()),
            containsString("太郎")
        ));

        assertThat(user.getAge(), allOf(
            greaterThanOrEqualTo(18),
            lessThan(100)
        ));
    }
}

第5章: パラメータ化テスト

5.1 @ParameterizedTest アノテーション

パラメータ化テストは、同じテストロジックを異なる入力値で複数回実行するための機能である。JUnit 4では @RunWith(Parameterized.class) という制約の多い仕組みであったが、JUnit 5ではより柔軟で使いやすいAPIが提供されている。

パラメータ化テストを使用するには、junit-jupiter-params モジュールが必要である。

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
</dependency>

基本的な使用方法は以下の通りである。

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class ParameterizedTestBasicExample {

    @ParameterizedTest
    @ValueSource(strings = {"racecar", "radar", "level", "madam"})
    void isPalindrome(String candidate) {
        assertTrue(StringUtils.isPalindrome(candidate));
    }
}

5.2 @ValueSource

@ValueSource は、リテラル値の配列を指定する最もシンプルなソースである。以下の型をサポートする: short, byte, int, long, float, double, char, boolean, String, Class

class ValueSourceExamplesTest {

    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5})
    void testPositiveNumbers(int number) {
        assertTrue(number > 0);
    }

    @ParameterizedTest
    @ValueSource(strings = {"", " ", "  ", "\t", "\n"})
    void testBlankStrings(String input) {
        assertTrue(input.isBlank());
    }

    @ParameterizedTest
    @ValueSource(doubles = {0.1, 0.2, 0.3, 0.5, 0.9})
    void testFractionalNumbers(double value) {
        assertTrue(value > 0 && value < 1);
    }

    @ParameterizedTest
    @ValueSource(classes = {ArrayList.class, LinkedList.class, Vector.class})
    void testListImplementations(Class<?> listClass) throws Exception {
        List<?> list = (List<?>) listClass.getDeclaredConstructor().newInstance();
        assertNotNull(list);
        assertTrue(list.isEmpty());
    }
}

5.3 @NullSource, @EmptySource, @NullAndEmptySource

これらのアノテーションは null や空の値をテスト引数として提供する。

class NullAndEmptySourceExamplesTest {

    @ParameterizedTest
    @NullSource
    void testWithNull(String input) {
        assertNull(input);
    }

    @ParameterizedTest
    @EmptySource
    void testWithEmptyString(String input) {
        assertNotNull(input);
        assertTrue(input.isEmpty());
    }

    // @NullAndEmptySource は @NullSource + @EmptySource の組み合わせ
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {" ", "  ", "\t", "\n"})
    void testIsBlankOrNullOrEmpty(String input) {
        assertTrue(input == null || input.isBlank());
    }

    // コレクション型にも使用可能
    @ParameterizedTest
    @EmptySource
    void testWithEmptyList(List<String> list) {
        assertTrue(list.isEmpty());
    }

    @ParameterizedTest
    @EmptySource
    void testWithEmptyMap(Map<String, String> map) {
        assertTrue(map.isEmpty());
    }

    @ParameterizedTest
    @EmptySource
    void testWithEmptyArray(int[] array) {
        assertEquals(0, array.length);
    }
}

5.4 @EnumSource

@EnumSource は、Enum定数をテスト引数として使用する。

enum Season { SPRING, SUMMER, AUTUMN, WINTER }
enum Priority { LOW, MEDIUM, HIGH, CRITICAL }

class EnumSourceExamplesTest {

    // すべてのEnum定数でテスト
    @ParameterizedTest
    @EnumSource(Season.class)
    void testAllSeasons(Season season) {
        assertNotNull(season);
    }

    // 特定のEnum定数のみを含める
    @ParameterizedTest
    @EnumSource(value = Season.class, names = {"SPRING", "SUMMER"})
    void testWarmSeasons(Season season) {
        assertTrue(season == Season.SPRING || season == Season.SUMMER);
    }

    // 特定のEnum定数を除外
    @ParameterizedTest
    @EnumSource(value = Priority.class,
                names = {"LOW"},
                mode = EnumSource.Mode.EXCLUDE)
    void testNonLowPriorities(Priority priority) {
        assertNotEquals(Priority.LOW, priority);
    }

    // 正規表現でマッチング
    @ParameterizedTest
    @EnumSource(value = Season.class,
                names = ".*ER",
                mode = EnumSource.Mode.MATCH_ALL)
    void testSeasonsEndingWithER(Season season) {
        assertTrue(season == Season.SUMMER || season == Season.WINTER);
    }
}

5.5 @MethodSource

@MethodSource は、ファクトリメソッドからテスト引数を提供する。最も柔軟なソースであり、複雑な入力データの生成に適している。

import org.junit.jupiter.params.provider.Arguments;
import java.util.stream.Stream;

class MethodSourceExamplesTest {

    // 同名のstaticファクトリメソッドから引数を提供
    @ParameterizedTest
    @MethodSource  // メソッド名を省略すると、テストメソッドと同名のメソッドを探す
    void testIsPositive(int number) {
        assertTrue(number > 0);
    }

    static IntStream testIsPositive() {
        return IntStream.range(1, 10);
    }

    // 明示的にメソッド名を指定
    @ParameterizedTest
    @MethodSource("stringProvider")
    void testNotBlank(String input) {
        assertFalse(input.isBlank());
    }

    static Stream<String> stringProvider() {
        return Stream.of("apple", "banana", "cherry");
    }

    // 複数引数のテスト
    @ParameterizedTest
    @MethodSource("additionProvider")
    void testAddition(int a, int b, int expected) {
        assertEquals(expected, calculator.add(a, b));
    }

    static Stream<Arguments> additionProvider() {
        return Stream.of(
            Arguments.of(1, 1, 2),
            Arguments.of(2, 3, 5),
            Arguments.of(0, 0, 0),
            Arguments.of(-1, 1, 0),
            Arguments.of(-5, -3, -8),
            Arguments.of(Integer.MAX_VALUE, 0, Integer.MAX_VALUE)
        );
    }

    // 外部クラスのメソッドを参照
    @ParameterizedTest
    @MethodSource("com.example.TestDataFactory#emailAddresses")
    void testEmailValidation(String email, boolean expected) {
        assertEquals(expected, EmailValidator.isValid(email));
    }

    // 複雑なオブジェクトを引数として使用
    @ParameterizedTest
    @MethodSource("userProvider")
    void testUserCreation(String name, String email, int age, boolean expectedValid) {
        if (expectedValid) {
            assertDoesNotThrow(() -> new User(name, email, age));
        } else {
            assertThrows(ValidationException.class, () -> new User(name, email, age));
        }
    }

    static Stream<Arguments> userProvider() {
        return Stream.of(
            Arguments.of("太郎", "taro@example.com", 25, true),
            Arguments.of("花子", "hanako@example.com", 30, true),
            Arguments.of("", "test@example.com", 20, false),      // 空の名前
            Arguments.of("太郎", "invalid-email", 25, false),      // 無効なメール
            Arguments.of("太郎", "taro@example.com", -1, false),   // 負の年齢
            Arguments.of("太郎", "taro@example.com", 200, false),  // 不正な年齢
            Arguments.of(null, "taro@example.com", 25, false)      // null名前
        );
    }
}

5.6 @CsvSource と @CsvFileSource

@CsvSource はインラインCSVデータを、@CsvFileSource は外部CSVファイルからテスト引数を提供する。

class CsvSourceExamplesTest {

    @ParameterizedTest
    @CsvSource({
        "apple, 1",
        "banana, 2",
        "'lemon, lime', 3",  // シングルクォートでエスケープ
        "strawberry, 4"
    })
    void testFruitOrder(String fruit, int quantity) {
        assertNotNull(fruit);
        assertTrue(quantity > 0);
    }

    @ParameterizedTest
    @CsvSource(value = {
        "1 + 1 = 2",
        "2 + 3 = 5",
        "10 + -5 = 5",
        "0 + 0 = 0"
    }, delimiterString = " = ")
    void testCalculation(String expression, int expected) {
        // 区切り文字をカスタマイズ
        assertEquals(expected, evaluateExpression(expression));
    }

    // null値の表現
    @ParameterizedTest
    @CsvSource(value = {
        "太郎, taro@example.com, 25",
        "花子, NULL, 30",               // "NULL" が null として解釈される
        "次郎, jiro@example.com, NULL"
    }, nullValues = "NULL")
    void testWithNullValues(String name, String email, Integer age) {
        assertNotNull(name);
        // email と age は null の可能性あり
    }

    // CSVファイルからの読み込み
    @ParameterizedTest
    @CsvFileSource(
        resources = "/test-data/users.csv",  // src/test/resources からの相対パス
        numLinesToSkip = 1,                   // ヘッダー行をスキップ
        encoding = "UTF-8"
    )
    void testFromCsvFile(String name, String email, int age) {
        User user = new User(name, email, age);
        assertTrue(userValidator.isValid(user));
    }
}

src/test/resources/test-data/users.csv の例:

name,email,age
太郎,taro@example.com,25
花子,hanako@example.com,30
次郎,jiro@example.com,28

5.7 @ArgumentsSource とカスタム ArgumentsProvider

独自のデータソースを定義するには、ArgumentsProvider インターフェースを実装する。

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.provider.ArgumentsSource;

// カスタムArgumentsProvider
class RandomIntArgumentsProvider implements ArgumentsProvider {

    @Override
    public Stream<? extends Arguments> provideArguments(
            ExtensionContext context) {
        Random random = new Random(42); // 再現性のための固定シード
        return IntStream.range(0, 10)
            .mapToObj(i -> Arguments.of(random.nextInt(100)));
    }
}

// 使用例
class CustomArgumentsProviderTest {

    @ParameterizedTest
    @ArgumentsSource(RandomIntArgumentsProvider.class)
    void testWithRandomIntegers(int number) {
        assertTrue(number >= 0 && number < 100);
    }
}

// アノテーション合成によるカスタムソース
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ParameterizedTest
@ArgumentsSource(JsonFileArgumentsProvider.class)
@interface JsonFileSource {
    String value(); // JSONファイルパス
}

class JsonFileArgumentsProvider implements ArgumentsProvider, AnnotationConsumer<JsonFileSource> {

    private String filePath;

    @Override
    public void accept(JsonFileSource annotation) {
        this.filePath = annotation.value();
    }

    @Override
    public Stream<? extends Arguments> provideArguments(
            ExtensionContext context) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        InputStream inputStream = getClass().getResourceAsStream(filePath);
        List<Map<String, Object>> data = mapper.readValue(inputStream,
            new TypeReference<List<Map<String, Object>>>() {});
        return data.stream().map(map -> Arguments.of(map));
    }
}

// 使用例
class JsonSourceTest {

    @JsonFileSource("/test-data/users.json")
    void testFromJsonFile(Map<String, Object> userData) {
        assertNotNull(userData.get("name"));
        assertNotNull(userData.get("email"));
    }
}

5.8 引数の変換(ArgumentConverter)

JUnit 5は、テスト引数の型変換を自動的に行う。組み込みの暗黙的変換に加え、カスタムコンバータを作成できる。

import org.junit.jupiter.params.converter.ConvertWith;
import org.junit.jupiter.params.converter.SimpleArgumentConverter;
import org.junit.jupiter.params.converter.TypedArgumentConverter;

class ArgumentConversionTest {

    // 暗黙的変換(String → 各型への自動変換)
    @ParameterizedTest
    @ValueSource(strings = {"2024-01-15", "2024-06-30", "2024-12-31"})
    void testImplicitConversion(LocalDate date) {
        // String が自動的に LocalDate に変換される
        assertNotNull(date);
        assertEquals(2024, date.getYear());
    }

    // カスタムコンバータ
    @ParameterizedTest
    @CsvSource({
        "太郎, taro@example.com, 25",
        "花子, hanako@example.com, 30"
    })
    void testWithCustomConverter(
            String name,
            String email,
            @ConvertWith(UserConverter.class) User user) {
        // 第3引数がカスタムコンバータで変換される
        // (この例では age の int が User に変換される)
    }

    // SimpleArgumentConverter の実装
    static class UserConverter extends SimpleArgumentConverter {
        @Override
        protected Object convert(Object source, Class<?> targetType) {
            if (source instanceof String && targetType == User.class) {
                String[] parts = ((String) source).split(",");
                return new User(parts[0].trim(), parts[1].trim(),
                    Integer.parseInt(parts[2].trim()));
            }
            throw new IllegalArgumentException("変換できません: " + source);
        }
    }

    // TypedArgumentConverter の実装(型安全)
    static class StringToUserConverter
            extends TypedArgumentConverter<String, User> {

        protected StringToUserConverter() {
            super(String.class, User.class);
        }

        @Override
        protected User convert(String source) {
            String[] parts = source.split(",");
            return new User(parts[0].trim(), parts[1].trim(),
                Integer.parseInt(parts[2].trim()));
        }
    }

    // ArgumentsAggregator を使用した引数の集約
    @ParameterizedTest
    @CsvSource({
        "太郎, taro@example.com, 25",
        "花子, hanako@example.com, 30"
    })
    void testWithAggregator(
            @AggregateWith(UserAggregator.class) User user) {
        assertNotNull(user);
        assertNotNull(user.getName());
        assertNotNull(user.getEmail());
    }

    static class UserAggregator implements ArgumentsAggregator {
        @Override
        public Object aggregateArguments(ArgumentsAccessor accessor,
                                          ParameterContext context) {
            return new User(
                accessor.getString(0),
                accessor.getString(1),
                accessor.getInteger(2)
            );
        }
    }
}

5.9 表示名のカスタマイズ

パラメータ化テストの表示名をカスタマイズできる。

class DisplayNameCustomizationTest {

    @ParameterizedTest(name = "#{index}: {0} は回文である")
    @ValueSource(strings = {"racecar", "radar", "level"})
    void palindromeTest(String candidate) {
        assertTrue(StringUtils.isPalindrome(candidate));
    }

    @ParameterizedTest(name = "[{index}] {0} + {1} = {2}")
    @CsvSource({
        "1, 1, 2",
        "2, 3, 5",
        "10, -5, 5"
    })
    void additionTest(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
}

利用可能なプレースホルダー:

  • {index}: テスト呼び出しのインデックス(1始まり)
  • {arguments}: すべての引数のカンマ区切り文字列
  • {argumentsWithNames}: パラメータ名付きの引数文字列
  • {0}, {1}, ... : 個々の引数

第6章: テストの構造化

6.1 @Nested によるネストされたテスト

@Nested アノテーションを使用すると、テストクラスの内部にネストされたテストクラスを作成でき、テストを論理的にグループ化できる。これにより、BDD(振る舞い駆動開発)スタイルのテスト構造を実現できる。

import org.junit.jupiter.api.*;

@DisplayName("スタック")
class StackTest {

    private Stack<Object> stack;

    @Test
    @DisplayName("new Stack() でインスタンスを作成")
    void isInstantiatedWithNew() {
        new Stack<>();
    }

    @Nested
    @DisplayName("空のスタック")
    class WhenNew {

        @BeforeEach
        void createNewStack() {
            stack = new Stack<>();
        }

        @Test
        @DisplayName("空である")
        void isEmpty() {
            assertTrue(stack.isEmpty());
        }

        @Test
        @DisplayName("pop すると EmptyStackException がスローされる")
        void throwsExceptionWhenPopped() {
            assertThrows(EmptyStackException.class, stack::pop);
        }

        @Test
        @DisplayName("peek すると EmptyStackException がスローされる")
        void throwsExceptionWhenPeeked() {
            assertThrows(EmptyStackException.class, stack::peek);
        }

        @Nested
        @DisplayName("要素を push した後")
        class AfterPushing {

            String anElement = "an element";

            @BeforeEach
            void pushAnElement() {
                stack.push(anElement);
            }

            @Test
            @DisplayName("空ではない")
            void isNotEmpty() {
                assertFalse(stack.isEmpty());
            }

            @Test
            @DisplayName("pop すると要素を返し、スタックは空になる")
            void returnElementWhenPopped() {
                assertEquals(anElement, stack.pop());
                assertTrue(stack.isEmpty());
            }

            @Test
            @DisplayName("peek すると要素を返すが、スタックは空にならない")
            void returnElementWhenPeeked() {
                assertEquals(anElement, stack.peek());
                assertFalse(stack.isEmpty());
            }
        }
    }
}

@Nested クラスの制約と特徴:

  • 非 static の内部クラスでなければならない
  • 外部クラスのインスタンスフィールドにアクセス可能
  • @BeforeAll@AfterAll はデフォルトでは使用不可(@TestInstance(PER_CLASS) を使えば可能)
  • ネストの深さに制限はない
  • 各レベルで @BeforeEach@AfterEach を持つことができ、外側から順に実行される

6.2 @Tag によるテストのカテゴリ分け

@Tag アノテーションを使用してテストにタグを付け、特定のタグを持つテストのみを実行・除外できる。

@Tag("unit")
class UserServiceUnitTest {

    @Test
    @Tag("fast")
    void createUser() {
        // 単体テスト
    }

    @Test
    @Tag("fast")
    void validateEmail() {
        // 単体テスト
    }
}

@Tag("integration")
class UserServiceIntegrationTest {

    @Test
    @Tag("slow")
    @Tag("database")
    void createUserInDatabase() {
        // データベースを使用する結合テスト
    }

    @Test
    @Tag("slow")
    @Tag("external-api")
    void syncUserWithExternalService() {
        // 外部APIとの結合テスト
    }
}

カスタムアノテーションとしてタグを定義することも推奨される。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Tag("integration")
@Test
@interface IntegrationTest { }

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Tag("unit")
@Tag("fast")
@Test
@interface UnitTest { }

// 使用例
class OrderServiceTest {

    @UnitTest
    void calculateTotal() { }

    @IntegrationTest
    void placeOrderWithPayment() { }
}

ビルドツールでのタグフィルタリング:

<!-- Maven Surefire: 特定のタグのみ実行 -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.5.2</version>
    <configuration>
        <groups>unit &amp; fast</groups>
        <excludedGroups>slow | external-api</excludedGroups>
    </configuration>
</plugin>
// Gradle: タグフィルタリング
tasks.named('test') {
    useJUnitPlatform {
        includeTags 'unit', 'fast'
        excludeTags 'slow', 'external-api'
    }
}

// 結合テスト専用タスク
tasks.register('integrationTest', Test) {
    useJUnitPlatform {
        includeTags 'integration'
    }
}

6.3 テスト実行順序

デフォルトではテストメソッドの実行順序は保証されないが、@TestMethodOrder で明示的に指定できる。

import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.TestMethodOrder;

// @Order アノテーションによる順序指定
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderedTest {

    @Test
    @Order(1)
    void createUser() {
        // 最初に実行
    }

    @Test
    @Order(2)
    void updateUser() {
        // 2番目に実行
    }

    @Test
    @Order(3)
    void deleteUser() {
        // 3番目に実行
    }
}

// メソッド名のアルファベット順
@TestMethodOrder(MethodOrderer.MethodName.class)
class AlphabeticalOrderTest {

    @Test
    void aTest() { } // 最初

    @Test
    void bTest() { } // 2番目

    @Test
    void cTest() { } // 3番目
}

// ランダム順序(テスト間の依存関係を検出するのに有用)
@TestMethodOrder(MethodOrderer.Random.class)
class RandomOrderTest {

    @Test
    void testA() { }

    @Test
    void testB() { }

    @Test
    void testC() { }
}

// DisplayName のアルファベット順
@TestMethodOrder(MethodOrderer.DisplayName.class)
class DisplayNameOrderTest {

    @Test
    @DisplayName("3. 削除テスト")
    void deleteTest() { }

    @Test
    @DisplayName("1. 作成テスト")
    void createTest() { }

    @Test
    @DisplayName("2. 更新テスト")
    void updateTest() { }
}

テストクラスの実行順序も @TestClassOrder で指定可能である。

import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.TestClassOrder;

@TestClassOrder(ClassOrderer.OrderAnnotation.class)
class OuterTest {

    @Nested
    @Order(1)
    class FirstTest {
        @Test void test1() { }
    }

    @Nested
    @Order(2)
    class SecondTest {
        @Test void test2() { }
    }
}

6.4 @RepeatedTest(繰り返しテスト)

@RepeatedTest を使用すると、同じテストを指定回数繰り返し実行できる。非決定的なテスト(タイミング依存やランダム要素を含むテスト)の信頼性検証に有用である。

import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.RepetitionInfo;

class RepeatedTestExamples {

    @RepeatedTest(10)
    void repeatedTestBasic() {
        int random = ThreadLocalRandom.current().nextInt(100);
        assertTrue(random >= 0 && random < 100);
    }

    // カスタム表示名
    @RepeatedTest(value = 5,
        name = "{displayName} - 反復 {currentRepetition}/{totalRepetitions}")
    @DisplayName("並行性テスト")
    void concurrencyTest(RepetitionInfo repetitionInfo) {
        System.out.printf("実行 %d / %d%n",
            repetitionInfo.getCurrentRepetition(),
            repetitionInfo.getTotalRepetitions());
    }

    // RepetitionInfo を使用
    @RepeatedTest(3)
    void testWithRepetitionInfo(RepetitionInfo info, TestInfo testInfo) {
        int current = info.getCurrentRepetition();
        int total = info.getTotalRepetitions();
        assertTrue(current <= total);
    }
}

6.5 @TestFactory と動的テスト(DynamicTest)

@TestFactory メソッドは、実行時に動的にテストケースを生成する。コンパイル時にテスト数が確定しない場合に有用である。

import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.DynamicContainer;
import org.junit.jupiter.api.DynamicNode;
import org.junit.jupiter.api.TestFactory;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;

class DynamicTestExamples {

    // Collection を返す
    @TestFactory
    Collection<DynamicTest> dynamicTestsFromCollection() {
        return List.of(
            dynamicTest("1つ目のテスト",
                () -> assertTrue(isPalindrome("racecar"))),
            dynamicTest("2つ目のテスト",
                () -> assertEquals(4, calculator.add(2, 2))),
            dynamicTest("3つ目のテスト",
                () -> assertNotNull(new Object()))
        );
    }

    // Stream を返す
    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStream() {
        return Stream.of("racecar", "radar", "level", "madam")
            .map(text -> dynamicTest(
                "「" + text + "」は回文である",
                () -> assertTrue(isPalindrome(text))
            ));
    }

    // ファイルベースの動的テスト
    @TestFactory
    Stream<DynamicTest> dynamicTestsFromFiles() throws IOException {
        Path testDataDir = Path.of("src/test/resources/test-cases");
        return Files.walk(testDataDir)
            .filter(path -> path.toString().endsWith(".json"))
            .map(path -> dynamicTest(
                path.getFileName().toString(),
                () -> {
                    TestCase tc = loadTestCase(path);
                    Object result = execute(tc.getInput());
                    assertEquals(tc.getExpected(), result);
                }
            ));
    }

    // DynamicContainer でグループ化
    @TestFactory
    Stream<DynamicNode> dynamicContainerTests() {
        return Stream.of(
            dynamicContainer("数学関数テスト", Stream.of(
                dynamicTest("abs(-5) = 5",
                    () -> assertEquals(5, Math.abs(-5))),
                dynamicTest("abs(5) = 5",
                    () -> assertEquals(5, Math.abs(5))),
                dynamicTest("abs(0) = 0",
                    () -> assertEquals(0, Math.abs(0)))
            )),
            dynamicContainer("文字列関数テスト", Stream.of(
                dynamicTest("toUpperCase",
                    () -> assertEquals("HELLO", "hello".toUpperCase())),
                dynamicTest("toLowerCase",
                    () -> assertEquals("hello", "HELLO".toLowerCase()))
            ))
        );
    }
}

6.6 テストスイートの構成

JUnit 5では、@Suite アノテーションを使用してテストスイートを構成できる。

import org.junit.platform.suite.api.*;

// パッケージベースのスイート
@Suite
@SelectPackages("com.example.tests")
@IncludeTags("unit")
class UnitTestSuite { }

// クラスベースのスイート
@Suite
@SelectClasses({
    UserServiceTest.class,
    OrderServiceTest.class,
    PaymentServiceTest.class
})
class CriticalPathSuite { }

// 複合フィルタリング
@Suite
@SelectPackages({"com.example.service", "com.example.repository"})
@IncludeTags("fast")
@ExcludeTags("flaky")
@IncludeClassNamePatterns(".*Test")
class FastTestSuite { }
<!-- Maven: テストスイート用依存関係 -->
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-suite</artifactId>
    <version>1.11.4</version>
    <scope>test</scope>
</dependency>

第7章: Extensionモデル

7.1 Extensionインターフェースの概要

JUnit 5のExtensionモデルは、JUnit 4における @RunWith(Runner)と @Rule/@ClassRule の仕組みを統合・置き換えるものである。JUnit 4ではRunnerは1つしか指定できなかったが、JUnit 5のExtensionは複数を自由に組み合わせることができる。

Extension APIは、マーカーインターフェース Extension を基底とし、特定の拡張ポイントに対応するサブインターフェースを実装する形で作成する。

// すべてのExtensionの基底インターフェース(マーカー)
package org.junit.jupiter.api.extension;

public interface Extension {
    // マーカーインターフェース
}

7.2 ライフサイクルコールバック

ライフサイクルコールバックExtensionは、テスト実行の各フェーズに介入する。

import org.junit.jupiter.api.extension.*;

// テスト実行時間を計測するExtension
public class TimingExtension implements
        BeforeAllCallback,
        AfterAllCallback,
        BeforeEachCallback,
        AfterEachCallback,
        BeforeTestExecutionCallback,
        AfterTestExecutionCallback {

    private static final Logger logger = Logger.getLogger(TimingExtension.class.getName());

    // 拡張ポイントごとの名前空間
    private static final ExtensionContext.Namespace NAMESPACE =
        ExtensionContext.Namespace.create(TimingExtension.class);

    @Override
    public void beforeAll(ExtensionContext context) {
        context.getStore(NAMESPACE).put("classStartTime", System.nanoTime());
        logger.info("テストクラス開始: " + context.getDisplayName());
    }

    @Override
    public void afterAll(ExtensionContext context) {
        long startTime = context.getStore(NAMESPACE).get("classStartTime", Long.class);
        long duration = System.nanoTime() - startTime;
        logger.info(String.format("テストクラス完了: %s (%.2f ms)",
            context.getDisplayName(), duration / 1_000_000.0));
    }

    @Override
    public void beforeEach(ExtensionContext context) {
        // 各テストメソッド前
    }

    @Override
    public void afterEach(ExtensionContext context) {
        // 各テストメソッド後
    }

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        context.getStore(NAMESPACE).put("methodStartTime", System.nanoTime());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        long startTime = context.getStore(NAMESPACE)
            .get("methodStartTime", Long.class);
        long duration = System.nanoTime() - startTime;
        logger.info(String.format("  テストメソッド: %s (%.2f ms)",
            context.getDisplayName(), duration / 1_000_000.0));
    }
}

ライフサイクルコールバックの実行順序:

BeforeAllCallback.beforeAll()
  @BeforeAll
    BeforeEachCallback.beforeEach()
      @BeforeEach
        BeforeTestExecutionCallback.beforeTestExecution()
          @Test メソッド
        AfterTestExecutionCallback.afterTestExecution()
      @AfterEach
    AfterEachCallback.afterEach()
  @AfterAll
AfterAllCallback.afterAll()

7.3 TestExecutionCondition と ExecutionCondition

ExecutionCondition を実装することで、テストの実行を条件付きで制御できる。

import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;

// 営業時間内のみテストを実行する条件Extension
public class BusinessHoursCondition implements ExecutionCondition {

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(
            ExtensionContext context) {
        LocalTime now = LocalTime.now();
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(18, 0);

        if (now.isAfter(start) && now.isBefore(end)) {
            return ConditionEvaluationResult.enabled("営業時間内のため実行");
        } else {
            return ConditionEvaluationResult.disabled("営業時間外のためスキップ");
        }
    }
}

// カスタムアノテーションと組み合わせ
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(BusinessHoursCondition.class)
public @interface BusinessHoursOnly { }

// 使用例
class ExternalServiceTest {

    @Test
    @BusinessHoursOnly
    void callExternalApi() {
        // 営業時間内のみ実行
    }
}

環境変数に基づく条件Extension:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(EnabledIfEnvironmentExtension.class)
public @interface EnabledInEnvironment {
    String value(); // "dev", "staging", "production"
}

public class EnabledIfEnvironmentExtension implements ExecutionCondition {

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(
            ExtensionContext context) {
        return context.getElement()
            .flatMap(el -> AnnotationSupport.findAnnotation(
                el, EnabledInEnvironment.class))
            .map(annotation -> {
                String requiredEnv = annotation.value();
                String currentEnv = System.getenv("APP_ENV");
                if (requiredEnv.equalsIgnoreCase(currentEnv)) {
                    return ConditionEvaluationResult.enabled(
                        "環境 " + currentEnv + " で実行");
                }
                return ConditionEvaluationResult.disabled(
                    "環境 " + requiredEnv + " でのみ実行可能");
            })
            .orElse(ConditionEvaluationResult.enabled("アノテーションなし"));
    }
}

7.4 ParameterResolver

ParameterResolver は、テストメソッドやコンストラクタのパラメータを解決するExtensionである。

public class RandomParameterExtension implements ParameterResolver {

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.PARAMETER)
    public @interface Random {
        int min() default 0;
        int max() default 100;
    }

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                      ExtensionContext extensionContext) {
        return parameterContext.isAnnotated(Random.class);
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                    ExtensionContext extensionContext) {
        Random annotation = parameterContext.findAnnotation(Random.class).get();
        int min = annotation.min();
        int max = annotation.max();
        return ThreadLocalRandom.current().nextInt(min, max);
    }
}

// 使用例
@ExtendWith(RandomParameterExtension.class)
class RandomTest {

    @Test
    void testWithRandomValues(
            @RandomParameterExtension.Random(min = 1, max = 100) int value) {
        assertTrue(value >= 1 && value < 100);
    }
}

データベース接続を提供するParameterResolver:

public class DatabaseConnectionExtension
        implements ParameterResolver, BeforeAllCallback, AfterAllCallback {

    private static final Namespace NAMESPACE =
        Namespace.create(DatabaseConnectionExtension.class);

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        Connection connection = DriverManager.getConnection(
            "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1");
        context.getStore(NAMESPACE).put("connection", connection);
    }

    @Override
    public void afterAll(ExtensionContext context) {
        Connection connection = context.getStore(NAMESPACE)
            .get("connection", Connection.class);
        if (connection != null) {
            try { connection.close(); } catch (SQLException e) { /* ignore */ }
        }
    }

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                      ExtensionContext extensionContext) {
        return parameterContext.getParameter().getType() == Connection.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                    ExtensionContext extensionContext) {
        return extensionContext.getStore(NAMESPACE).get("connection", Connection.class);
    }
}

// 使用例
@ExtendWith(DatabaseConnectionExtension.class)
class DatabaseTest {

    @Test
    void queryDatabase(Connection connection) throws SQLException {
        assertNotNull(connection);
        assertFalse(connection.isClosed());
    }
}

7.5 TestWatcher

TestWatcher Extensionは、テスト実行の結果を監視し、成功・失敗・スキップなどのイベントに応じた処理を行う。

public class TestResultLoggerExtension implements TestWatcher {

    private static final Logger logger =
        Logger.getLogger(TestResultLoggerExtension.class.getName());

    @Override
    public void testSuccessful(ExtensionContext context) {
        logger.info("[PASS] " + context.getDisplayName());
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        logger.severe("[FAIL] " + context.getDisplayName()
            + " - 原因: " + cause.getMessage());
    }

    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        logger.warning("[ABORT] " + context.getDisplayName()
            + " - 原因: " + cause.getMessage());
    }

    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        logger.info("[SKIP] " + context.getDisplayName()
            + reason.map(r -> " - 理由: " + r).orElse(""));
    }
}

失敗時にスクリーンショットを取得するTestWatcher:

public class ScreenshotOnFailureExtension implements TestWatcher {

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        String testName = context.getRequiredTestMethod().getName();
        String timestamp = LocalDateTime.now()
            .format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
        String fileName = String.format("screenshots/%s_%s.png",
            testName, timestamp);

        // WebDriverからスクリーンショットを取得
        context.getStore(Namespace.GLOBAL)
            .get("webDriver", WebDriver.class);
        // ... スクリーンショット取得ロジック
    }
}

7.6 @ExtendWith と @RegisterExtension

// @ExtendWith: 宣言的な登録
@ExtendWith(TimingExtension.class)
@ExtendWith(TestResultLoggerExtension.class)
class DeclarativeExtensionTest {

    @Test
    void someTest() { }
}

// 複数のExtensionを1つのアノテーションにまとめる
@ExtendWith({TimingExtension.class, TestResultLoggerExtension.class})
class MultipleExtensionsTest {

    @Test
    void someTest() { }
}

// @RegisterExtension: プログラマティックな登録
class ProgrammaticExtensionTest {

    // static フィールド: クラスレベルとメソッドレベルの両方で動作
    @RegisterExtension
    static DatabaseExtension database = DatabaseExtension.builder()
        .setUrl("jdbc:h2:mem:testdb")
        .setUsername("sa")
        .setPassword("")
        .setInitScript("schema.sql")
        .build();

    // インスタンスフィールド: メソッドレベルでのみ動作
    @RegisterExtension
    HttpClientExtension httpClient = HttpClientExtension.builder()
        .setTimeout(Duration.ofSeconds(5))
        .setBaseUrl("http://localhost:8080")
        .build();

    @Test
    void testWithDatabase() {
        Connection connection = database.getConnection();
        assertNotNull(connection);
    }

    @Test
    void testWithHttpClient() {
        HttpResponse response = httpClient.get("/api/users");
        assertEquals(200, response.statusCode());
    }
}

7.7 カスタムExtensionの作成例 — リトライExtension

テスト失敗時に自動リトライする実用的なExtensionの例を示す。

// カスタムアノテーション
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RetryOnFailure {
    int maxRetries() default 3;
    Class<? extends Throwable>[] retryOn() default {Throwable.class};
}

// Extension実装
public class RetryExtension implements TestExecutionExceptionHandler {

    @Override
    public void handleTestExecutionException(ExtensionContext context,
                                              Throwable throwable) throws Throwable {
        RetryOnFailure annotation = context.getRequiredTestMethod()
            .getAnnotation(RetryOnFailure.class);

        if (annotation == null) {
            throw throwable;
        }

        int maxRetries = annotation.maxRetries();
        Class<? extends Throwable>[] retryOn = annotation.retryOn();

        boolean shouldRetry = Arrays.stream(retryOn)
            .anyMatch(cls -> cls.isInstance(throwable));

        if (!shouldRetry) {
            throw throwable;
        }

        Namespace namespace = Namespace.create(RetryExtension.class,
            context.getRequiredTestMethod());
        ExtensionContext.Store store = context.getStore(namespace);
        int retryCount = store.getOrDefault("retryCount", Integer.class, 0);

        if (retryCount < maxRetries) {
            store.put("retryCount", retryCount + 1);
            Logger.getLogger(RetryExtension.class.getName())
                .warning(String.format("テスト '%s' が失敗。リトライ %d/%d",
                    context.getDisplayName(), retryCount + 1, maxRetries));

            // テストメソッドを再実行
            Method testMethod = context.getRequiredTestMethod();
            Object testInstance = context.getRequiredTestInstance();
            try {
                testMethod.invoke(testInstance);
            } catch (InvocationTargetException e) {
                handleTestExecutionException(context, e.getCause());
            }
        } else {
            throw throwable;
        }
    }
}

// 使用例
@ExtendWith(RetryExtension.class)
class FlakyTest {

    @Test
    @RetryOnFailure(maxRetries = 3, retryOn = {AssertionError.class})
    void sometimesFails() {
        // 不安定なテスト
        double random = Math.random();
        assertTrue(random > 0.3, "ランダム値が低すぎ: " + random);
    }
}

7.8 TempDirectory Extension(組み込みExtension)

JUnit 5には、一時ディレクトリを提供する @TempDir が組み込まれている。

import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;

class TempDirTest {

    // フィールドインジェクション
    @TempDir
    Path tempDir;

    @Test
    void testFileCreation() throws IOException {
        Path file = tempDir.resolve("test.txt");
        Files.writeString(file, "Hello, World!");

        assertTrue(Files.exists(file));
        assertEquals("Hello, World!", Files.readString(file));
    }

    // パラメータインジェクション
    @Test
    void testWithParameterInjection(@TempDir Path anotherTempDir) throws IOException {
        Path file = anotherTempDir.resolve("data.json");
        Files.writeString(file, "{\"key\": \"value\"}");
        assertTrue(Files.exists(file));
    }

    // 共有一時ディレクトリ(static フィールド)
    @TempDir
    static Path sharedTempDir;

    @Test
    void test1() throws IOException {
        Files.writeString(sharedTempDir.resolve("shared.txt"), "data");
    }

    @Test
    void test2() {
        // sharedTempDir は test1 と同じディレクトリ
        assertTrue(Files.exists(sharedTempDir));
    }
}

第8章: モックフレームワークとの連携

8.1 Mockitoとの統合

Mockitoは、Javaで最も広く使われているモックフレームワークであり、JUnit 5とのシームレスな統合が提供されている。

<!-- Maven 依存関係 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.14.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.14.2</version>
    <scope>test</scope>
</dependency>
// Gradle 依存関係
dependencies {
    testImplementation 'org.mockito:mockito-core:5.14.2'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.14.2'
}

MockitoExtension を使用することで、@Mock アノテーションによるモックの自動生成が可能になる。

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 static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService;

    @Test
    void createUser_ShouldSaveAndSendEmail() {
        // Arrange (準備)
        User user = new User("太郎", "taro@example.com");
        when(userRepository.save(any(User.class))).thenReturn(user);
        when(emailService.sendWelcomeEmail(anyString())).thenReturn(true);

        // Act (実行)
        User result = userService.createUser("太郎", "taro@example.com");

        // Assert (検証)
        assertNotNull(result);
        assertEquals("太郎", result.getName());

        // メソッド呼び出しの検証
        verify(userRepository).save(any(User.class));
        verify(emailService).sendWelcomeEmail("taro@example.com");
        verifyNoMoreInteractions(userRepository, emailService);
    }
}

8.2 @Mock, @InjectMocks, @Spy, @Captor

@ExtendWith(MockitoExtension.class)
class MockAnnotationsTest {

    // @Mock: モックオブジェクトを生成
    @Mock
    private UserRepository userRepository;

    // @Spy: 実際のオブジェクトのスパイ(部分モック)
    @Spy
    private List<String> spyList = new ArrayList<>();

    // @InjectMocks: モックを自動注入してインスタンスを作成
    @InjectMocks
    private UserService userService;

    // @Captor: メソッド引数をキャプチャ
    @Captor
    private ArgumentCaptor<User> userCaptor;

    @Test
    void testMock() {
        // モックは全メソッドがスタブ化されている(デフォルトはnull/0/false)
        when(userRepository.findById(1L)).thenReturn(Optional.of(new User("太郎")));

        Optional<User> result = userRepository.findById(1L);
        assertTrue(result.isPresent());
        assertEquals("太郎", result.get().getName());
    }

    @Test
    void testSpy() {
        // スパイは実際のメソッドを呼び出す
        spyList.add("one");
        spyList.add("two");
        assertEquals(2, spyList.size()); // 実際のリスト操作

        // 特定のメソッドだけスタブ化
        doReturn(100).when(spyList).size();
        assertEquals(100, spyList.size()); // スタブ化された値
    }

    @Test
    void testCaptor() {
        // Arrange
        when(userRepository.save(any(User.class))).thenAnswer(
            invocation -> invocation.getArgument(0));

        // Act
        userService.createUser("太郎", "taro@example.com");

        // Assert: キャプチャされた引数を検証
        verify(userRepository).save(userCaptor.capture());
        User capturedUser = userCaptor.getValue();
        assertEquals("太郎", capturedUser.getName());
        assertEquals("taro@example.com", capturedUser.getEmail());
    }
}

8.3 when/thenReturn, verify, doThrow

@ExtendWith(MockitoExtension.class)
class MockitoStubAndVerifyTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    @Test
    void stubbing_thenReturn() {
        // 単純な戻り値のスタブ
        when(orderRepository.findById(1L))
            .thenReturn(Optional.of(new Order(1L, "商品A", 1000)));

        // 連続呼び出しで異なる値を返す
        when(orderRepository.count())
            .thenReturn(0L)   // 1回目
            .thenReturn(1L)   // 2回目
            .thenReturn(2L);  // 3回目以降

        assertEquals(0L, orderRepository.count());
        assertEquals(1L, orderRepository.count());
        assertEquals(2L, orderRepository.count());
        assertEquals(2L, orderRepository.count()); // 以降は最後の値
    }

    @Test
    void stubbing_thenAnswer() {
        // 動的な戻り値
        when(orderRepository.save(any(Order.class)))
            .thenAnswer(invocation -> {
                Order order = invocation.getArgument(0);
                order.setId(100L); // IDを設定
                return order;
            });

        Order order = new Order(null, "商品B", 2000);
        Order saved = orderRepository.save(order);
        assertEquals(100L, saved.getId());
    }

    @Test
    void stubbing_thenThrow() {
        // 例外をスロー
        when(paymentGateway.processPayment(anyDouble()))
            .thenThrow(new PaymentException("決済エラー"));

        assertThrows(PaymentException.class,
            () -> paymentGateway.processPayment(1000.0));
    }

    @Test
    void stubbing_doMethods() {
        // void メソッドのスタブ化には do系メソッドを使用
        doThrow(new RuntimeException("DB エラー"))
            .when(orderRepository).deleteById(anyLong());

        assertThrows(RuntimeException.class,
            () -> orderRepository.deleteById(1L));

        // void メソッドで何もしない
        doNothing().when(orderRepository).deleteAll();

        // void メソッドの呼び出し検証
        orderRepository.deleteAll();
        verify(orderRepository).deleteAll();
    }

    @Test
    void verification() {
        orderService.placeOrder(new Order(null, "商品C", 3000));

        // 呼び出し回数の検証
        verify(orderRepository, times(1)).save(any(Order.class));
        verify(paymentGateway, atLeastOnce()).processPayment(anyDouble());
        verify(orderRepository, never()).deleteById(anyLong());

        // 呼び出し順序の検証
        InOrder inOrder = inOrder(orderRepository, paymentGateway);
        inOrder.verify(orderRepository).save(any(Order.class));
        inOrder.verify(paymentGateway).processPayment(3000.0);

        // タイムアウト付き検証(非同期処理の検証に有用)
        verify(orderRepository, timeout(1000)).save(any(Order.class));
    }
}

8.4 ArgumentMatchers

@ExtendWith(MockitoExtension.class)
class ArgumentMatchersTest {

    @Mock
    private UserRepository userRepository;

    @Test
    void builtInMatchers() {
        // any系
        when(userRepository.findById(anyLong())).thenReturn(Optional.empty());
        when(userRepository.findByName(anyString())).thenReturn(List.of());
        when(userRepository.save(any(User.class))).thenReturn(new User());
        when(userRepository.findByAge(anyInt())).thenReturn(List.of());

        // 比較系
        when(userRepository.findByAge(eq(25))).thenReturn(List.of(new User("太郎")));
        when(userRepository.findByAge(intThat(age -> age >= 18)))
            .thenReturn(List.of());

        // null関連
        when(userRepository.findByName(isNull())).thenReturn(List.of());
        when(userRepository.findByName(isNotNull())).thenReturn(List.of());

        // 文字列系
        when(userRepository.findByName(startsWith("田")))
            .thenReturn(List.of(new User("田中")));
        when(userRepository.findByName(contains("山")))
            .thenReturn(List.of(new User("山田")));
        when(userRepository.findByName(matches(".*@example\\.com")))
            .thenReturn(List.of());
    }

    @Test
    void customMatcher() {
        when(userRepository.save(argThat(user ->
            user.getName() != null && user.getEmail().contains("@")
        ))).thenReturn(new User());

        // カスタムArgumentMatcherクラス
        when(userRepository.save(argThat(new ValidUserMatcher())))
            .thenReturn(new User());
    }

    // カスタムArgumentMatcher
    static class ValidUserMatcher implements ArgumentMatcher<User> {
        @Override
        public boolean matches(User user) {
            return user != null
                && user.getName() != null
                && !user.getName().isEmpty()
                && user.getEmail() != null
                && user.getEmail().contains("@");
        }

        @Override
        public String toString() {
            return "有効なユーザー(名前とメールが非null)";
        }
    }
}

8.5 BDDMockito

BDDMockito は、BDD(振る舞い駆動開発)スタイルの記述を提供する。when/then の代わりに given/willReturnverify の代わりに then().should() を使用する。

import static org.mockito.BDDMockito.*;

@ExtendWith(MockitoExtension.class)
class BDDMockitoTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService;

    @Test
    void shouldCreateUserAndSendWelcomeEmail() {
        // Given (前提条件)
        User expectedUser = new User("太郎", "taro@example.com");
        given(userRepository.save(any(User.class))).willReturn(expectedUser);
        given(emailService.sendWelcomeEmail(anyString())).willReturn(true);

        // When (操作)
        User result = userService.createUser("太郎", "taro@example.com");

        // Then (検証)
        then(userRepository).should().save(any(User.class));
        then(emailService).should().sendWelcomeEmail("taro@example.com");
        then(userRepository).shouldHaveNoMoreInteractions();

        assertThat(result.getName()).isEqualTo("太郎");
    }

    @Test
    void shouldThrowExceptionForDuplicateEmail() {
        // Given
        given(userRepository.existsByEmail("taro@example.com")).willReturn(true);

        // When & Then
        thenThrownBy(() ->
            userService.createUser("太郎", "taro@example.com"))
            .isInstanceOf(DuplicateEmailException.class);
    }
}

8.6 WireMockとの連携

WireMockは、HTTPベースのAPIをモックするためのライブラリである。外部APIとの結合テストに有用。

<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-standalone</artifactId>
    <version>3.10.0</version>
    <scope>test</scope>
</dependency>
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import static com.github.tomakehurst.wiremock.client.WireMock.*;

@WireMockTest(httpPort = 8089)
class ExternalApiClientTest {

    @Test
    void shouldFetchUserFromExternalApi() {
        // WireMockスタブの設定
        stubFor(get(urlPathEqualTo("/api/users/1"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                        "id": 1,
                        "name": "太郎",
                        "email": "taro@example.com"
                    }
                    """)));

        // テスト対象のクライアント
        ExternalApiClient client = new ExternalApiClient("http://localhost:8089");
        User user = client.fetchUser(1L);

        assertEquals("太郎", user.getName());
        assertEquals("taro@example.com", user.getEmail());

        // リクエストの検証
        verify(getRequestedFor(urlPathEqualTo("/api/users/1"))
            .withHeader("Accept", equalTo("application/json")));
    }

    @Test
    void shouldHandleServerError() {
        stubFor(get(urlPathEqualTo("/api/users/999"))
            .willReturn(aResponse()
                .withStatus(500)
                .withBody("Internal Server Error")));

        ExternalApiClient client = new ExternalApiClient("http://localhost:8089");
        assertThrows(ApiException.class, () -> client.fetchUser(999L));
    }

    @Test
    void shouldHandleTimeout() {
        stubFor(get(urlPathEqualTo("/api/users/1"))
            .willReturn(aResponse()
                .withStatus(200)
                .withFixedDelay(5000) // 5秒の遅延
                .withBody("{}")));

        ExternalApiClient client = new ExternalApiClient("http://localhost:8089");
        client.setTimeout(Duration.ofSeconds(2));

        assertThrows(TimeoutException.class, () -> client.fetchUser(1L));
    }
}

// @RegisterExtension を使用したプログラマティック設定
class WireMockExtensionTest {

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(
            wireMockConfig()
                .dynamicPort()
                .usingFilesUnderClasspath("wiremock")
        )
        .build();

    @Test
    void testWithDynamicPort() {
        wireMock.stubFor(get("/api/health")
            .willReturn(ok("{\"status\": \"UP\"}")));

        String baseUrl = wireMock.baseUrl();
        // baseUrl を使用してテスト
    }
}

第9章: Spring Bootテストとの統合

9.1 @SpringBootTest

Spring Boot Test は、JUnit 5とSpring Bootアプリケーションを統合するためのフレームワークである。@SpringBootTest はSpringの ApplicationContext を完全にロードし、結合テストを可能にする。

<!-- Maven 依存関係 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- 以下が自動的に含まれる:
     - JUnit 5 (junit-jupiter)
     - Mockito (mockito-core, mockito-junit-jupiter)
     - AssertJ
     - Hamcrest
     - JSONassert
     - JsonPath
     - Spring Test
-->
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
        userRepository.save(new User("太郎", "taro@example.com", 25));
    }

    @Test
    void shouldReturnAllUsers() {
        ResponseEntity<User[]> response = restTemplate.getForEntity(
            "http://localhost:" + port + "/api/users",
            User[].class);

        assertEquals(HttpStatus.OK, response.getStatusCode());
        assertNotNull(response.getBody());
        assertEquals(1, response.getBody().length);
    }

    @Test
    void shouldCreateUser() {
        User newUser = new User("花子", "hanako@example.com", 30);
        ResponseEntity<User> response = restTemplate.postForEntity(
            "http://localhost:" + port + "/api/users",
            newUser, User.class);

        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertNotNull(response.getBody().getId());
    }
}

webEnvironment の設定オプション:

  • MOCK(デフォルト): MockServletContextを使用。実際のサーバーは起動しない
  • RANDOM_PORT: ランダムなポートで実際のサーバーを起動
  • DEFINED_PORT: application.properties で定義されたポートでサーバーを起動
  • NONE: Webサーバーを起動しない

9.2 スライステスト

Spring Boot Test は、特定のレイヤーのみをテストするための「スライステスト」アノテーションを提供する。フルコンテキストをロードするよりも高速である。

@WebMvcTest

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void shouldReturnUserById() throws Exception {
        User user = new User(1L, "太郎", "taro@example.com");
        when(userService.findById(1L)).thenReturn(Optional.of(user));

        mockMvc.perform(get("/api/users/1")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("太郎"))
            .andExpect(jsonPath("$.email").value("taro@example.com"));
    }

    @Test
    void shouldReturn404WhenUserNotFound() throws Exception {
        when(userService.findById(999L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound());
    }

    @Test
    void shouldCreateUser() throws Exception {
        User user = new User(1L, "太郎", "taro@example.com");
        when(userService.createUser(any(UserCreateRequest.class)))
            .thenReturn(user);

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "name": "太郎",
                        "email": "taro@example.com"
                    }
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("太郎"));
    }

    @Test
    void shouldReturnValidationError() throws Exception {
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "name": "",
                        "email": "invalid-email"
                    }
                    """))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors.length()").value(2));
    }
}

@DataJpaTest

import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldFindUserByEmail() {
        // Arrange
        User user = new User("太郎", "taro@example.com", 25);
        entityManager.persistAndFlush(user);

        // Act
        Optional<User> found = userRepository.findByEmail("taro@example.com");

        // Assert
        assertTrue(found.isPresent());
        assertEquals("太郎", found.get().getName());
    }

    @Test
    void shouldReturnEmptyForNonExistentEmail() {
        Optional<User> found = userRepository.findByEmail("nonexistent@example.com");
        assertFalse(found.isPresent());
    }

    @Test
    void shouldFindActiveUsersByAge() {
        entityManager.persist(new User("太郎", "taro@example.com", 25, true));
        entityManager.persist(new User("花子", "hanako@example.com", 30, true));
        entityManager.persist(new User("次郎", "jiro@example.com", 25, false));
        entityManager.flush();

        List<User> users = userRepository.findActiveUsersByAge(25);
        assertEquals(1, users.size());
        assertEquals("太郎", users.get(0).getName());
    }
}

@WebFluxTest

import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.test.web.reactive.server.WebTestClient;

@WebFluxTest(UserController.class)
class UserControllerWebFluxTest {

    @Autowired
    private WebTestClient webTestClient;

    @MockBean
    private UserService userService;

    @Test
    void shouldReturnUser() {
        User user = new User(1L, "太郎", "taro@example.com");
        when(userService.findById(1L)).thenReturn(Mono.just(user));

        webTestClient.get()
            .uri("/api/users/1")
            .accept(MediaType.APPLICATION_JSON)
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.name").isEqualTo("太郎")
            .jsonPath("$.email").isEqualTo("taro@example.com");
    }

    @Test
    void shouldReturnUserStream() {
        when(userService.findAll()).thenReturn(Flux.just(
            new User(1L, "太郎", "taro@example.com"),
            new User(2L, "花子", "hanako@example.com")
        ));

        webTestClient.get()
            .uri("/api/users")
            .accept(MediaType.APPLICATION_JSON)
            .exchange()
            .expectStatus().isOk()
            .expectBodyList(User.class)
            .hasSize(2);
    }
}

9.3 @MockBean と @SpyBean

@SpringBootTest
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    // Spring ApplicationContext内のBeanをモックに置換
    @MockBean
    private PaymentGateway paymentGateway;

    // Spring ApplicationContext内のBeanをスパイに置換
    @SpyBean
    private EmailService emailService;

    @Test
    void shouldProcessOrderWithMockedPayment() {
        when(paymentGateway.charge(anyDouble())).thenReturn(true);

        Order order = orderService.placeOrder("商品A", 1000);

        assertNotNull(order);
        assertEquals(OrderStatus.COMPLETED, order.getStatus());
        verify(paymentGateway).charge(1000.0);
        verify(emailService).sendOrderConfirmation(any(Order.class));
    }
}

9.4 TestRestTemplate と WebTestClient

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ApiIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void testWithTestRestTemplate() {
        // GET
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/api/health", String.class);
        assertEquals(HttpStatus.OK, response.getStatusCode());

        // POST
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<String> request = new HttpEntity<>(
            "{\"name\": \"太郎\"}", headers);
        ResponseEntity<User> createResponse = restTemplate.postForEntity(
            "/api/users", request, User.class);
        assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());

        // PUT
        restTemplate.put("/api/users/1", request);

        // DELETE
        restTemplate.delete("/api/users/1");

        // Basic認証付き
        ResponseEntity<String> authResponse = restTemplate
            .withBasicAuth("admin", "password")
            .getForEntity("/api/admin", String.class);
        assertEquals(HttpStatus.OK, authResponse.getStatusCode());
    }
}

9.5 テストプロファイルとテスト設定

# src/test/resources/application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  mail:
    host: localhost
    port: 3025

logging:
  level:
    org.springframework: WARN
    com.example: DEBUG
    org.hibernate.SQL: DEBUG

app:
  external-api:
    base-url: http://localhost:8089
    timeout: 5s
  feature:
    new-payment-flow: true
// テストプロファイルの使用
@SpringBootTest
@ActiveProfiles("test")
class TestProfileExample {
    // application-test.yml の設定が使用される
}

// テスト固有の設定をオーバーライド
@SpringBootTest(properties = {
    "app.external-api.base-url=http://localhost:9999",
    "app.feature.new-payment-flow=false"
})
class PropertyOverrideTest {
    // 特定のプロパティをオーバーライド
}

// @TestConfiguration でテスト用Beanを定義
@SpringBootTest
class TestConfigurationExample {

    @TestConfiguration
    static class TestConfig {

        @Bean
        public Clock clock() {
            // テスト用に固定時刻を返すClockを提供
            return Clock.fixed(
                Instant.parse("2025-01-15T10:00:00Z"),
                ZoneId.of("Asia/Tokyo"));
        }

        @Bean
        public ExternalApiClient externalApiClient() {
            return new MockExternalApiClient();
        }
    }

    @Autowired
    private Clock clock;

    @Test
    void shouldUseFixedClock() {
        LocalDateTime now = LocalDateTime.now(clock);
        assertEquals(2025, now.getYear());
        assertEquals(1, now.getMonthValue());
    }
}

9.6 @DirtiesContext

@DirtiesContext は、テスト実行後にSpring ApplicationContextを再作成することを指示する。テストがApplicationContextの状態を変更する場合に使用する。

@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DirtiesContextExample {

    @Autowired
    private CacheManager cacheManager;

    @Test
    @Order(1)
    void testThatModifiesContext() {
        cacheManager.getCache("users").put("key", "value");
    }

    @Test
    @Order(2)
    @DirtiesContext  // このテスト後にコンテキストを再作成
    void testAfterContextModification() {
        // キャッシュにデータが残っている可能性
    }

    @Test
    @Order(3)
    void testWithCleanContext() {
        // 新しいApplicationContextで実行される
    }
}

第10章: データベーステスト

10.1 インメモリデータベース(H2)でのテスト

H2データベースは、Javaで書かれたインメモリデータベースであり、テスト環境で広く使用されている。本番環境のデータベース(PostgreSQL、MySQL等)を模倣しつつ、高速なテスト実行を可能にする。

<!-- Maven 依存関係 -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>
# src/test/resources/application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    database-platform: org.hibernate.dialect.H2Dialect
    show-sql: true
  h2:
    console:
      enabled: true
@DataJpaTest
@ActiveProfiles("test")
class UserRepositoryH2Test {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @BeforeEach
    void setUp() {
        User user1 = User.builder()
            .name("太郎").email("taro@example.com").age(25).active(true)
            .build();
        User user2 = User.builder()
            .name("花子").email("hanako@example.com").age(30).active(true)
            .build();
        User user3 = User.builder()
            .name("次郎").email("jiro@example.com").age(22).active(false)
            .build();

        entityManager.persist(user1);
        entityManager.persist(user2);
        entityManager.persist(user3);
        entityManager.flush();
    }

    @Test
    void shouldFindActiveUsers() {
        List<User> activeUsers = userRepository.findByActiveTrue();
        assertEquals(2, activeUsers.size());
    }

    @Test
    void shouldFindUsersByAgeRange() {
        List<User> users = userRepository.findByAgeBetween(20, 26);
        assertEquals(2, users.size());
    }

    @Test
    void shouldCountByActive() {
        long count = userRepository.countByActiveTrue();
        assertEquals(2, count);
    }

    @Test
    void shouldFindByEmailContaining() {
        List<User> users = userRepository.findByEmailContaining("example.com");
        assertEquals(3, users.size());
    }

    @Test
    void shouldExecuteCustomQuery() {
        // @Query を使用したカスタムクエリのテスト
        List<UserSummary> summaries = userRepository.getUserSummaries();
        assertFalse(summaries.isEmpty());
    }
}

10.2 @Sql / @SqlGroup

@Sql アノテーションを使用して、テスト前後にSQLスクリプトを実行できる。

@DataJpaTest
@ActiveProfiles("test")
class SqlAnnotationTest {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    @Sql("/sql/create-users.sql")
    void testWithSqlScript() {
        Integer count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM users", Integer.class);
        assertEquals(5, count);
    }

    @Test
    @Sql(scripts = "/sql/create-users.sql",
         executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(scripts = "/sql/cleanup-users.sql",
         executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    void testWithBeforeAndAfterScripts() {
        // テスト実行
    }

    @Test
    @SqlGroup({
        @Sql(value = "/sql/schema.sql",
             config = @SqlConfig(encoding = "UTF-8")),
        @Sql(value = "/sql/test-data.sql",
             config = @SqlConfig(
                 transactionMode = SqlConfig.TransactionMode.ISOLATED,
                 errorMode = SqlConfig.ErrorMode.CONTINUE_ON_ERROR
             ))
    })
    void testWithMultipleScripts() {
        // 複数のSQLスクリプトを実行
    }

    @Test
    @Sql(statements = {
        "INSERT INTO users (name, email, age) VALUES ('太郎', 'taro@example.com', 25)",
        "INSERT INTO users (name, email, age) VALUES ('花子', 'hanako@example.com', 30)"
    })
    void testWithInlineStatements() {
        Integer count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM users", Integer.class);
        assertEquals(2, count);
    }
}

src/test/resources/sql/create-users.sql:

INSERT INTO users (name, email, age, active) VALUES ('太郎', 'taro@example.com', 25, true);
INSERT INTO users (name, email, age, active) VALUES ('花子', 'hanako@example.com', 30, true);
INSERT INTO users (name, email, age, active) VALUES ('次郎', 'jiro@example.com', 22, false);
INSERT INTO users (name, email, age, active) VALUES ('三郎', 'saburo@example.com', 28, true);
INSERT INTO users (name, email, age, active) VALUES ('四郎', 'shiro@example.com', 35, true);

10.3 Testcontainersとの連携

Testcontainersは、Dockerコンテナを使用して本番と同じデータベースでテストを実行するためのライブラリである。H2のような互換モードでは再現できない本番データベース固有の挙動をテストできる。

<!-- Maven 依存関係 -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.20.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.20.4</version>
    <scope>test</scope>
</dependency>
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
@ActiveProfiles("testcontainers")
class PostgreSQLIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test")
        .withInitScript("sql/schema.sql");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldSaveAndRetrieveUser() {
        User user = new User("太郎", "taro@example.com", 25);
        User saved = userRepository.save(user);

        assertNotNull(saved.getId());

        Optional<User> found = userRepository.findById(saved.getId());
        assertTrue(found.isPresent());
        assertEquals("太郎", found.get().getName());
    }

    @Test
    void shouldExecuteNativeQuery() {
        // PostgreSQL固有の機能をテスト
        userRepository.save(new User("太郎", "taro@example.com", 25));
        userRepository.save(new User("花子", "hanako@example.com", 30));

        // PostgreSQL固有のJSON関数やウィンドウ関数のテスト等
        List<User> users = userRepository.findUsersWithRank();
        assertFalse(users.isEmpty());
    }
}

共通のTestcontainers設定を抽象クラスで共有:

@Testcontainers
public abstract class AbstractPostgreSQLTest {

    @Container
    protected static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

// 継承して使用
@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest extends AbstractPostgreSQLTest {

    @Autowired
    private UserService userService;

    @Test
    void shouldCreateUser() {
        User user = userService.createUser("太郎", "taro@example.com");
        assertNotNull(user.getId());
    }
}

10.4 トランザクション管理(@Transactional)

@Transactional をテストクラスまたはメソッドに付与すると、テスト完了後にロールバックが自動的に実行される。これにより、テストデータがデータベースに残らず、テスト間の独立性が保たれる。

@SpringBootTest
@Transactional  // 各テスト後に自動ロールバック
class TransactionalTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldSaveUser() {
        User user = userRepository.save(new User("太郎", "taro@example.com", 25));
        assertNotNull(user.getId());
        // テスト後に自動ロールバック → データは残らない
    }

    @Test
    void shouldSaveOrder() {
        // 前のテストのデータは存在しない
        assertEquals(0, userRepository.count());

        User user = userRepository.save(new User("花子", "hanako@example.com", 30));
        Order order = orderRepository.save(new Order(user, "商品A", 1000));
        assertNotNull(order.getId());
    }

    @Test
    @Rollback(false)  // ロールバックを無効化(デバッグ用)
    void shouldCommitData() {
        userRepository.save(new User("次郎", "jiro@example.com", 22));
        // このテストのデータはコミットされる
    }

    @Test
    @Commit  // @Rollback(false) と同じ
    void shouldAlsoCommitData() {
        userRepository.save(new User("三郎", "saburo@example.com", 28));
    }
}

10.5 テストデータの準備と管理

テストデータの準備には複数のアプローチがある。

// 1. @BeforeEach での直接作成
@SpringBootTest
@Transactional
class DirectDataCreationTest {

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.save(User.builder()
            .name("太郎").email("taro@example.com").age(25).build());
        userRepository.save(User.builder()
            .name("花子").email("hanako@example.com").age(30).build());
    }

    @Test
    void testSomething() {
        assertEquals(2, userRepository.count());
    }
}

// 2. テストデータビルダーパターン
class TestDataFactory {

    public static User.UserBuilder defaultUser() {
        return User.builder()
            .name("テストユーザー")
            .email("test@example.com")
            .age(25)
            .active(true);
    }

    public static Order.OrderBuilder defaultOrder(User user) {
        return Order.builder()
            .user(user)
            .product("テスト商品")
            .amount(1000)
            .status(OrderStatus.PENDING);
    }
}

// 使用例
@SpringBootTest
@Transactional
class TestDataFactoryUsageTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void testWithFactory() {
        User user = userRepository.save(
            TestDataFactory.defaultUser()
                .name("カスタム名前")
                .age(30)
                .build()
        );

        assertEquals("カスタム名前", user.getName());
        assertEquals(30, user.getAge());
        assertTrue(user.isActive()); // デフォルト値
    }
}

// 3. Fixture Monkey(テストデータ自動生成ライブラリ)
// ランダムなテストデータを自動生成する
class FixtureMonkeyTest {

    private static final FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
        .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE)
        .build();

    @Test
    void testWithRandomData() {
        User user = fixtureMonkey.giveMeOne(User.class);
        assertNotNull(user.getName());
        assertNotNull(user.getEmail());
    }

    @Test
    void testWithConstrainedRandomData() {
        User user = fixtureMonkey.giveMeBuilder(User.class)
            .set("age", Arbitraries.integers().between(18, 65))
            .set("active", true)
            .sample();

        assertTrue(user.getAge() >= 18 && user.getAge() <= 65);
        assertTrue(user.isActive());
    }
}

10.6 DatabaseRider によるデータベーステスト

DatabaseRiderは、データセットベースのデータベーステストを簡潔に記述するためのライブラリである。

<dependency>
    <groupId>com.github.database-rider</groupId>
    <artifactId>rider-junit5</artifactId>
    <version>1.44.0</version>
    <scope>test</scope>
</dependency>
import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.junit5.api.DBRider;

@DBRider
@SpringBootTest
class DatabaseRiderTest {

    @Autowired
    private UserService userService;

    @Test
    @DataSet("datasets/users.yml")
    void shouldFindAllUsers() {
        List<User> users = userService.findAll();
        assertEquals(3, users.size());
    }

    @Test
    @DataSet("datasets/users.yml")
    @ExpectedDataSet("datasets/expected-users-after-delete.yml")
    void shouldDeleteUser() {
        userService.deleteById(1L);
    }
}

src/test/resources/datasets/users.yml:

users:
  - id: 1
    name: "太郎"
    email: "taro@example.com"
    age: 25
    active: true
  - id: 2
    name: "花子"
    email: "hanako@example.com"
    age: 30
    active: true
  - id: 3
    name: "次郎"
    email: "jiro@example.com"
    age: 22
    active: false

第11章: テストカバレッジと品質指標

11.1 JaCoCo の設定と使い方

JaCoCo(Java Code Coverage)は、Javaプログラムのコードカバレッジを計測するためのオープンソースツールである。バイトコードの計装(instrumentation)によりカバレッジを収集し、レポートを生成する。

Mavenでの設定

<build>
    <plugins>
        <!-- JaCoCo プラグイン -->
        <plugin>
            <groupId>org.jacoco</groupId>
            <artifactId>jacoco-maven-plugin</artifactId>
            <version>0.8.12</version>
            <executions>
                <!-- テスト実行前にエージェントを準備 -->
                <execution>
                    <id>prepare-agent</id>
                    <goals>
                        <goal>prepare-agent</goal>
                    </goals>
                </execution>

                <!-- テスト実行後にレポートを生成 -->
                <execution>
                    <id>report</id>
                    <phase>test</phase>
                    <goals>
                        <goal>report</goal>
                    </goals>
                </execution>

                <!-- カバレッジ閾値のチェック -->
                <execution>
                    <id>check</id>
                    <goals>
                        <goal>check</goal>
                    </goals>
                    <configuration>
                        <rules>
                            <rule>
                                <element>BUNDLE</element>
                                <limits>
                                    <limit>
                                        <counter>LINE</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.80</minimum>
                                    </limit>
                                    <limit>
                                        <counter>BRANCH</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.70</minimum>
                                    </limit>
                                </limits>
                            </rule>
                            <rule>
                                <element>CLASS</element>
                                <limits>
                                    <limit>
                                        <counter>LINE</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.60</minimum>
                                    </limit>
                                </limits>
                            </rule>
                        </rules>
                    </configuration>
                </execution>
            </executions>

            <configuration>
                <!-- 除外パターン -->
                <excludes>
                    <exclude>**/config/**</exclude>
                    <exclude>**/dto/**</exclude>
                    <exclude>**/entity/**</exclude>
                    <exclude>**/*Application.*</exclude>
                    <exclude>**/*Config.*</exclude>
                </excludes>
            </configuration>
        </plugin>
    </plugins>
</build>

Gradleでの設定

plugins {
    id 'java'
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.12"
}

tasks.named('test') {
    useJUnitPlatform()
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test
    reports {
        xml.required = true
        csv.required = false
        html.required = true
        html.outputLocation = layout.buildDirectory.dir('reports/jacoco')
    }

    // 除外パターン
    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                '**/config/**',
                '**/dto/**',
                '**/entity/**',
                '**/*Application*',
                '**/*Config*'
            ])
        }))
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }
        }
        rule {
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.70
            }
        }
        rule {
            element = 'CLASS'
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.60
            }
            excludes = [
                'com.example.config.*',
                'com.example.dto.*'
            ]
        }
    }
}

tasks.named('check') {
    dependsOn jacocoTestCoverageVerification
}

11.2 カバレッジの種類

JaCoCoは以下のカバレッジ指標を計測する。

指標説明重要度
行カバレッジ(Line Coverage)実行された行の割合基本
分岐カバレッジ(Branch Coverage)if/switch等の条件分岐のカバー率重要
メソッドカバレッジ(Method Coverage)実行されたメソッドの割合参考
クラスカバレッジ(Class Coverage)テストされたクラスの割合参考
命令カバレッジ(Instruction Coverage)バイトコード命令のカバー率詳細
複雑度カバレッジ(Complexity Coverage)McCabe複雑度に基づくカバー率高度
// 分岐カバレッジの例
public class AgeValidator {
    public String categorize(int age) {
        if (age < 0) {                    // 分岐1
            throw new IllegalArgumentException("年齢は正の値でなければなりません");
        } else if (age < 13) {            // 分岐2
            return "子供";
        } else if (age < 20) {            // 分岐3
            return "ティーンエイジャー";
        } else if (age < 65) {            // 分岐4
            return "大人";
        } else {                          // 分岐5
            return "高齢者";
        }
    }
}

// 100% 分岐カバレッジを達成するテスト
class AgeValidatorTest {

    private final AgeValidator validator = new AgeValidator();

    @Test
    void shouldThrowForNegativeAge() {
        assertThrows(IllegalArgumentException.class,
            () -> validator.categorize(-1));
    }

    @Test
    void shouldReturnChildForAgeUnder13() {
        assertEquals("子供", validator.categorize(5));
    }

    @Test
    void shouldReturnTeenagerForAgeBetween13And19() {
        assertEquals("ティーンエイジャー", validator.categorize(15));
    }

    @Test
    void shouldReturnAdultForAgeBetween20And64() {
        assertEquals("大人", validator.categorize(30));
    }

    @Test
    void shouldReturnSeniorForAge65AndAbove() {
        assertEquals("高齢者", validator.categorize(70));
    }
}

11.3 SonarQubeとの連携

SonarQubeは、コード品質を継続的に監視するプラットフォームである。JaCoCoのカバレッジデータと統合できる。

<!-- Maven: SonarQube プラグイン設定 -->
<properties>
    <sonar.projectKey>com.example:my-project</sonar.projectKey>
    <sonar.host.url>http://localhost:9000</sonar.host.url>
    <sonar.login>your-sonar-token</sonar.login>
    <sonar.java.coveragePlugin>jacoco</sonar.java.coveragePlugin>
    <sonar.coverage.jacoco.xmlReportPaths>
        ${project.build.directory}/site/jacoco/jacoco.xml
    </sonar.coverage.jacoco.xmlReportPaths>
    <sonar.exclusions>
        **/config/**,**/dto/**,**/entity/**
    </sonar.exclusions>
</properties>
# SonarQube 分析の実行
mvn clean verify sonar:sonar

# Gradle の場合
gradle clean test jacocoTestReport sonarqube

11.4 ミューテーションテスト(PIT)

ミューテーションテストは、ソースコードに意図的な変更(ミュータント)を加え、テストがその変更を検出できるかを確認する手法である。カバレッジだけでは測れないテストの品質(アサーションの有効性)を評価できる。

<!-- Maven: PIT プラグイン -->
<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.17.1</version>
    <dependencies>
        <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>1.2.1</version>
        </dependency>
    </dependencies>
    <configuration>
        <targetClasses>
            <param>com.example.service.*</param>
            <param>com.example.util.*</param>
        </targetClasses>
        <targetTests>
            <param>com.example.*Test</param>
        </targetTests>
        <mutators>
            <mutator>DEFAULTS</mutator>
        </mutators>
        <outputFormats>
            <outputFormat>HTML</outputFormat>
            <outputFormat>XML</outputFormat>
        </outputFormats>
        <timestampedReports>false</timestampedReports>
    </configuration>
</plugin>
# ミューテーションテストの実行
mvn org.pitest:pitest-maven:mutationCoverage

PITが適用する主なミュータント(変更パターン):

  • 条件境界: <<= に変更
  • 否定条件: ==!= に変更
  • 算術演算子: +- に変更
  • 戻り値変更: return truereturn false に変更
  • void メソッド呼び出し削除: メソッド呼び出しを削除
// ミューテーションテストで検出される弱いテストの例
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

// 弱いテスト(ミュータントが生き残る)
class WeakCalculatorTest {
    @Test
    void testAdd() {
        Calculator calc = new Calculator();
        calc.add(0, 0); // アサーションがない!
    }
}

// 強いテスト(ミュータントを殺せる)
class StrongCalculatorTest {
    @Test
    void testAdd() {
        Calculator calc = new Calculator();
        assertEquals(5, calc.add(2, 3)); // 具体的なアサーション
        assertEquals(0, calc.add(0, 0));
        assertEquals(-1, calc.add(2, -3));
    }
}

第12章: CI/CDパイプラインでのJUnit

12.1 Maven Surefire / Failsafe プラグイン

Maven Surefireプラグインは単体テストの実行を、Failsafeプラグインは結合テストの実行を担当する。

<build>
    <plugins>
        <!-- Surefire: 単体テスト (*Test.java, *Tests.java, Test*.java) -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.5.2</version>
            <configuration>
                <!-- JUnit 5 を使用 -->
                <includes>
                    <include>**/*Test.java</include>
                    <include>**/*Tests.java</include>
                </includes>
                <excludes>
                    <exclude>**/*IntegrationTest.java</exclude>
                    <exclude>**/*IT.java</exclude>
                </excludes>

                <!-- 並列実行 -->
                <parallel>methods</parallel>
                <threadCount>4</threadCount>
                <perCoreThreadCount>true</perCoreThreadCount>

                <!-- テスト失敗時にビルドを止めない(CI用) -->
                <!-- <testFailureIgnore>true</testFailureIgnore> -->

                <!-- JVMオプション -->
                <argLine>-Xmx1024m -XX:+UseG1GC</argLine>

                <!-- システムプロパティ -->
                <systemPropertyVariables>
                    <env>test</env>
                    <spring.profiles.active>test</spring.profiles.active>
                </systemPropertyVariables>

                <!-- タグフィルタ -->
                <groups>unit &amp; !slow</groups>
            </configuration>
        </plugin>

        <!-- Failsafe: 結合テスト (*IT.java, *IntegrationTest.java) -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <version>3.5.2</version>
            <configuration>
                <includes>
                    <include>**/*IT.java</include>
                    <include>**/*IntegrationTest.java</include>
                </includes>
                <groups>integration</groups>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>integration-test</goal>
                        <goal>verify</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>
# 単体テストの実行
mvn test

# 結合テストを含むすべてのテストの実行
mvn verify

# 特定のテストクラスのみ実行
mvn test -Dtest=UserServiceTest

# 特定のテストメソッドのみ実行
mvn test -Dtest=UserServiceTest#shouldCreateUser

# 特定のパッケージのテスト
mvn test -Dtest="com.example.service.*Test"

12.2 Gradleでのテスト実行設定

plugins {
    id 'java'
    id 'jacoco'
}

// テスト設定
tasks.named('test') {
    useJUnitPlatform {
        includeTags 'unit'
        excludeTags 'integration', 'slow'
    }

    // 並列実行
    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1

    // テスト実行ごとにJVMをフォーク
    forkEvery = 100

    // JVMオプション
    jvmArgs '-Xmx1024m', '-XX:+UseG1GC'

    // システムプロパティ
    systemProperty 'spring.profiles.active', 'test'

    // テスト結果のログ
    testLogging {
        events 'passed', 'skipped', 'failed'
        showExceptions true
        showCauses true
        showStackTraces true
        exceptionFormat 'full'
    }

    // テストレポート
    reports {
        html.required = true
        junitXml.required = true
    }

    finalizedBy jacocoTestReport
}

// 結合テスト用タスク
tasks.register('integrationTest', Test) {
    description = '結合テストを実行'
    group = 'verification'

    useJUnitPlatform {
        includeTags 'integration'
    }

    shouldRunAfter test

    testLogging {
        events 'passed', 'skipped', 'failed'
    }
}

// check タスクに結合テストを追加
tasks.named('check') {
    dependsOn integrationTest
}

12.3 GitHub ActionsでのJUnit実行

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        java-version: [ 17, 21 ]

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java-version }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java-version }}
          distribution: 'temurin'
          cache: 'maven'

      - name: Run Unit Tests
        run: mvn test -B -Dgroups="unit"

      - name: Run Integration Tests
        run: mvn verify -B -Dgroups="integration"
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
          SPRING_DATASOURCE_USERNAME: test
          SPRING_DATASOURCE_PASSWORD: test

      - name: Generate Coverage Report
        run: mvn jacoco:report -B

      - name: Upload Test Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-java-${{ matrix.java-version }}
          path: |
            **/target/surefire-reports/
            **/target/failsafe-reports/
            **/target/site/jacoco/

      - name: Publish Test Report
        if: always()
        uses: dorny/test-reporter@v1
        with:
          name: JUnit Results (Java ${{ matrix.java-version }})
          path: '**/target/surefire-reports/*.xml'
          reporter: java-junit

      - name: Upload Coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: target/site/jacoco/jacoco.xml
          token: ${{ secrets.CODECOV_TOKEN }}

12.4 Jenkins / GitLab CI での設定

Jenkinsfile

pipeline {
    agent any

    tools {
        maven 'Maven-3.9'
        jdk 'JDK-21'
    }

    stages {
        stage('Build') {
            steps {
                sh 'mvn clean compile -B'
            }
        }

        stage('Unit Test') {
            steps {
                sh 'mvn test -B -Dgroups="unit"'
            }
            post {
                always {
                    junit '**/target/surefire-reports/*.xml'
                    jacoco(
                        execPattern: '**/target/jacoco.exec',
                        classPattern: '**/target/classes',
                        sourcePattern: '**/src/main/java'
                    )
                }
            }
        }

        stage('Integration Test') {
            steps {
                sh 'mvn verify -B -Dgroups="integration"'
            }
            post {
                always {
                    junit '**/target/failsafe-reports/*.xml'
                }
            }
        }

        stage('SonarQube Analysis') {
            steps {
                withSonarQubeEnv('SonarQube') {
                    sh 'mvn sonar:sonar -B'
                }
            }
        }
    }

    post {
        always {
            publishHTML(target: [
                reportDir: 'target/site/jacoco',
                reportFiles: 'index.html',
                reportName: 'JaCoCo Coverage Report'
            ])
        }
    }
}

GitLab CI

# .gitlab-ci.yml
stages:
  - build
  - test
  - analyze

variables:
  MAVEN_CLI_OPTS: "-B --no-transfer-progress"
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"

cache:
  paths:
    - .m2/repository/

build:
  stage: build
  image: maven:3.9-eclipse-temurin-21
  script:
    - mvn $MAVEN_CLI_OPTS clean compile

unit-test:
  stage: test
  image: maven:3.9-eclipse-temurin-21
  script:
    - mvn $MAVEN_CLI_OPTS test -Dgroups="unit"
  artifacts:
    when: always
    reports:
      junit: target/surefire-reports/*.xml
    paths:
      - target/site/jacoco/

integration-test:
  stage: test
  image: maven:3.9-eclipse-temurin-21
  services:
    - postgres:16-alpine
  variables:
    POSTGRES_DB: testdb
    POSTGRES_USER: test
    POSTGRES_PASSWORD: test
    SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/testdb
  script:
    - mvn $MAVEN_CLI_OPTS verify -Dgroups="integration"
  artifacts:
    when: always
    reports:
      junit: target/failsafe-reports/*.xml

coverage:
  stage: analyze
  image: maven:3.9-eclipse-temurin-21
  script:
    - mvn $MAVEN_CLI_OPTS test jacoco:report
  coverage: '/Total.*?([0-9]{1,3})%/'
  artifacts:
    paths:
      - target/site/jacoco/

12.5 並列テスト実行

JUnit 5は組み込みの並列テスト実行機能を提供する。

# src/test/resources/junit-platform.properties

# 並列実行を有効化
junit.jupiter.execution.parallel.enabled = true

# デフォルトの実行モード
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent

# 並列度の設定
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4

# または動的な並列度(CPUコア数に基づく)
# junit.jupiter.execution.parallel.config.strategy = dynamic
# junit.jupiter.execution.parallel.config.dynamic.factor = 1.0
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.api.parallel.ResourceAccessMode;

// クラスレベルでの並列実行指定
@Execution(ExecutionMode.CONCURRENT)
class ParallelTest {

    @Test
    void test1() throws InterruptedException {
        Thread.sleep(1000);
    }

    @Test
    void test2() throws InterruptedException {
        Thread.sleep(1000);
    }

    @Test
    void test3() throws InterruptedException {
        Thread.sleep(1000);
    }
    // 並列実行により約1秒で完了(直列なら3秒)
}

// リソースロックによる同期制御
class ResourceLockTest {

    @Test
    @ResourceLock(value = "database",
                  mode = ResourceAccessMode.READ_WRITE)
    void writeToDatabase() {
        // 排他的にデータベースにアクセス
    }

    @Test
    @ResourceLock(value = "database",
                  mode = ResourceAccessMode.READ)
    void readFromDatabase() {
        // 読み取り専用(他のREADと並列可能)
    }

    @Test
    @ResourceLock("system-properties")
    void modifySystemProperty() {
        // システムプロパティへのアクセスを同期
    }
}

第13章: TDDとBDDの実践

13.1 TDD(テスト駆動開発)のワークフロー

テスト駆動開発(Test-Driven Development)は、テストを先に書き、そのテストを通すためにプロダクションコードを書くという開発手法である。Kent Beck が提唱し、XP(Extreme Programming)の核となるプラクティスである。

Red-Green-Refactor サイクル

TDDは以下の3つのステップを繰り返す。

  1. Red(赤): 失敗するテストを書く
  2. Green(緑): テストを通す最小限のコードを書く
  3. Refactor(リファクタリング): テストが通る状態を維持しながら、コードを改善する

TDDの実践例:メールバリデーターの実装

// ステップ 1: Red - 失敗するテストを書く
class EmailValidatorTest {

    private EmailValidator validator;

    @BeforeEach
    void setUp() {
        validator = new EmailValidator();
    }

    @Test
    @DisplayName("有効なメールアドレスはtrueを返す")
    void shouldReturnTrueForValidEmail() {
        assertTrue(validator.isValid("user@example.com"));
    }
}

// この時点ではEmailValidatorクラスが存在しないため、コンパイルエラー

// ステップ 2: Green - 最小限の実装
public class EmailValidator {
    public boolean isValid(String email) {
        return true; // 最小限の実装
    }
}

// ステップ 3: さらにテストを追加(Red)
class EmailValidatorTest {

    private EmailValidator validator;

    @BeforeEach
    void setUp() {
        validator = new EmailValidator();
    }

    @Test
    void shouldReturnTrueForValidEmail() {
        assertTrue(validator.isValid("user@example.com"));
    }

    @Test
    @DisplayName("nullはfalseを返す")
    void shouldReturnFalseForNull() {
        assertFalse(validator.isValid(null)); // Red: 失敗する
    }

    @Test
    @DisplayName("空文字列はfalseを返す")
    void shouldReturnFalseForEmptyString() {
        assertFalse(validator.isValid("")); // Red: 失敗する
    }

    @Test
    @DisplayName("@がないメールはfalseを返す")
    void shouldReturnFalseForEmailWithoutAtSign() {
        assertFalse(validator.isValid("userexample.com")); // Red: 失敗する
    }
}

// ステップ 4: Green - テストを通す実装
public class EmailValidator {
    public boolean isValid(String email) {
        if (email == null || email.isEmpty()) {
            return false;
        }
        return email.contains("@");
    }
}

// ステップ 5: さらにテストを追加
class EmailValidatorTest {
    // ... 上記のテストに加えて

    @Test
    @DisplayName("@の後にドメインがないメールはfalseを返す")
    void shouldReturnFalseForEmailWithoutDomain() {
        assertFalse(validator.isValid("user@")); // Red
    }

    @Test
    @DisplayName("@の前にローカルパートがないメールはfalseを返す")
    void shouldReturnFalseForEmailWithoutLocalPart() {
        assertFalse(validator.isValid("@example.com")); // Red
    }

    @Test
    @DisplayName("ドメインにドットが含まれないメールはfalseを返す")
    void shouldReturnFalseForDomainWithoutDot() {
        assertFalse(validator.isValid("user@example")); // Red
    }

    @ParameterizedTest
    @ValueSource(strings = {
        "user@example.com",
        "first.last@example.com",
        "user+tag@example.co.jp",
        "user@sub.domain.example.com"
    })
    @DisplayName("有効なメールアドレスのバリエーション")
    void shouldReturnTrueForVariousValidEmails(String email) {
        assertTrue(validator.isValid(email));
    }

    @ParameterizedTest
    @ValueSource(strings = {
        "user",
        "@",
        "user@",
        "@example.com",
        "user @example.com",
        "user@exam ple.com"
    })
    @DisplayName("無効なメールアドレスのバリエーション")
    void shouldReturnFalseForVariousInvalidEmails(String email) {
        assertFalse(validator.isValid(email));
    }
}

// ステップ 6: Green & Refactor - 実装の改善
public class EmailValidator {

    private static final Pattern EMAIL_PATTERN = Pattern.compile(
        "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$"
    );

    public boolean isValid(String email) {
        if (email == null || email.isBlank()) {
            return false;
        }
        return EMAIL_PATTERN.matcher(email).matches();
    }
}

13.2 TDDの三原則(Uncle Bob)

Robert C. Martin(Uncle Bob)は、TDDの3つのルールを定義した。

  1. 失敗するテストなしに、プロダクションコードを書いてはならない
  2. 失敗を示す以上のテストを書いてはならない(コンパイルエラーも失敗)
  3. 失敗するテストを通す以上のプロダクションコードを書いてはならない

13.3 BDD(振る舞い駆動開発)の概要

BDD(Behavior-Driven Development)は、TDDを進化させ、ビジネスの振る舞い(behavior)を中心にテストを記述する手法である。テストの記述を「Given-When-Then」形式で構造化し、非技術者にも理解しやすい仕様として機能させる。

13.4 Cucumber JVMとの連携

Cucumberは、自然言語に近いGherkin記法でテストシナリオを記述し、Javaのステップ定義と紐づけるBDDフレームワークである。

<!-- Maven 依存関係 -->
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>7.20.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit-platform-engine</artifactId>
    <version>7.20.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-spring</artifactId>
    <version>7.20.1</version>
    <scope>test</scope>
</dependency>

Gherkin シナリオ(日本語可)

# src/test/resources/features/user_registration.feature
# language: ja

機能: ユーザー登録
  新規ユーザーがシステムに登録できるようにする

  背景:
    前提 システムにユーザーが存在しない

  シナリオ: 有効な情報でユーザーを登録する
    前提 ユーザー名 "太郎" とメールアドレス "taro@example.com" を入力する
    もし ユーザー登録を実行する
    ならば 登録が成功する
    かつ ユーザー "太郎" がシステムに存在する
    かつ ウェルカムメールが "taro@example.com" に送信される

  シナリオ: 重複するメールアドレスで登録を試みる
    前提 メールアドレス "existing@example.com" のユーザーが既に存在する
    もし ユーザー名 "花子" とメールアドレス "existing@example.com" で登録を試みる
    ならば 登録が失敗する
    かつ エラーメッセージ "このメールアドレスは既に使用されています" が表示される

  シナリオアウトライン: 無効な入力でのバリデーション
    もし ユーザー名 "<名前>" とメールアドレス "<メール>" で登録を試みる
    ならば 登録が失敗する
    かつ エラーメッセージ "<エラー>" が表示される

    例:
      | 名前   | メール              | エラー                           |
      |        | taro@example.com  | 名前は必須です                     |
      | 太郎   |                   | メールアドレスは必須です              |
      | 太郎   | invalid-email     | メールアドレスの形式が不正です          |

ステップ定義

import io.cucumber.java.ja.*;
import io.cucumber.java.Before;
import static org.junit.jupiter.api.Assertions.*;

public class UserRegistrationSteps {

    private UserService userService;
    private String userName;
    private String userEmail;
    private RegistrationResult result;

    @Before
    public void setUp() {
        userService = new UserService(
            new InMemoryUserRepository(),
            new MockEmailService()
        );
    }

    @前提("システムにユーザーが存在しない")
    public void システムにユーザーが存在しない() {
        assertTrue(userService.findAll().isEmpty());
    }

    @前提("ユーザー名 {string} とメールアドレス {string} を入力する")
    public void ユーザー名とメールアドレスを入力する(String name, String email) {
        this.userName = name;
        this.userEmail = email;
    }

    @もし("ユーザー登録を実行する")
    public void ユーザー登録を実行する() {
        result = userService.register(userName, userEmail);
    }

    @ならば("登録が成功する")
    public void 登録が成功する() {
        assertTrue(result.isSuccess());
    }

    @かつ("ユーザー {string} がシステムに存在する")
    public void ユーザーがシステムに存在する(String name) {
        assertTrue(userService.findByName(name).isPresent());
    }

    @かつ("ウェルカムメールが {string} に送信される")
    public void ウェルカムメールが送信される(String email) {
        assertTrue(userService.wasWelcomeEmailSent(email));
    }

    @前提("メールアドレス {string} のユーザーが既に存在する")
    public void メールアドレスのユーザーが既に存在する(String email) {
        userService.register("既存ユーザー", email);
    }

    @もし("ユーザー名 {string} とメールアドレス {string} で登録を試みる")
    public void 登録を試みる(String name, String email) {
        this.userName = name;
        this.userEmail = email;
        result = userService.register(name, email);
    }

    @ならば("登録が失敗する")
    public void 登録が失敗する() {
        assertFalse(result.isSuccess());
    }

    @かつ("エラーメッセージ {string} が表示される")
    public void エラーメッセージが表示される(String message) {
        assertEquals(message, result.getErrorMessage());
    }
}

JUnit Platform での実行設定

# src/test/resources/junit-platform.properties
cucumber.plugin = pretty, html:target/cucumber-reports/index.html, json:target/cucumber-reports/cucumber.json
cucumber.glue = com.example.steps
cucumber.features = src/test/resources/features
cucumber.publish.quiet = true
// テストランナー設定(JUnit Platform Suite)
@Suite
@IncludeEngines("cucumber")
@SelectPackages("com.example")
@ConfigurationParameter(key = "cucumber.glue", value = "com.example.steps")
@ConfigurationParameter(key = "cucumber.features", value = "src/test/resources/features")
class CucumberTest { }

13.5 JUnitでのBDDスタイルテスト

Cucumberを使わなくても、JUnit 5の @Nested@DisplayName を組み合わせてBDDスタイルのテストを記述できる。

@DisplayName("ショッピングカート")
class ShoppingCartBDDTest {

    private ShoppingCart cart;

    @BeforeEach
    void setUp() {
        cart = new ShoppingCart();
    }

    @Nested
    @DisplayName("空のカートの場合")
    class EmptyCart {

        @Test
        @DisplayName("合計金額は0円である")
        void totalShouldBeZero() {
            assertEquals(0, cart.getTotal());
        }

        @Test
        @DisplayName("商品数は0である")
        void itemCountShouldBeZero() {
            assertEquals(0, cart.getItemCount());
        }

        @Nested
        @DisplayName("商品を追加した場合")
        class WhenItemAdded {

            @BeforeEach
            void addItem() {
                cart.addItem(new Product("りんご", 150), 2);
            }

            @Test
            @DisplayName("合計金額は商品の価格×数量である")
            void totalShouldReflectAddedItem() {
                assertEquals(300, cart.getTotal());
            }

            @Test
            @DisplayName("商品数は追加した数量である")
            void itemCountShouldReflectAddedQuantity() {
                assertEquals(2, cart.getItemCount());
            }

            @Nested
            @DisplayName("同じ商品をさらに追加した場合")
            class WhenSameItemAddedAgain {

                @BeforeEach
                void addSameItemAgain() {
                    cart.addItem(new Product("りんご", 150), 3);
                }

                @Test
                @DisplayName("数量が合算される")
                void quantityShouldBeSummed() {
                    assertEquals(5, cart.getItemCount());
                }

                @Test
                @DisplayName("合計金額が更新される")
                void totalShouldBeUpdated() {
                    assertEquals(750, cart.getTotal());
                }
            }
        }
    }
}

第14章: ベストプラクティスとアンチパターン

14.1 テストの命名規約

テストメソッドの名前は、テストの意図を明確に伝えるべきである。以下のような命名パターンが広く使用されている。

class NamingConventionExamples {

    // パターン1: should_期待結果_when_条件
    @Test
    void should_ReturnTrue_When_EmailIsValid() { }

    @Test
    void should_ThrowException_When_NameIsNull() { }

    // パターン2: メソッド名_条件_期待結果
    @Test
    void isValid_WithValidEmail_ReturnsTrue() { }

    @Test
    void createUser_WithNullName_ThrowsIllegalArgumentException() { }

    // パターン3: given_when_then(BDDスタイル)
    @Test
    void givenValidEmail_whenCreateUser_thenUserIsCreated() { }

    @Test
    void givenExistingEmail_whenCreateUser_thenThrowsDuplicateException() { }

    // パターン4: 自然言語(@DisplayNameと組み合わせ)
    @Test
    @DisplayName("有効なメールアドレスでユーザーを作成できること")
    void createUserWithValidEmail() { }

    // パターン5: シンプルな動詞形
    @Test
    void createsUserSuccessfully() { }

    @Test
    void rejectsInvalidEmail() { }

    @Test
    void deletesExistingUser() { }
}

14.2 テストの独立性と再現性

各テストは、他のテストに依存せず、独立して実行できなければならない。また、何回実行しても同じ結果が得られる再現性が必要である。

// 悪い例: テスト間に依存関係がある
class DependentTestsBad {

    private static User createdUser;

    @Test
    @Order(1)
    void createUser() {
        createdUser = userService.create("太郎", "taro@example.com");
        assertNotNull(createdUser); // このテストが失敗すると次も失敗
    }

    @Test
    @Order(2)
    void updateUser() {
        // createdUser が null の場合、NullPointerException
        createdUser.setName("太郎(更新)");
        userService.update(createdUser);
    }

    @Test
    @Order(3)
    void deleteUser() {
        // 前のテストに依存
        userService.delete(createdUser.getId());
    }
}

// 良い例: 各テストが独立している
class IndependentTestsGood {

    @Autowired
    private UserService userService;

    @BeforeEach
    void setUp() {
        userService.deleteAll(); // クリーンな状態から開始
    }

    @Test
    void shouldCreateUser() {
        User user = userService.create("太郎", "taro@example.com");
        assertNotNull(user);
        assertNotNull(user.getId());
    }

    @Test
    void shouldUpdateUser() {
        // テストに必要なデータを自分で準備
        User user = userService.create("太郎", "taro@example.com");

        user.setName("太郎(更新)");
        User updated = userService.update(user);

        assertEquals("太郎(更新)", updated.getName());
    }

    @Test
    void shouldDeleteUser() {
        // テストに必要なデータを自分で準備
        User user = userService.create("太郎", "taro@example.com");

        userService.delete(user.getId());

        assertFalse(userService.findById(user.getId()).isPresent());
    }
}

14.3 F.I.R.S.T 原則

良いテストは以下の5つの特性を持つべきである。

原則説明
Fast(高速)テストは高速に実行できるべき。遅いテストは開発者に実行を躊躇させる
Independent(独立)テストは互いに独立し、任意の順序で実行できるべき
Repeatable(再現可能)どの環境でも同じ結果が得られるべき
Self-Validating(自己検証)テストは pass/fail を自動的に判定すべき(手動確認は不要)
Timely(適時)テストはプロダクションコードと同時期に書かれるべき(理想はTDD)

14.4 テストのアンチパターン

アンチパターン1: 過剰なモック

// 悪い例: すべてをモック化(テストの価値が低下)
@ExtendWith(MockitoExtension.class)
class OverMockedTest {

    @Mock private UserRepository userRepository;
    @Mock private EmailService emailService;
    @Mock private AuditLogger auditLogger;
    @Mock private CacheManager cacheManager;
    @Mock private EventPublisher eventPublisher;
    @Mock private NotificationService notificationService;
    @InjectMocks private UserService userService;

    @Test
    void createUser() {
        // 大量のモック設定
        when(userRepository.save(any())).thenReturn(new User());
        when(emailService.send(any())).thenReturn(true);
        when(cacheManager.get(any())).thenReturn(null);
        doNothing().when(auditLogger).log(any());
        doNothing().when(eventPublisher).publish(any());
        doNothing().when(notificationService).notify(any());

        // テストは実装の詳細に過度に依存している
        userService.createUser("太郎", "taro@example.com");

        verify(userRepository).save(any());
        verify(emailService).send(any());
        verify(auditLogger).log(any());
        verify(cacheManager).put(any(), any());
        // 実装が変更されると大量のテストが壊れる
    }
}

// 良い例: 本当に必要なものだけモック化
@SpringBootTest
class WellTestedUserServiceTest {

    @Autowired
    private UserService userService;

    @MockBean
    private EmailService emailService; // 外部依存のみモック

    @Test
    void shouldCreateUserAndPersistToDatabase() {
        when(emailService.send(any())).thenReturn(true);

        User user = userService.createUser("太郎", "taro@example.com");

        // 振る舞いの結果を検証(実装の詳細ではなく)
        assertNotNull(user.getId());
        assertEquals("太郎", user.getName());
    }
}

アンチパターン2: 脆いテスト(Brittle Test)

// 悪い例: 実装の詳細に依存
@Test
void brittleTest() {
    List<User> users = userService.findAll();

    // 順序に依存(データベースの実装に依存)
    assertEquals("太郎", users.get(0).getName());
    assertEquals("花子", users.get(1).getName());

    // 完全一致に依存(将来フィールドが追加されると壊れる)
    assertEquals("{\"name\":\"太郎\",\"email\":\"taro@example.com\"}",
        objectMapper.writeValueAsString(users.get(0)));
}

// 良い例: 振る舞いに注目
@Test
void robustTest() {
    List<User> users = userService.findAll();

    // 順序に依存しない検証
    assertThat(users)
        .extracting(User::getName)
        .containsExactlyInAnyOrder("太郎", "花子");

    // 必要なフィールドのみ検証
    assertThat(users)
        .anyMatch(u -> u.getName().equals("太郎")
                    && u.getEmail().equals("taro@example.com"));
}

アンチパターン3: 遅いテスト

// 悪い例: 不必要に遅いテスト
@SpringBootTest // フルコンテキストロード(不要な場合がある)
class SlowTest {

    @Test
    void simpleCalculation() {
        // Spring コンテキスト全体のロードは不要
        assertEquals(5, 2 + 3);
    }
}

// 良い例: 適切なテストレベル
class FastTest {

    @Test
    void simpleCalculation() {
        // フレームワーク不要の単純なテスト
        Calculator calc = new Calculator();
        assertEquals(5, calc.add(2, 3));
    }
}

14.5 テストピラミッド

Mike Cohn が提唱したテストピラミッドは、テストの種類ごとの適切な比率を示すモデルである。

         /\
        /  \
       / E2E \        ← 少数(遅い、高コスト、脆い)
      /--------\
     /Integration\    ← 中程度
    /--------------\
   /   Unit Tests   \ ← 多数(高速、低コスト、安定)
  /------------------\
テストレベル割合目安実行速度特徴
単体テスト70%ミリ秒個々のクラス/メソッドの検証
結合テスト20%コンポーネント間の連携検証
E2Eテスト10%システム全体のワークフロー検証

14.6 テストダブルの適切な使い分け

Gerard Meszarosが定義したテストダブル(Test Double)の5つの種類を理解し、適切に使い分ける。

種類説明Mockitoでの対応
Dummy引数を埋めるだけで実際には使われないmock() (未設定)
Stub事前に定義された値を返すwhen().thenReturn()
Spy実際の処理を行いつつ、呼び出しを記録@Spy, spy()
Mock呼び出しの検証を行う@Mock + verify()
Fake簡易的な実装(インメモリDBなど)手動実装
// Dummy: 使われないが引数として必要
@Test
void dummyExample() {
    Logger dummyLogger = mock(Logger.class); // 使われない
    Calculator calc = new Calculator(dummyLogger);
    assertEquals(5, calc.add(2, 3));
}

// Stub: 戻り値を事前定義
@Test
void stubExample() {
    UserRepository stub = mock(UserRepository.class);
    when(stub.findById(1L)).thenReturn(Optional.of(new User("太郎")));

    UserService service = new UserService(stub);
    User user = service.getUser(1L);
    assertEquals("太郎", user.getName());
}

// Fake: 簡易実装
class InMemoryUserRepository implements UserRepository {
    private final Map<Long, User> store = new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    @Override
    public User save(User user) {
        if (user.getId() == null) {
            user.setId(idGenerator.getAndIncrement());
        }
        store.put(user.getId(), user);
        return user;
    }

    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List<User> findAll() {
        return new ArrayList<>(store.values());
    }

    @Override
    public void deleteById(Long id) {
        store.remove(id);
    }
}

14.7 AAA パターン(Arrange-Act-Assert)

テストメソッドは、3つのセクションに明確に分割すべきである。

@Test
void shouldApplyDiscountForPremiumCustomer() {
    // Arrange(準備)
    Customer customer = new Customer("太郎", CustomerTier.PREMIUM);
    Order order = new Order(customer);
    order.addItem(new Product("ノートPC", 100000));
    order.addItem(new Product("マウス", 5000));
    PricingService pricingService = new PricingService();

    // Act(実行)
    OrderSummary summary = pricingService.calculateTotal(order);

    // Assert(検証)
    assertEquals(105000, summary.getSubtotal());
    assertEquals(10500, summary.getDiscount()); // 10% 割引
    assertEquals(94500, summary.getTotal());
}

第15章: まとめと参考資料

15.1 JUnitの強みとエコシステム

JUnitは、20年以上にわたってJavaテストの標準であり続けている。その成功の要因は以下の通りである。

JUnit 5の主な強み:

  • モジュラーアーキテクチャ: JUnit Platform、Jupiter、Vintageの分離により、拡張性と後方互換性を両立
  • 豊富なアノテーション: @ParameterizedTest, @Nested, @TestFactory, @RepeatedTest など、多様なテストパターンに対応
  • 柔軟なExtensionモデル: JUnit 4のRunnerとRuleの制約を解消し、複数の拡張を自由に組み合わせ可能
  • ラムダサポート: Java 8以降のラムダ式を活用したアサーション(assertThrows, assertTimeout, assertAll
  • 並列テスト実行: 組み込みの並列実行サポートによりテスト実行時間を短縮
  • 条件付きテスト実行: OS、JREバージョン、環境変数などに基づくテストの条件付き実行
  • 強力なエコシステム: Mockito、Spring Boot Test、Testcontainers、AssertJ、JaCoCo等との緊密な統合

JUnitを中心としたエコシステム:

                      ┌─────────────────────┐
                      │      JUnit 5        │
                      │  (JUnit Platform)   │
                      └─────────┬───────────┘
                                │
              ┌─────────────────┼─────────────────┐
              │                 │                   │
    ┌─────────┴───────┐ ┌──────┴─────┐ ┌──────────┴──────────┐
    │   テスト記述     │ │  モック     │ │   テスト基盤        │
    │                  │ │            │ │                      │
    │ • JUnit Jupiter  │ │ • Mockito  │ │ • Testcontainers    │
    │ • AssertJ        │ │ • WireMock │ │ • H2 Database       │
    │ • Hamcrest       │ │ • MockK    │ │ • Spring Boot Test  │
    │ • JSONassert     │ │            │ │ • Arquillian        │
    └──────────────────┘ └────────────┘ └──────────────────────┘
              │                                     │
    ┌─────────┴───────────────┐  ┌─────────────────┴───────┐
    │   カバレッジ・品質      │  │   CI/CD                  │
    │                          │  │                          │
    │ • JaCoCo                 │  │ • Maven Surefire/Failsafe│
    │ • SonarQube              │  │ • Gradle Test            │
    │ • PIT (Mutation Testing) │  │ • GitHub Actions         │
    │ • Spotbugs               │  │ • Jenkins / GitLab CI    │
    └──────────────────────────┘  └──────────────────────────┘

15.2 JUnit 5の今後の動向

JUnit 5は継続的に開発が進められており、以下のような機能が注目されている。

  • JUnit 5.11+: テストスイートAPIの強化、パフォーマンス改善
  • GraalVM Native Image対応: ネイティブイメージでのテスト実行サポートの改善
  • Kotlin対応の強化: Kotlin DSLやコルーチンサポートの改善
  • テストレポートの標準化: Open Test Reporting フォーマットの普及
  • 並列テスト実行の改善: より細かい制御とリソースロック機能の強化

15.3 JUnit 5 クイックリファレンス

主要アノテーション一覧

アノテーション説明
@Testテストメソッドを定義
@ParameterizedTestパラメータ化テストを定義
@RepeatedTest繰り返しテストを定義
@TestFactory動的テストのファクトリメソッドを定義
@TestTemplateテストテンプレートを定義
@BeforeEach各テスト前に実行
@AfterEach各テスト後に実行
@BeforeAll全テスト前に1回実行
@AfterAll全テスト後に1回実行
@DisplayNameテスト表示名をカスタマイズ
@DisplayNameGeneration表示名の自動生成を設定
@Nestedネストされたテストクラスを定義
@Tagテストにタグを付与
@Disabledテストを無効化
@Timeoutタイムアウトを設定
@ExtendWithExtensionを登録
@RegisterExtensionExtensionをプログラマティックに登録
@TempDir一時ディレクトリを提供
@TestMethodOrderテスト実行順序を指定
@TestInstanceテストインスタンスのライフサイクルを設定
@Orderテストの実行順序を指定

主要アサーション一覧

メソッド説明
assertEquals(expected, actual)等価性の検証
assertNotEquals(unexpected, actual)非等価性の検証
assertTrue(condition)trueの検証
assertFalse(condition)falseの検証
assertNull(actual)nullの検証
assertNotNull(actual)非nullの検証
assertSame(expected, actual)参照同一性の検証
assertNotSame(unexpected, actual)参照非同一性の検証
assertArrayEquals(expected, actual)配列の等価性検証
assertIterableEquals(expected, actual)Iterableの等価性検証
assertLinesMatch(expected, actual)文字列リストのパターンマッチ
assertThrows(type, executable)例外スローの検証
assertDoesNotThrow(executable)例外非スローの検証
assertTimeout(duration, executable)タイムアウトの検証
assertTimeoutPreemptively(duration, executable)即時打ち切りタイムアウト検証
assertAll(executables...)グループアサーション
fail()テストを強制失敗

15.4 プロジェクトテンプレート

新規プロジェクトでJUnit 5を開始するための最小構成テンプレートを示す。

Maven (pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>junit5-project</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <properties>
        <java.version>21</java.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <junit.version>5.11.4</junit.version>
        <mockito.version>5.14.2</mockito.version>
        <assertj.version>3.26.3</assertj.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>${junit.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-junit-jupiter</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.2</version>
            </plugin>
            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.12</version>
                <executions>
                    <execution>
                        <goals><goal>prepare-agent</goal></goals>
                    </execution>
                    <execution>
                        <id>report</id>
                        <phase>test</phase>
                        <goals><goal>report</goal></goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

Gradle (build.gradle)

plugins {
    id 'java'
    id 'jacoco'
}

group = 'com.example'
version = '1.0.0-SNAPSHOT'

java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.11.4')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.14.2'
    testImplementation 'org.assertj:assertj-core:3.26.3'
}

tasks.named('test') {
    useJUnitPlatform()
    testLogging {
        events 'passed', 'skipped', 'failed'
    }
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test
    reports {
        xml.required = true
        html.required = true
    }
}

15.5 参考文献とリソース

公式ドキュメント:

関連ライブラリのドキュメント:

書籍:

  • 『JUnit実践入門 — 体系的に学ぶユニットテストの技法』(渡辺修司著、技術評論社)
  • 『xUnit Test Patterns: Refactoring Test Code』(Gerard Meszaros著、Addison-Wesley)
  • 『Clean Code: アジャイルソフトウェア達人の技』(Robert C. Martin著)
  • 『テスト駆動開発』(Kent Beck著、オーム社)
  • 『Effective Software Testing: A Developer's Guide』(Mauricio Aniche著、Manning)
  • 『Growing Object-Oriented Software, Guided by Tests』(Steve Freeman, Nat Pryce著)

ウェブリソース:


本記事では、JUnit 5の基本概念からアーキテクチャ、テスト作成方法、モックフレームワークとの連携、Spring Bootテスト、データベーステスト、カバレッジ分析、CI/CDパイプラインでの活用、TDD/BDD手法、そしてベストプラクティスまで、JUnitを使用したJavaテストの全体像を網羅的に解説した。

効果的なテストを書くことは、ソフトウェアの品質を向上させるだけでなく、開発速度を長期的に加速させる。JUnit 5とその豊富なエコシステムを活用し、堅牢で保守性の高いテストコードを構築していただきたい。