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ファミリーの主なメンバーは以下の通りである。
| フレームワーク | 言語 | 備考 |
|---|---|---|
| SUnit | Smalltalk | xUnitの始祖 |
| JUnit | Java | 最も広く普及 |
| NUnit | C# (.NET) | .NET向け |
| xUnit.net | C# (.NET) | NUnitの後継的存在 |
| pytest | Python | Python標準テストランナー |
| RSpec | Ruby | BDDスタイル |
| PHPUnit | PHP | PHP向け |
| Google Test | C++ | C++向け |
| Jest | JavaScript | React/Node.js向け |
| go test | Go | Go標準ライブラリに内蔵 |
| XCTest | Swift/Objective-C | Apple プラットフォーム向け |
xUnitファミリーに共通する概念は以下の通りである。
- テストケース: 個々のテストメソッド
- テストスイート: テストケースの集合
- テストフィクスチャ: テストの前後処理(セットアップ・ティアダウン)
- アサーション: テスト結果の検証
- テストランナー: テストの実行エンジン
1.4 JUnit 5のアーキテクチャ概要
JUnit 5は以下の3つの主要コンポーネントから構成される。
JUnit Platform
JUnit Platformは、JVM上でテストフレームワークを実行するための基盤を提供する。テストの発見と実行のためのAPIを定義し、IDEやビルドツール(Maven、Gradle)との統合ポイントとなる。
主な責務は以下の通りである。
LauncherAPI: テストの発見・フィルタリング・実行のためのAPITestEngineSPI: テストエンジンの実装を可能にするサービスプロバイダインターフェース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-engine | TestEngine SPIの定義 |
junit-platform-launcher | テスト発見と実行のためのLauncher API |
junit-platform-reporting | テストレポート生成 |
junit-platform-runner | JUnit 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,@DisabledAssertionsクラスAssumptionsクラス- Extension API インターフェース
junit-jupiter-engine
JUnit Jupiterのテストを実行するTestEngine実装。ランタイム時に必要。
junit-jupiter-params
パラメータ化テスト機能を提供するモジュール。
主な内容:
@ParameterizedTest@ValueSource,@EnumSource,@MethodSource,@CsvSourceArgumentsProvider,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の@Test(org.junit.Test)とは異なるクラスであることに注意が必要である。JUnit 5の @Test には expected や timeout パラメータが存在しない。これらの機能は 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では、assertThrows と assertDoesNotThrow を使用して、例外の発生/非発生を検証する。
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();
});
}
}
assertTimeout と assertTimeoutPreemptively の違いは重要である。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 & 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/willReturn、verify の代わりに 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 trueをreturn 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 & !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つのステップを繰り返す。
- Red(赤): 失敗するテストを書く
- Green(緑): テストを通す最小限のコードを書く
- 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つのルールを定義した。
- 失敗するテストなしに、プロダクションコードを書いてはならない
- 失敗を示す以上のテストを書いてはならない(コンパイルエラーも失敗)
- 失敗するテストを通す以上のプロダクションコードを書いてはならない
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 | タイムアウトを設定 |
@ExtendWith | Extensionを登録 |
@RegisterExtension | Extensionをプログラマティックに登録 |
@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 5 User Guide: https://junit.org/junit5/docs/current/user-guide/
- JUnit 5 API Javadoc: https://junit.org/junit5/docs/current/api/
- JUnit 5 GitHub Repository: https://github.com/junit-team/junit5
関連ライブラリのドキュメント:
- Mockito: https://site.mockito.org/
- AssertJ: https://assertj.github.io/doc/
- Testcontainers: https://testcontainers.com/
- JaCoCo: https://www.jacoco.org/jacoco/
- WireMock: https://wiremock.org/
- Cucumber JVM: https://cucumber.io/docs/installation/java/
- PIT Mutation Testing: https://pitest.org/
書籍:
- 『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著)
ウェブリソース:
- Baeldung JUnit 5 Guide: https://www.baeldung.com/junit-5
- Spring Boot Testing Documentation: https://docs.spring.io/spring-boot/reference/testing/index.html
- Mockito Wiki: https://github.com/mockito/mockito/wiki
本記事では、JUnit 5の基本概念からアーキテクチャ、テスト作成方法、モックフレームワークとの連携、Spring Bootテスト、データベーステスト、カバレッジ分析、CI/CDパイプラインでの活用、TDD/BDD手法、そしてベストプラクティスまで、JUnitを使用したJavaテストの全体像を網羅的に解説した。
効果的なテストを書くことは、ソフトウェアの品質を向上させるだけでなく、開発速度を長期的に加速させる。JUnit 5とその豊富なエコシステムを活用し、堅牢で保守性の高いテストコードを構築していただきたい。