はじめに (対象読者・この記事でわかること)
この記事は、Javaで開発を行っているエンジニア、特にJUnitを使ってユニットテストを書き始めたばかりの方や、テストの品質を向上させたいと考えている方を対象としています。
本稿を読むことで、テストケースの設計ポイントや可読性・保守性を高める書き方、よくある落とし穴とその回避策が具体的に理解でき、実務で即座に活用できるテストコードを書けるようになります。
テストは「コードを書く」以上に「コードを守る」作業です。正しい観点を持ってテストを書けば、バグの早期検出だけでなく、リファクタリングや機能追加の際の安心材料にもなります。
JUnitテストを書く上で意識すべき基本観点
JUnitでテストを作成する際に特に重要になるのが、「何をテストすべきか」と「どのようにテストを書くか」の二軸です。
1. テスト対象の粒度
- ユニットテストは「1メソッド」や「1クラス」の振る舞いを検証することが原則です。外部サービスやデータベースへの依存はできるだけモックに置き換え、テストが外部要因に左右されないようにします。
-
テストケースの網羅性
- 正常系だけでなく、例外系・境界値・null入力など、「あり得るすべての入力パターン」を少なくとも一つずつはカバーするように設計します。 -
テストコードの可読性
- テスト名は「メソッド名_入力_期待結果」の形で記述し、テストの意図が一目で分かるようにします。
- アサーションはassertEqualsだけでなく、assertThrowsやassertAllなど、意図に適したものを選びます。 -
テストの独立性と高速性
- テスト同士が相互作用しないこと(状態共有しない)を徹底し、実行順序に依存しないようにします。
- データベースやファイルIOはできるだけインメモリ化し、テスト実行時間を数ミリ秒レベルに抑えます。 -
CI/CD への組み込み
- ローカルだけでなく、GitHub Actions や Jenkins などのCI環境でも同じテストが通ることを前提に、テストレポートやカバレッジレポートを自動生成します。
これらの観点を意識すれば、テストが「ドキュメント」になるだけでなく、「安全に開発を進めるための基盤」となります。
実践編:JUnitテスト作成の具体的手順
以下では、実際に「ユーザー登録」機能を例に、先ほどの観点を踏まえたテストコードを段階的に作成していきます。
ステップ1:テスト対象クラスとメソッドの設計
Javapublic class UserService { private final UserRepository repo; private final PasswordEncoder encoder; public UserService(UserRepository repo, PasswordEncoder encoder) { this.repo = repo; this.encoder = encoder; } public User register(String email, String rawPassword) { if (repo.existsByEmail(email)) { throw new IllegalArgumentException("Email already exists"); } String hashed = encoder.encode(rawPassword); User user = new User(email, hashed); return repo.save(user); } }
- 粒度の確認:
registerメソッドは外部リポジトリとエンコーダに依存しているため、モック化が必須です。 - テストケースの抽出:
1. 正常にユーザーが登録できる
2. 既にメールが存在する場合は例外がスローされる
3.emailがnullのときはNullPointerException(もしくはバリデーション例外)
ステップ2:テストクラスの雛形作成とモック設定
Java@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository repo; @Mock private PasswordEncoder encoder; @InjectMocks private UserService service; }
@ExtendWith(MockitoExtension.class)で Mockito の拡張を有効化し、@Mockと@InjectMocksによって依存オブジェクトを自動注入します。- 独立性の確保:各テストメソッドの開始前にモックの振る舞いを再定義し、状態が残らないようにします。
ステップ3:正常系テストの実装
Java@Test @DisplayName("正常系: 新規ユーザーが登録できること") void register_success() { // Arrange String email = "test@example.com"; String rawPwd = "secret"; String hashedPwd = "hashedSecret"; when(repo.existsByEmail(email)).thenReturn(false); when(encoder.encode(rawPwd)).thenReturn(hashedPwd); when(repo.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); // Act User result = service.register(email, rawPwd); // Assert assertAll( () -> assertEquals(email, result.getEmail()), () -> assertEquals(hashedPwd, result.getPassword()), () -> verify(repo).save(result) ); }
- 可読性:
@DisplayNameでテストの意図を明示し、assertAllで関連アサーションをまとめています。 - 例外系のテストは次のステップで示します。
ステップ4:例外系テストの実装
Java@Test @DisplayName("異常系: すでにメールが存在する場合は例外が投げられる") void register_emailAlreadyExists() { // Arrange String email = "dup@example.com"; when(repo.existsByEmail(email)).thenReturn(true); // Act & Assert IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> service.register(email, "anyPwd") ); assertEquals("Email already exists", ex.getMessage()); verify(repo, never()).save(any()); }
assertThrowsとverify(..., never())を組み合わせることで、副作用が起きていないことも同時に検証しています。
ステップ5:境界値・Null入力テスト
Java@Test @DisplayName("異常系: email が null のとき NullPointerException が投げられる") void register_nullEmail() { // Arrange when(repo.existsByEmail(null)).thenThrow(NullPointerException.class); // Act & Assert assertThrows(NullPointerException.class, () -> service.register(null, "pwd")); }
- ここでは モックが例外を投げる設定 を利用し、実装側の NPE 発生箇所を確認します。実際のプロジェクトでは、バリデーション層で事前チェックを入れることが多いです。
ステップ6:パラメータ化テストで網羅性を向上させる
Java@ParameterizedTest(name = "無効なメール形式: {0}") @ValueSource(strings = {"plain", "missing@domain", "@noUser.com", "user@.com"}) void register_invalidEmailFormat(String invalidEmail) { // Arrange when(repo.existsByEmail(invalidEmail)).thenReturn(false); // Act & Assert assertThrows(IllegalArgumentException.class, () -> service.register(invalidEmail, "pwd")); }
@ParameterizedTestと@ValueSourceを併用し、同一ロジックのバリエーションテストを簡潔に記述できます。
ステップ7:モック以外の外部リソースを扱う場合の対策
もしリポジトリが実際のデータベースにアクセスする設計であれば、TestContainers や H2 のインメモリデータベースを使用します。
Java@Test @Tag("integration") void register_withRealRepository() { // H2 データベースを起動し、UserRepository の実装を注入 // ここでは省略。実装例は公式ドキュメント参照 }
- 本番環境と同じ設定でテストを走らせる統合テストは、ユニットテストだけでは捕捉できない不具合を事前に検出できます。
ハマった点やエラー解決
| 発生した問題 | 原因 | 解決策 |
|---|---|---|
NullPointerException がテスト側でスローされた |
モックが when(...).thenReturn(...) の前に null を受け取っていた |
lenient() を付与するか、対象メソッドで null チェックを明示的に実装 |
| テストが実行順序に依存した | 静的フィールドに状態を保持していた | テストクラスに @TestInstance(Lifecycle.PER_METHOD) を指定し、インスタンスごとに状態をリセット |
assertEquals の期待値と実際が逆転 |
アサーションの引数順を間違えていた | JUnit5 の assertEquals(expected, actual) の順序を常に意識する |
CI への組み込み例(GitHub Actions)
Yamlname: Java CI on: push: branches: [ main ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up JDK 21 uses: actions/setup-java@v3 with: java-version: '21' distribution: 'temurin' - name: Build with Maven run: mvn -B clean verify - name: Upload test report uses: actions/upload-artifact@v3 with: name: surefire-reports path: target/surefire-reports
mvn verifyでユニットテストとカバレッジ解析が同時に走り、失敗したテストはプルリクエストのチェックとして即時フィードバックされます。
まとめ
本記事では、JUnitでユニットテストを書く際に押さえておくべき5つの観点と、実際のコード例を交えて テスト設計から実装、CI 連携までのフロー を解説しました。
- 粒度と独立性:1メソッド単位でモック化し、テスト間の状態共有を防ぐ
- 網羅性:正常系・例外系・境界値・パラメータ化テストで入力パターンを網羅
- 可読性:
@DisplayName、メソッド名、assertAllで意図を明示 - 高速性:インメモリ化・モック利用でミリ秒単位の実行時間を実現
- CI/CD への自動組み込み:GitHub Actions の例で継続的に品質を保証
これらを実践すれば、テストがプロジェクトの品質保証基盤となり、リファクタリングや機能追加を安心して行えるようになります。次回は「Mockito の高度な使い方」と「JUnit5 の拡張機能(カスタムアノテーション・タイムアウト等)」に焦点を当てた記事を執筆予定です。
参考資料
- JUnit 5 User Guide
- Mockito Documentation
- Effective Unit Testing with JUnit and Mockito (書籍)
- GitHub Actions for Java
