11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【生成AI活用】ゼロから2日でできる!Java自動テスト(UT/IT/E2E)導入ガイド【692件実例付】

Posted at

はじめに

GMOコネクトの永田です。

5年ぶりぐらいにJava BackendなAPI Serverのメンテナンスに関わりました。

幸か不幸か、自動テストが全く導入されていなかったプロジェクトだったため(Mavenなどもなかったですが😇)、Github Copilotと相談しつつ自動試験を導入しました。

最終的に2日ぐらいで、ユニットテスト481件、統合テスト193件、シナリオテスト18件の合計692件のテストを導入することができました。

この記事では、テストフェーズごとのライブラリ選定理由と、実践的な使い方のポイントを解説します。実際にプロジェクトに導入して運用している知見をお伝えできれば幸いです!

まとめ

  • JUnit 5 + Mockito + Hamcrestで高速・高品質なユニットテストを実現(481件、5秒)
  • Failsafe + Tomcat Catalinaで本番環境に近い統合テストを実現(193件、1秒)
  • REST Assured + Awaitilityで可読性の高いE2Eテストを実現(18件、12秒)
  • JaCoCoで91%の命令カバレッジを達成
  • 結果として、692件のテストを約20秒で実行し、継続的な品質担保を実現
    • 生成AIのサポートありだと、0からの自動テストコードの実装でも、2日程度で対応可能
  • 自動テストコードの作成は非常にコストがかかるという感覚があったが、生成AIを使うと手動でのテストよりはるかに効率的
    • 特にAsIsのテストコード作成に効果を発揮する(ToBeでも適切なレビューができれば、効果は高い)
  • シナリオ試験に向けて、全体概要やサブシステム間シーケンスなどは、Markdownでまとめておこう

結果サマリー

選定したライブラリ一覧

本記事で紹介する、各テストフェーズで選定したライブラリの一覧です。

テストフェーズ ライブラリ バージョン 役割 選定のポイント
ユニットテスト Mockito 5.20.0 モックオブジェクト生成 外部依存を排除し、ロジック単体をテスト可能に
Hamcrest 3.0 可読性の高いアサーション 自然言語に近い表現でテスト意図を明確化
JaCoCo 0.8.14 コードカバレッジ測定 テストの網羅性を定量的に把握
統合テスト Failsafe 3.5.4 統合テスト実行プラグイン ビルドライフサイクルでUT/ITを明確に分離
シナリオテスト REST Assured 5.5.6 REST APIテスト 自然言語に近いDSLで可読性UP
Awaitility 4.3.0 非同期処理の待機 ポーリングベースで非同期完了を待機可能

テスト実行結果

$ mvn clean verify
[INFO] Tests run: 692, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 20.6 s

ユニットテスト481件、統合テスト193件、シナリオテスト18件の合計692件のテストを、約20秒で実行し、91%のコードカバレッジを達成しています。

自動テストの全体像

今回のテストの全体像を最初にまとめます。

検証環境

  • Java: OpenJDK 11 (Eclipse Temurin)
  • ビルドツール: Maven 3.9
  • フレームワーク: JAX-RS (Jersey 2.41)
  • データベース: MariaDB 10.11
  • アプリケーション構成:
    • API: REST API (JAX-RS)
    • 外部連携: 外部認証サーバー (SOAP), 外部申請サーバー (REST)
    • 非同期処理: スレッドプール + データベースポーリング

ディレクトリ構成

project/
├── src/main/java/              # プロダクションコード
├── src/test/java/              # ユニットテスト(外部依存なし)
├── src/integration-test/java/  # 統合テスト(DB/サーバー必要)
└── src/scenario-test/java/     # シナリオテスト(E2Eテスト)

テストピラミッドと使用ライブラリ

        /\
       /ST\     シナリオテスト (18)
      /____\    ├─ REST Assured: HTTPリクエスト/レスポンス検証
     /      \   └─ Awaitility: 非同期処理待機
    / 統合IT \  統合テスト (193)
   /__________\ └─ Failsafe: IT実行プラグイン
  /            \
 /  ユニットUT  \ ユニットテスト (481)
/________________\├─ Mockito: モックによる外部依存排除
                  ├─ Hamcrest: 可読性の高いアサーション
                  └─ JaCoCo: カバレッジ測定

共通基盤: JUnit 5 (全テストで使用)

フェーズごとの責務

フェーズ 目的 テスト対象 外部依存
ユニットテスト ロジックの正確性 単一クラス/メソッド ❌ なし(Mock使用)
統合テスト 連携の正確性 API + DB + JNDI ✅ あり(Docker環境)
シナリオテスト 業務フローの正確性 複数API連携 ✅ あり(Docker + Stub)

工程毎に採用したライブラリの紹介

では、実際に各工程で導入したライブラリについて紹介していきます。

  • ライブラリの概要
  • 導入した理由
  • 簡単な使い方の解説

1. ユニットテスト(481件、~5秒)

使用ライブラリ一覧

ユニットテストで使用している主要ライブラリは以下の通りです:

  • JUnit 5.14.0: テスティングフレームワーク(共通基盤)
  • Maven Surefire Plugin 3.5.4: ユニットテスト実行プラグイン
  • Mockito 5.20.0: モックオブジェクト生成
  • Hamcrest 3.0: 可読性の高いアサーション
  • JaCoCo 0.8.14: コードカバレッジ測定

本セクションでは、ユニットテストで特に重要な MockitoHamcrestJaCoCo の3つを詳しく解説します。


① Mockito 5.20.0 - モックによる外部依存の排除

なぜMockitoが必要なのか

ユニットテストでは、テスト対象のロジック単体を検証することが重要です。しかし、実際のコードは以下のような外部依存を持っています:

  • データベースアクセス(JPA/EntityManager)
  • HTTP通信(外部APIコール)
  • ファイルI/O
  • 環境変数・システムプロパティ

これらの外部依存をそのままテストすると、以下の問題が発生します:

テストが遅い: DBアクセスやHTTP通信は時間がかかる
テストが不安定: ネットワーク障害やDB状態でテストが失敗する
セットアップが複雑: テストデータの準備が大変
CI/CD環境で実行困難: GitHub ActionsなどでDBやネットワークの準備が必要

Mockitoを使うことで、これらの外部依存を モック(模擬オブジェクト) に置き換え、高速で安定したユニットテスト を実現できます。さらに、CI/CD環境でも追加のインフラなしで実行可能になります。

採用理由

  • ✅ 外部依存(DB、HTTP通信など)を完全にモック化
  • ✅ テストの高速化(DBアクセスなし → ミリ秒単位で完了)
  • ✅ CI/CD環境での実行が容易(GitHub Actions等でインフラ不要)
  • ✅ Spy機能で部分的なモック化が可能
  • ✅ JUnit 5との統合が容易(@ExtendWith(MockitoExtension.class)

実装例:外部API通信のモック化

@ExtendWith(MockitoExtension.class)
@DisplayName("外部認証サーバーとの通信テスト")
class ExternalAuthServiceTest {
    
    @Mock
    private CloseableHttpClient httpClient;  // HTTPクライアントをモック化
    
    @Mock
    private HttpResponse httpResponse;  // HTTPレスポンスをモック化
    
    @Mock
    private StatusLine statusLine;  // ステータスラインをモック化
    
    @InjectMocks
    private ExternalAuthService service;  // モックを自動注入
    
    @Test
    @DisplayName("外部認証が成功した場合、レスポンスコード200を返す")
    void testSuccessfulAuthentication() throws IOException {
        // モックの振る舞いを定義(Given)
        when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse);
        when(httpResponse.getStatusLine()).thenReturn(statusLine);
        when(statusLine.getStatusCode()).thenReturn(200);
        when(httpResponse.getEntity()).thenReturn(
            new StringEntity("{\"status\":\"success\",\"sessionId\":\"abc123\"}")
        );
        
        // テスト実行(When)
        AuthResponse response = service.authenticate("user001", "password");
        
        // 検証(Then)
        assertThat(response.getStatus(), equalTo("success"));
        assertThat(response.getSessionId(), equalTo("abc123"));
        
        // モックが正しく呼ばれたことを確認
        verify(httpClient, times(1)).execute(any(HttpPost.class));
    }
    
    @Test
    @DisplayName("外部認証が失敗した場合、例外をスローする")
    void testFailedAuthentication() throws IOException {
        // モックが例外をスローするように設定
        when(httpClient.execute(any(HttpPost.class)))
            .thenThrow(new IOException("Connection timeout"));
        
        // 例外が発生することを検証
        assertThrows(ExternalAuthException.class, () -> {
            service.authenticate("user001", "password");
        });
    }
}

Mockitoの主要機能

機能 説明
@Mock モックオブジェクトを生成 @Mock private HttpClient client;
@InjectMocks モックを自動注入 @InjectMocks private MyService service;
when().thenReturn() モックの戻り値を定義 when(client.get()).thenReturn(response);
when().thenThrow() モックが例外をスローするように設定 when(client.get()).thenThrow(IOException.class);
verify() モックが呼ばれたことを検証 verify(client, times(1)).get();
@Spy 実オブジェクトの一部メソッドのみモック化 @Spy private MyService service;

Mockitoを使うべき場面

使うべき場面:

  • データベースアクセスをモック化(EntityManager、Repository)
  • 外部APIコールをモック化(HTTP通信)
  • ファイルI/Oをモック化
  • 時刻依存のロジック(LocalDateTime.now()をモック化)

使わない方が良い場面:

  • シンプルな計算ロジック(Utilityクラスなど)
  • データクラス(Getter/Setterのみ)
  • 統合テスト(実際のDBやAPIを使うべき)

② Hamcrest 3.0 - 可読性を高めるアサーション

なぜHamcrestが必要なのか

JUnitの標準アサーション(assertEqualsassertTrueなど)でも基本的なテストは可能ですが、以下のような課題があります:

可読性が低い: assertEquals(expected, actual) の引数順が分かりにくい
複雑な検証が難しい: コレクションの検証でループが必要
エラーメッセージが不親切: 何が期待値で何が実際の値か分かりにくい

Hamcrestを使うことで、自然言語に近い表現でテストの意図を明確に示せます。

採用理由

  • 可読性の向上: assertThat(actual, is(expected)) のような自然言語に近い表現
  • 複雑な検証が簡単: Matcherの組み合わせで柔軟な検証が可能
  • JUnitとの併用が可能: 既存のJUnitテストに段階的に導入できる

JUnitとの比較:基本的な検証

// ❌ JUnit標準のアサーション
@Test
void testWithJUnit() {
    String actual = service.getName();
    assertEquals("John", actual);  // どちらがexpected?
    
    List<String> list = service.getNames();
    assertEquals(3, list.size());  // サイズだけ検証
    assertTrue(list.contains("Alice"));  // 個別に検証
    assertTrue(list.contains("Bob"));
}

// ✅ Hamcrestのアサーション
@Test
void testWithHamcrest() {
    String actual = service.getName();
    assertThat(actual, equalTo("John"));  // 明確に「actualがJohnと等しい」
    
    List<String> list = service.getNames();
    assertThat(list, hasSize(3));  // 可読性が高い
    assertThat(list, hasItems("Alice", "Bob"));  // 複数を一度に検証
}

JUnitとの比較:複雑な検証

Hamcrestの真価は、複数のMatcherを組み合わせることで、JUnitでは煩雑になる検証を簡潔に表現できる点にあります。

// ❌ JUnit標準: ループと複数のアサーションが必要(冗長)
@Test
void testUserListWithJUnit() {
    List<User> users = userService.getActiveUsers();
    
    // サイズの範囲チェック
    assertTrue(users.size() >= 1);
    assertTrue(users.size() <= 10);
    
    // 全要素のチェック(ループが必要)
    for (User user : users) {
        assertNotNull(user.getEmail());
        assertTrue(user.getEmail().contains("@"));
        assertTrue(user.getAge() >= 20);
    }
}

// ✅ Hamcrest: Matcherの組み合わせで簡潔に表現
@Test
void testUserListWithHamcrest() {
    List<User> users = userService.getActiveUsers();
    
    // サイズの範囲チェック(1行で表現)
    assertThat(users, hasSize(both(greaterThanOrEqualTo(1)).and(lessThanOrEqualTo(10))));
    
    // 全要素のチェック(ループ不要)
    assertThat(users, everyItem(hasProperty("email", 
        allOf(notNullValue(), containsString("@")))));
    assertThat(users, everyItem(hasProperty("age", greaterThanOrEqualTo(20))));
}

このように、HamcrestのeveryItemallOfboth().and()などのコンビネーターを使うことで:

  • ループを書かずにコレクション全体を検証できる
  • 複数条件を1つのアサーションにまとめられる
  • テストの意図が一目で分かる

よく使うMatcher

Hamcrestには豊富なMatcherが用意されています。詳細は公式ドキュメントを参照してください。

特にJUnitと比べて便利なMatcher:

カテゴリ Matcher 用途
コレクション hasSize(n) サイズ検証 assertThat(list, hasSize(3))
hasItems(x, y) 複数要素を一度に検証 assertThat(list, hasItems("a", "b"))
everyItem(matcher) 全要素が条件を満たす assertThat(list, everyItem(greaterThan(0)))
containsInAnyOrder(...) 順序不問で一致 assertThat(list, containsInAnyOrder("b", "a"))
組み合わせ allOf(x, y) 複数条件をAND assertThat(value, allOf(notNullValue(), startsWith("A")))
both(x).and(y) 2つの条件をAND assertThat(num, both(greaterThan(0)).and(lessThan(100)))
hasProperty(name, matcher) オブジェクトのプロパティ検証 assertThat(user, hasProperty("name", equalTo("John")))
文字列 containsString(x) 部分一致 assertThat(str, containsString("test"))
startsWith(x) 前方一致 assertThat(str, startsWith("Hello"))

③ JaCoCo 0.8.14 - コードカバレッジの可視化

なぜJaCoCoが必要なのか

テストを書いても、どこがテストされていないかを把握するのは困難です。JaCoCoを使うことで:

テストの網羅性を定量的に把握できる
HTMLレポートで視覚的に確認できる
カバレッジ目標を設定してチームで品質基準を共有できる

採用理由

  • ✅ Mavenとの統合が容易(プラグインを追加するだけ)
  • ✅ HTMLレポートが自動生成される
  • ✅ 命令カバレッジ・分岐カバレッジの両方をサポート
  • ✅ CI/CDパイプラインとの連携が簡単

実行コマンド

# カバレッジ測定(テスト実行時に自動生成)
mvn clean test

# レポート確認
open target/site/jacoco/index.html

カバレッジ結果の例

[INFO] Tests run: 481, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] --- jacoco:0.8.13:report (report) @ app-backend ---
[INFO] Analyzed bundle 'Application Backend' with 65 classes
[INFO] Instruction Coverage: 91% (6,542/7,131)
[INFO] Branch Coverage:      87% (270/310)

HTMLレポートの見方

JaCoCoのHTMLレポートは、以下のように色分けされます:

  • 🟢 緑色: カバーされたコード(テストで実行された)
  • 🔴 赤色: カバーされていないコード(テストで実行されていない)
  • 🟡 黄色: 部分的にカバーされたコード(分岐の一部のみテスト)
public int calculateDiscount(int price, boolean isMember) {
    if (isMember) {  // 🟡 黄色:両方の分岐がテストされていない
        return price * 90 / 100;  // 🔴 赤色:テストされていない
    }
    return price;  // 🟢 緑色:テストされている
}

💡 100%を目指さない: Getter/Setterなどの単純なコードや、エラーハンドリングの一部は無理に100%にする必要はありません。

JaCoCoの制限事項

⚠️ 統合テストのカバレッジは含まれない
Surefire Pluginで実行されるユニットテストのみがカバレッジ対象です。統合テスト(Failsafe Plugin)のカバレッジは別途設定が必要です。

⚠️ カバレッジ≠品質保証
カバレッジが高くても、アサーションが不十分ならテストの意味がありません。「実行されたか」だけでなく「正しく検証されているか」が重要です。


2. 統合テスト(193件、~1秒)

使用ライブラリ一覧

統合テストで使用している主要ライブラリは以下の通りです:

  • JUnit 5.10.0: テスティングフレームワーク(共通基盤)
  • Maven Failsafe Plugin 3.0.0: 統合テスト実行プラグイン
  • Tomcat Catalina 9.0.82: JNDI環境の提供

本セクションでは、統合テストで特に重要な Failsafe を詳しく解説します。


① Maven Failsafe Plugin 3.0.0 - 統合テスト実行プラグイン

なぜFailsafeが必要なのか

統合テストは、ユニットテストと異なり、実際の環境に近い状態でテストする必要があります:

  1. 環境準備: Docker Compose、データベース起動、初期データ投入
  2. アプリケーションデプロイ: WARファイルのビルドとデプロイ
  3. テスト実行: 実際のHTTPリクエストやDB接続を使った検証
  4. 環境クリーンアップ: コンテナ停止、データ削除

この一連のフローをMavenのライフサイクルと統合し、テスト失敗時でも必ずクリーンアップを実行できるのがFailsafeの強みです。

採用理由

  • Mavenライフサイクルとの統合: 環境準備→テスト→クリーンアップを自動化
  • Surefireとの分離: ユニットテスト(mvn test)と統合テスト(mvn verify)を明確に分離
  • テスト失敗時の安全なクリーンアップ: テストが失敗してもクリーンアップフェーズが実行される
  • *IT.java パターンの自動認識: 命名規則でテストを自動分類

Mavenライフサイクルとの統合

Failsafeは、Mavenの以下のフェーズ(★)と連携します:

1. compile           → プロダクションコードのコンパイル
2. test-compile      → テストコードのコンパイル
3. test              → ユニットテスト実行 (Surefire)
4. package           → WARファイル作成
★ 5. pre-integration-test  → 環境準備フェーズ
     └─ Docker起動、DB初期化、WARデプロイ
★ 6. integration-test      → テスト実行フェーズ
     └─ Failsafeがテスト実行
★ 7. post-integration-test → 環境クリーンアップ
     └─ Docker停止、データ削除
★ 8. verify                → テスト結果確認
     └─ Failsafeが結果をチェック(ここで初めて失敗)

重要ポイント: Failsafeはintegration-testフェーズではビルドを失敗させず、post-integration-testでクリーンアップを実行した後、verifyフェーズで初めて結果をチェックします。これにより、テストが失敗してもDocker環境は必ず停止されるという安全性が保証されます。

実行例

# 統合テスト実行(環境準備→テスト→クリーンアップが自動実行)
mvn clean verify

# 実行フロー:
# [INFO] --- maven-compiler-plugin:3.11.0:compile
# [INFO] --- maven-compiler-plugin:3.11.0:testCompile
# [INFO] --- maven-surefire-plugin:3.5.4:test (ユニットテスト)
# [INFO] Tests run: 481, Failures: 0, Errors: 0, Skipped: 0
# [INFO] --- maven-war-plugin:3.3.2:war
# [INFO] Building war: target/app.war
# [INFO] --- docker-maven-plugin:0.43.4:start (Docker起動)
# [INFO] --- maven-failsafe-plugin:3.5.4:integration-test (統合テスト)
# [INFO] Tests run: 193, Failures: 0, Errors: 0, Skipped: 0
# [INFO] --- docker-maven-plugin:0.43.4:stop (Docker停止)
# [INFO] --- maven-failsafe-plugin:3.5.4:verify (結果確認)
# [INFO] BUILD SUCCESS

SurefireとFailsafeの使い分け

プラグイン 対象テスト 実行タイミング 環境要件 失敗時の挙動
Surefire ユニットテスト (*Test.java) test フェーズ なし(モックのみ) 即座にビルド失敗
Failsafe 統合テスト (*IT.java) integration-test フェーズ DB、サーバーなど クリーンアップ後に失敗

② Tomcat Catalina 9.0.111 - JNDI サポート

なぜTomcat Catalinaが必要なのか:

本プロジェクトでは、統合テストで WARファイルをデプロイせず、DBのみ起動してテストを実行 する方式を採用しています。

  • WARデプロイ + Tomcat起動の場合: Tomcat Catalinaの依存関係は不要(起動したTomcatがJNDI機能を提供)
  • 本プロジェクトの場合: DBのみ起動 + テストコード内でJNDI環境を構築 → Tomcat Catalinaの依存関係が必要

この方式により、重いTomcatの起動を避けつつ、本番環境と同じJNDI設定でテスト可能になります。

採用理由:

  • 統合テストでJNDI(Java Naming and Directory Interface)環境を再現
  • データソースの名前解決をテスト環境で実現(java:/comp/env/jdbc/AppDB
  • WARデプロイなしで軽量・高速にテスト実行

実装例:

@BeforeAll
static void setupJNDI() throws Exception {
    // JNDIコンテキスト初期化
    System.setProperty(Context.INITIAL_CONTEXT_FACTORY,
        "org.apache.naming.java.javaURLContextFactory");
    System.setProperty(Context.URL_PKG_PREFIXES, "org.apache.naming");
    
    InitialContext ic = new InitialContext();
    
    try {
        ic.createSubcontext("java:");
        ic.createSubcontext("java:/comp");
        ic.createSubcontext("java:/comp/env");
        ic.createSubcontext("java:/comp/env/jdbc");
    } catch (NameAlreadyBoundException e) {
        // 既にバインド済みの場合は無視
    }
    
    // データソース登録
    MariaDbDataSource dataSource = new MariaDbDataSource();
    dataSource.setUrl("jdbc:mariadb://localhost:13306/appdb");
    dataSource.setUser("appuser");
    dataSource.setPassword("password");
    
    ic.bind("java:/comp/env/jdbc/AppDB", dataSource);
}

ポイント:

  • ✅ プロダクションコードの Context.lookup() が統合テストでも動作
  • ✅ Docker環境のMariaDBに接続可能
  • ⚠️ @BeforeAll で1回だけ初期化すること(毎回初期化するとエラー)

統合テストの実行環境

Docker Compose環境

# docker-compose.yml
# 統合テスト用:DBのみ起動(アプリケーションサーバーは起動しない)
services:
  db:
    image: mariadb:10.11
    container_name: app-db
    ports:
      - "13306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: appdb
      MYSQL_USER: appuser
      MYSQL_PASSWORD: password

ポイント:

  • ✅ 統合テストでは DBのみ起動(WARデプロイ不要)
  • ✅ テストコード内でJNDI環境を構築してDB接続
  • ✅ Tomcat起動のオーバーヘッドを回避

実行手順

# 1. Docker環境起動(DBのみ)
docker-compose up -d

# 2. データベース初期化
docker exec -i app-db mysql -uappuser -ppassword appdb < DDL/001_schema.sql
docker exec -i app-db mysql -uappuser -ppassword appdb < DDL/002_master_data.sql

# 3. 統合テスト実行
mvn failsafe:integration-test failsafe:verify

# 出力例
[INFO] Running test.FetchDataResourceIT
[INFO] Tests run: 36, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running test.ProcessResourceIT
[INFO] Tests run: 143, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running test.ResultResourceIT
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 193, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

ポイント:

  • ✅ Failsafeは環境のクリーンアップ後に結果をチェック
  • ✅ Docker Mavenプラグインと組み合わせて環境構築を自動化可能
  • ✅ CI/CD環境でも同じコマンド(mvn verify)で実行可能
  • ✅ Docker環境で本番DBと同じMariaDBを使用
  • ✅ テストコードはJNDI経由でDB接続(本番コードと同じ方式)
  • ⚠️ mvn test では統合テストは実行されない(Surefireのみ)

3. シナリオテスト(18件、~12秒)

シナリオテストとは

シナリオテストは、複数のAPIを順番に呼び出すE2Eテストです。

例えば、以下のような業務フローをテストします:

  1. 初期化API呼び出し
     └─> POST /api/initialize
         ├─ requestId生成
         └─ 初期データ取得

  2. メイン処理API呼び出し(非同期処理開始)
     └─> POST /api/process
         └─ バックグラウンドで処理実行

  3. 非同期処理完了待機
     └─> Awaitility でDBをポーリング
         └─ completed_at IS NOT NULL を確認

  4. 結果取得API呼び出し
     └─> GET /api/result?requestId=${requestId}
         └─ 処理結果の検証

この一連の流れを実現するため、以下のライブラリを使用します。


使用ライブラリ

① REST Assured 5.5.6 - REST APIテスト

採用理由:

  • REST APIテストのデファクトスタンダード
  • Given-When-Then形式で可読性が高い
    • given(前提条件).when(実行).then(検証) の流れで自然言語に近い記述が可能
  • JSONパスで柔軟なレスポンス検証
  • Hamcrestとの統合

実装例:

import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;

@Test
@DisplayName("全体シナリオ: データ取得 → 処理実行 → 結果確認")
void testFullScenario() {
    String requestId = UUID.randomUUID().toString();
    
    // ===== Step1: 初期データ取得 =====
    Map<String, Object> initialResponse = 
        given()
            .contentType(ContentType.JSON)
            .body(buildInitialRequest(requestId))
        .when()
            .post("/api/initialize")
        .then()
            .statusCode(200)
            .body("resultCode", equalTo(0))
            .body("status", equalTo("ready"))
            .body("metadata.organizationId", notNullValue())
        .extract()
            .jsonPath()
            .getMap("$");
    
    // ===== Step2: メイン処理実行 =====
    given()
        .contentType(ContentType.JSON)
        .body(buildProcessRequest(requestId, initialResponse))
    .when()
        .post("/api/process")
    .then()
        .statusCode(200)
        .body("resultCode", equalTo(0));
    
    // 非同期処理完了待機(後述のAwaitilityを使用)
    await()
        .atMost(60, TimeUnit.SECONDS)
        .pollInterval(500, TimeUnit.MILLISECONDS)
        .until(() -> {
            ProcessLog log = dbHelper.findProcessLog(requestId);
            return log != null && log.getCompletedAt() != null;
        });
    
    // ===== Step3: 結果取得 =====
    given()
        .queryParam("requestId", requestId)
    .when()
        .get("/api/result")
    .then()
        .statusCode(200)
        .body("processResult.status", equalTo("completed"));
}

REST Assuredの強み:

機能 説明
Given-When-Then 自然言語に近い記述で可読性UP given().body(...).when().post(...).then().statusCode(200)
JSONPath検証 ネストしたJSONも簡単 .body("metadata.organizationId", notNullValue())
自動デシリアライズ JSON→Javaオブジェクト .extract().as(ApplicationData.class)
柔軟なアサーション Hamcrest統合 .body("list", hasSize(3))

ポイント:

  • given() でリクエスト設定、when() で実行、then() で検証
  • .extract() でレスポンスを取得して後続処理に利用可能
  • ⚠️ JSONPathは $ がルート、. で階層を辿る
  • 💡 contentType(ContentType.JSON) を忘れずに

② Awaitility 4.3.0 - 非同期処理待機

採用理由:

  • 非同期処理のポーリング待機を簡潔に記述
  • タイムアウト・リトライ間隔を柔軟に設定
  • ラムダ式で条件を記述可能

実装例:

import static org.awaitility.Awaitility.*;

/**
 * 非同期処理(外部認証連携)の完了を待機
 */
private void waitForAsyncProcessing(String requestId, int timeoutSeconds) {
    await()
        .atMost(timeoutSeconds, TimeUnit.SECONDS)      // 最大待機時間
        .pollInterval(500, TimeUnit.MILLISECONDS)      // ポーリング間隔
        .pollDelay(1, TimeUnit.SECONDS)                // 初回ポーリングまでの遅延
        .until(() -> {
            ProcessLog log = dbHelper.findProcessLog(requestId);
            
            // completed_at が設定されていれば処理完了
            return log != null && log.getCompletedAt() != null;
        });
}

Awaitilityの設定パラメータ:

パラメータ 説明 推奨値
atMost() 最大待機時間(タイムアウト) 60秒(外部処理時間)
pollInterval() ポーリング間隔 500ms(DB負荷とのバランス)
pollDelay() 初回ポーリングまでの遅延 1秒(即座にチェックしない)

ポイント:

  • until() のラムダ式が true を返すまで待機
  • ✅ タイムアウト時は ConditionTimeoutException が発生
  • ⚠️ pollInterval を短くしすぎるとDB負荷が高まる
  • 💡 pollDelay で初回の無駄なチェックを回避

従来の実装との比較:

// ❌ 従来: 手動ループ(冗長)
for (int i = 0; i < 120; i++) {
    ProcessLog log = dbHelper.findProcessLog(requestId);
    if (log != null && log.getCompletedAt() != null) {
        return;
    }
    Thread.sleep(500);
}
throw new TimeoutException("処理がタイムアウトしました");

// ✅ Awaitility: 簡潔
await()
    .atMost(60, TimeUnit.SECONDS)
    .pollInterval(500, TimeUnit.MILLISECONDS)
    .until(() -> {
        ProcessLog log = dbHelper.findProcessLog(requestId);
        return log != null && log.getCompletedAt() != null;
    });

ポイント:

  • await() で非同期処理を待機(Thread.sleep() より安全)
  • ✅ タイムアウトとポーリング間隔を細かく設定可能
  • ✅ JUnit、TestNGなどのテストフレームワークと統合可能
  • ⚠️ ポーリング対象は冪等(何度実行しても同じ結果)であること

トラブルシューティング

最後に、自動テスト実装時に遭遇したエラーの共有となります。

1. Mockitoエラー(Java 25での互換性問題)

症状:

Mockito cannot mock this class: class org.apache.http.impl.client.CloseableHttpClient

原因: Java 25でのMockito互換性問題

Mockito 5 switches the default mockmaker to mockito-inline, and now requires Java 11. Only one major version is supported at a time, and changes are not backported to older versions.

解決策:

# Java 11を使用
export JAVA_HOME=/opt/homebrew/opt/openjdk@11/libexec/openjdk.jdk/Contents/Home
mvn clean test

推奨: Java 11を使用(Java 17以降も可、サポートされるJDKバージョンは、Mockitoのページ参照)


2. JaCoCo レポートが生成されない

症状: target/site/jacoco/index.html が存在しない

原因: prepare-agent ゴールが実行されていない

解決策:

# JaCoCoを有効化してテスト実行
mvn clean test

# またはverifyフェーズで実行
mvn clean verify

3. 統合テストでJNDIエラー

症状:

javax.naming.NoInitialContextException: Need to specify class name in environment

原因: JNDIコンテキストが初期化されていない

解決策:

@BeforeAll
static void setupJNDI() throws Exception {
    System.setProperty(Context.INITIAL_CONTEXT_FACTORY,
        "org.apache.naming.java.javaURLContextFactory");
    System.setProperty(Context.URL_PKG_PREFIXES, "org.apache.naming");
    
    // Tomcat Catalinaの依存関係を追加
}

自動テスト導入の所感

今回、かなり久しぶりにJavaで自動テストを実装してみて、浦島太郎状態でした😇

  • 自動テストコードの作成は非常にコストがかかるという感覚があったが、生成AIを使うと手動でのテストよりはるかに効率的
    • 特に最近話題になっているように、AsIsのテストコード作成に効果を発揮する(ToBeでも適切なレビューができれば、効果は高い)
    • 昔は手動テストの工数の3倍〜5倍ぐらいかかり、自動テストコードを実装するか?、結構悩んだ記憶あり
  • 昔ながらのExcelとかでの試験項目表(試験手順、確認観点)を作成するぐらいなら、自動テストコードを生成AIのサポートで実装してしまうほうが、トータル工数も減りそう
    • 昔ながらのテスター(手順書に沿っての試験実施、結果確認)は不要になりそう?
    • 昔だとテスターから案件に参画して、ドメイン知識の吸収や技術のスキルアップをしていたものですが、生成AI全盛期の今ってオンボーディングとか教育って、どうなんですかね・・・?
  • 統合試験、シナリオテストにあたっては、ソースコードからだと読み取れない情報が多いので、設計時にちゃんとドキュメント化(Markdownが理想的)しておくのは重要(普通やっていると思いますが、ない案件もままあるんですよね😇)
    • 全体アーキテクチャ図、システム構成図など、全体を俯瞰できるもの
    • 主要シナリオ毎の業務フロー、フローチャート、シーケンス図などの、サブシステム間・機能間の繋がりがわかるもの

参考:

(再掲)まとめ

  • JUnit 5 + Mockito + Hamcrestで高速・高品質なユニットテストを実現(481件、5秒)
  • Failsafe + Tomcat Catalinaで本番環境に近い統合テストを実現(193件、1秒)
  • REST Assured + Awaitilityで可読性の高いE2Eテストを実現(18件、12秒)
  • JaCoCoで91%の命令カバレッジを達成
  • 結果として、692件のテストを約20秒で実行し、継続的な品質担保を実現
    • 生成AIのサポートありだと、0からの自動テストコードの実装でも、2日程度で対応可能
  • 自動テストコードの作成は非常にコストがかかるという感覚があったが、生成AIを使うと手動でのテストよりはるかに効率的
    • 特にAsIsのテストコード作成に効果を発揮する(ToBeでも適切なレビューができれば、効果は高い)
  • シナリオ試験に向けて、全体概要やサブシステム間シーケンスなどは、Markdownでまとめておこう

最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。

お問合せ: https://gmo-connect.jp/contactus/

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?