はじめに
前回の記事
では、Spring AI の ChatClient を使ってGeminiと会話するAPIを、GETからPOST、コントローラー直書きからService層への切り出しへとリファクタリングしました。
今回は、そのService(SpringAiSample03Service)に対して単体テストを書いてみます。プロジェクトは「SpringAISample02」を拡張した「SpringAISample03」として進めます。
前提環境
今回はテスト環境の都合、およびJDK 21で出る警告を検証する目的もあり、前提環境を以下に更新して進めます(前回までは Maven / JDK 17 / Spring Boot 3.x でした)。
- Eclipse Version: 2025-12 (4.38.0)(Spring Tools がインストール済み)
- JDK 21
- Spring Boot 4.1.1 / Spring AI 2.0.1
- ビルドツール:Gradle
1. LLMを使ったコードのテストの難しさ
生成AIを使ったコードのテストには、通常のコードとは違った難しさがあります。LLMからの応答は非決定論的(同じ質問を送っても毎回違う文章が返ってくる)なので、assertEquals("〇〇", answer) のような、値をそのまま比較するテストが書けません。
そこで今回は、「Geminiに実際に問い合わせる部分」をMockitoでモック化し、「SpringAiSample03Service が、Geminiからの応答を正しく Answer に詰め替えて返しているか」だけを検証するテストを書きます。LLMの応答内容そのものの妥当性(=答えの中身が合っているか)を検証する話ではなく、あくまで自分が書いたコードのロジックを検証する、という位置づけです。
Tips:LLMの応答内容そのものの妥当性を検証したい場合は、「LLM自身に応答を評価させる」エバリュエーターという手法もあります。これはまた別の記事で扱う予定です。
2. なぜWireMockではなくMockitoなのか
こういったテストの定番として、HTTPレベルでAPIをモック化する WireMock という手法もあります。実際、参考にしている原著でもOpenAIを使ったサンプルではWireMockが使われています。
ただし、これは前回まで使ってきたGoogle GenAI(Gemini)構成にはそのまま使えません。OpenAI向けのSpring AI統合は素のHTTPクライアントでAPIを呼んでおり、spring.ai.openai.base-url を差し替えるだけでWireMockに向け先を変更できます。一方、Google GenAI向けの統合はGoogle公式のSDK(com.google.genai.Client)を内部で使っており、Spring AI側にはHTTPの向け先を差し替えるようなプロパティが用意されていません(設定できるのは api-key や project-id などの接続情報のみです)。
そこで今回は、HTTPレイヤーをモック化するのではなく、ChatClient そのものをMockitoでモック化するアプローチを取ります。都合の良いことに、このやり方は spring-boot-starter-test(Initializrでプロジェクトを作った時点で最初から入っています)に含まれるMockitoだけで書けるため、WireMockのような追加の依存関係も不要です。
3. テストコードの実装
SpringAiSample02Service は、コンストラクタで ChatClient.Builder を受け取り、prompt().user().call().content() というフルーエントAPIでGeminiに問い合わせていました(前回のおさらい)。今回はこのクラスを「SpringAISample03」プロジェクトに SpringAiSample03Service としてそのまま移植します。
@Service
public class SpringAiSample03Service implements SpringAiService {
private final ChatClient chatClient;
public SpringAiSample03Service(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@Override
public Answer askQuestion(Question question) {
var answerText = chatClient.prompt()
.user(question.question())
.call()
.content();
return new Answer(answerText);
}
}
このメソッドチェーンを丸ごとMockitoでモック化し、「content() が特定の文字列を返したときに、askQuestion() がその文字列を Answer に詰めて返すか」を検証します。
package com.example.springaisample;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SpringAiSample03ServiceTest {
private ChatClient.Builder chatClientBuilder;
private ChatClient chatClient;
private ChatClient.ChatClientRequestSpec requestSpec;
private ChatClient.CallResponseSpec callResponseSpec;
@BeforeEach
void setup() {
chatClientBuilder = mock(ChatClient.Builder.class);
chatClient = mock(ChatClient.class);
requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
callResponseSpec = mock(ChatClient.CallResponseSpec.class);
// ChatClient.Builder#build() が、モック化した chatClient を返すようにする
when(chatClientBuilder.build()).thenReturn(chatClient);
// prompt().user(...).call().content() のチェーンをすべてつなげる
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user("なぜ、犬はおなかを見せるのでしょうか?")).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(callResponseSpec);
when(callResponseSpec.content()).thenReturn("お腹を見せるのは、信頼している相手に対する愛情表現の一つです。");
}
@Test
void askQuestion_ChatClientの応答をAnswerに詰め替えて返す() {
var service = new SpringAiSample03Service(chatClientBuilder);
var answer = service.askQuestion(new Question("なぜ、犬はおなかを見せるのでしょうか?"));
assertThat(answer.answer())
.isEqualTo("お腹を見せるのは、信頼している相手に対する愛情表現の一つです。");
}
}
コードのポイント
-
ChatClientはフルーエントAPI(メソッドチェーン)になっているため、prompt()→user()→call()→content()のそれぞれの戻り値を1つずつモック化してつないでいく必要があります。ここが、通常のクラスをモック化するときと違って少し手間がかかる点です -
chatClientBuilder.build()をモック化しておくことで、SpringAiSample03Serviceのコンストラクタに実際の設定(APIキーなど)を一切渡さずにインスタンス化できます。つまり、このテストはAPIキーなし・ネットワーク接続なしで実行できます -
requestSpec.user(...)の引数は、実際にaskQuestion()に渡すQuestionの中身と一致させる必要があります(Mockitoはデフォルトでは引数の値まで一致しないとwhen()が効きません)
💡 発展Tips:
Answers.RETURNS_DEEP_STUBSでさらに簡潔に書く上記では
prompt()→user()→call()→content()の戻り値を1つずつmock()してつなぎましたが、MockitoのAnswers.RETURNS_DEEP_STUBSを使うと、チェーンの途中に出てくるオブジェクトを自動でモック化してくれるため、もっと簡潔に書けます。import org.mockito.Answers; // ... @BeforeEach void setup() { chatClientBuilder = mock(ChatClient.Builder.class); // RETURNS_DEEP_STUBS を使うことで、配下のチェーンオブジェクトも自動でモック化される chatClient = mock(ChatClient.class, Answers.RETURNS_DEEP_STUBS); when(chatClientBuilder.build()).thenReturn(chatClient); // メソッドチェーンを1行でモック化できる when(chatClient.prompt() .user("なぜ、犬はおなかを見せるのでしょうか?") .call() .content()) .thenReturn("お腹を見せるのは、信頼している相手に対する愛情表現の一つです。"); }
requestSpecやcallResponseSpecを個別に宣言・モック化する必要がなくなり、コード量がかなり減ります。ただし、内部でチェーン全体を自動でモック化している分、「今どの型がモックされているのか」が見えにくくなるという面もあります。今回のように、フルーエントAPIのモック化を初めて学ぶ場面では1つずつ書く方法が理解しやすく、慣れてきたらRETURNS_DEEP_STUBSに切り替える、という使い分けがおすすめです。
4. 実行
テストの実行方法は2通りありますが、今回は Gradle経由での実行 をおすすめします(理由は次の節で説明します)。
Eclipseの「Gradle Tasks」ビュー(表示されていなければ「ウィンドウ」→「ビューの表示」→「その他」→「Gradle」→「Gradle Tasks」)を開き、プロジェクトの verification → test をダブルクリックして実行します。ターミナルから ./gradlew test を実行しても同様です。
コンソールに以下のように表示されれば成功です。
> Task :compileJava
> Task :processResources
> Task :classes
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
BUILD SUCCESSFUL in 6s
4 actionable tasks: 4 executed
APIキーの設定や実際のネットワーク通信なしに、一瞬でテストが完了することが確認できるはずです。
5. 補足:「Mockito is currently self-attaching...」という警告が出た場合
環境によっては、テスト実行時に以下のような警告がコンソールに出ることがあります(テスト自体は成功しますが、放置しておくと将来のJDKで動かなくなる可能性があります)。
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (...byte-buddy-agent-x.x.x.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: Dynamic loading of agents will be disallowed by default in a future release
なぜ出るのか
Mockitoの mock() は、内部でクラスのバイトコードを書き換えることでモックを作っています(inline-mock-maker)。この書き換えのために、Mockitoは byte-buddy-agent というJavaエージェントを、起動中のJVMに後から動的にアタッチしています。しかし近年のJDK(Java 21以降で顕著)は、セキュリティ強化の一環としてこの「動的アタッチ」に制限をかける方向に進んでおり、将来のリリースではこの方法自体が使えなくなる予定です。
対処法:build.gradle にMockitoを正式なエージェントとして登録する
build.gradle の末尾に、以下のブロックを追記します。
tasks.withType(Test).configureEach {
doFirst {
def mockitoAgent = configurations.testRuntimeClasspath.get().files.find {
it.name.contains('mockito-core')
}
if (mockitoAgent != null) {
jvmArgs("-javaagent:${mockitoAgent.absolutePath}")
}
}
}
これは「テスト実行時に、依存関係の中からmockito-coreのjarファイルを見つけて、それを-javaagentとしてJVMに渡す」という設定です。これにより、「動的な自己アタッチ」ではなく「最初からJVMにエージェントとして登録された状態」でMockitoが起動するようになり、警告が消えます。
注意:この設定はGradle経由で実行したときだけ効きます
ここが一番ハマりやすいポイントです。上記の設定は、あくまで Gradleのtestタスクを経由して実行したときだけ有効になります。Eclipseで「テストクラスを右クリック → 実行 → JUnitテスト」を選んだ場合、Eclipseは独自のJUnitランチャーでテストを実行するため、build.gradle に書いたVM引数の設定は反映されません(クラスパスはGradleから取得しますが、タスクの設定までは見てくれません)。
そのため、この記事の「4. 実行」で紹介したように、Gradle Tasksビューやターミナルからtestタスクを実行する必要があります。Eclipseの「JUnitテスト」実行のまま警告を消したい場合は、実行構成(Run Configurations)のVM引数欄に、直接 -javaagent: でmockito-coreのjarパスを指定する方法もあります。
まとめ
今回は、Gemini(Google GenAI)を使ったServiceクラスに対して、Mockitoで ChatClient をモック化する単体テストを書きました。
- Google GenAI統合はGoogle公式SDKを内部で使っており、OpenAIのようにHTTPの向け先(base-url)を差し替えられないため、WireMockのようなHTTPレベルのモック化はそのままでは使えない
- 代わりに
ChatClientのフルーエントAPIをMockitoで直接モック化することで、追加の依存関係なしに、APIキーもネットワークも使わないテストが書ける - このテストは「LLMの応答内容が正しいか」ではなく、「Serviceがその応答を正しく扱えているか」を検証するものである
- JDK 21以降では、Mockitoの動的エージェント自己アタッチに関する警告が出ることがある。
build.gradleにエージェント設定を追記することで解消できるが、この設定はGradle経由でテストを実行したときだけ有効という点に注意が必要
次回は、会話履歴を保持する「チャットメモリ」か、応答が質問に関連しているかをLLM自身に評価させる「エバリュエーター」のどちらかを扱う予定です。
参考
