「AIエージェントってどうテストするの?」は、2026年現在のエンジニアチームで最も頻繁に出る質問のひとつです。
通常のソフトウェアテストと決定的に違うのは、LLMの出力が非決定的だという点です。同じ入力でも毎回微妙に違う出力が返ってくる。ツール呼び出しの順序が変わる。これをどうテストすればいいのか、最初は誰もが戸惑います。
実際には、「非決定的な部分(LLMの判断)」と「決定的な部分(ツールの実行ロジック、出力のパース、状態管理)」を分けて考えることで、AIエージェントのテストは意外なほど体系的に設計できます。この記事では、その分け方からpytestによる実装パターンまで、コード例とともに解説します。
AIエージェントの基本構造については、AIエージェント構築完全ガイドもあわせてご参照ください。
AIエージェントテストの3層モデル
AIエージェントのテストは以下の3層に分けて設計するのが有効です。
| テスト層 | 対象 | LLM呼び出し | 実行速度 | コスト |
|---|---|---|---|---|
| ユニットテスト | ツール関数、パーサー、バリデーター | モック(ゼロ) | ミリ秒 | $0 |
| 統合テスト | エージェント + ツール協調動作 | モック or 軽量LLM | 秒 | 最小限 |
| E2Eテスト | 完全なマルチターン会話シナリオ | 実際のLLM | 10秒〜分 | 通常APIコスト |
鉄則は「決定的な部分はユニットテストで、非決定的な部分はE2Eで」です。ユニットテストの大半はLLMを呼ばずに実行できます。
まず試したい「5分即効」テスト設定3選
即効テクニック1:ツール関数のユニットテスト
ツール関数自体はただのPython関数です。LLMと無関係にテストできます。
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
# 動作環境: Python 3.11+, pytest>=8.0, pytest-asyncio>=0.23
# pip install pytest pytest-asyncio
# agent/tools.py
from typing import Optional
import httpx
async def search_web(query: str, max_results: int = 5) -> list[dict]:
"""Web検索ツール"""
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.search.example.com/search",
params={"q": query, "n": max_results},
timeout=10,
)
return resp.json()["results"]
# tests/unit/test_tools.py
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_search_web_returns_results():
"""search_webが正しく結果を返すかテスト"""
mock_response = [
{"title": "Result 1", "url": "https://example.com/1", "snippet": "..."},
{"title": "Result 2", "url": "https://example.com/2", "snippet": "..."},
]
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = AsyncMock(
json=lambda: {"results": mock_response},
status_code=200,
)
results = await search_web("AI agent testing")
assert len(results) == 2
assert results[0]["title"] == "Result 1"
@pytest.mark.asyncio
async def test_search_web_with_network_error():
"""ネットワークエラー時の挙動をテスト"""
with patch("httpx.AsyncClient.get", side_effect=httpx.TimeoutException("timeout")):
with pytest.raises(httpx.TimeoutException):
await search_web("test query")
即効テクニック2:LLMをモックしてツールルーティングをテスト
エージェントが「どのツールを選ぶか」というルーティングロジックをLLMなしでテストする方法です。
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
# 動作環境: Python 3.11+, pytest>=8.0, openai>=1.30.0
# pip install pytest openai
# tests/unit/test_agent_routing.py
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
# テスト対象のエージェント
from agent.core import AgentCore
@pytest.fixture
def mock_openai_client():
"""LLMレスポンスをモックするフィクスチャ"""
client = MagicMock()
return client
@pytest.mark.asyncio
async def test_agent_calls_search_tool_for_factual_query(mock_openai_client):
"""ファクト確認のクエリでsearch_webツールが呼ばれることをテスト"""
# LLMが「search_webを使え」と返すようにモック
mock_openai_client.chat.completions.create.return_value = MagicMock(
choices=[MagicMock(
message=MagicMock(
content=None,
tool_calls=[MagicMock(
function=MagicMock(
name="search_web",
arguments='{"query": "latest AI agent news", "max_results": 5}'
)
)]
)
)]
)
agent = AgentCore(llm_client=mock_openai_client)
mock_search = AsyncMock(return_value=[{"title": "News", "url": "http://..."}])
with patch.object(agent, "search_web", mock_search):
result = await agent.run("最新のAIエージェントニュースを調べて")
# ツールが正しい引数で呼ばれたか検証
mock_search.assert_called_once()
call_kwargs = mock_search.call_args.kwargs
assert "latest AI agent news" in call_kwargs.get("query", "")
即効テクニック3:Pydantic AIのTestModel(テスト専用モック)
Pydantic AIはテスト専用のTestModelを提供しています。LLMを使わずにエージェント全体を動かせます。
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
# 動作環境: Python 3.11+, pydantic-ai>=0.0.40
# pip install pydantic-ai
# tests/unit/test_pydantic_agent.py
import pytest
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
# エージェント定義
agent = Agent(
"openai:gpt-4o",
system_prompt="あなたは役立つアシスタントです。",
)
def test_agent_with_test_model():
"""TestModelを使ってLLMなしでエージェントをテスト"""
with agent.override(model=TestModel()):
result = agent.run_sync("こんにちは")
# TestModelは固定の応答を返す(LLMコストゼロ)
assert result.data is not None
assert isinstance(result.data, str)
統合テスト:エージェント + ツールの協調動作
統合テストでは、エージェントがツールを正しく使って状態を更新するフローを検証します。LLMは軽量モデルを使うか、モックで代替します。
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
# 動作環境: Python 3.11+, pytest>=8.0, pytest-asyncio>=0.23
# tests/integration/test_agent_workflow.py
import pytest
from unittest.mock import AsyncMock, patch
from agent.core import AgentCore
from agent.state import AgentState
@pytest.fixture
def agent_with_mocked_tools():
"""ツールをモックしたエージェントを返すフィクスチャ"""
agent = AgentCore()
agent.search_web = AsyncMock(return_value=[
{"title": "Test Result", "url": "https://test.com", "snippet": "test content"}
])
agent.save_to_memory = AsyncMock(return_value={"id": "mem-001", "saved": True})
return agent
@pytest.mark.asyncio
async def test_search_and_save_workflow(agent_with_mocked_tools):
"""検索→保存のワークフローが正しく動作するか統合テスト"""
agent = agent_with_mocked_tools
initial_state = AgentState(messages=[], memory=[])
# ワークフロー実行
final_state = await agent.run_workflow(
task="AIエージェントのテスト方法を調べて保存して",
state=initial_state,
)
# ツールの呼び出し順序と引数を検証
agent.search_web.assert_called_once()
agent.save_to_memory.assert_called_once()
# 状態が正しく更新されているか検証
assert len(final_state.memory) > 0
assert final_state.is_complete is True
@pytest.mark.asyncio
async def test_error_recovery_workflow(agent_with_mocked_tools):
"""ツールエラー時のリカバリーフローをテスト"""
agent = agent_with_mocked_tools
agent.search_web.side_effect = Exception("Network error") # エラーを注入
state = AgentState(messages=[], memory=[])
final_state = await agent.run_workflow(
task="調べて保存して",
state=state,
)
# エラー時はfallbackが動いてエージェントが落ちないことを検証
assert final_state.has_error is True
assert final_state.error_message is not None
assert final_state.is_complete is True # エラーでも完了状態になる
E2Eテスト:完全な会話シナリオ
E2Eテストは実際のLLMを使い、マルチターン会話を通じてエージェントが期待する振る舞いをするか検証します。LLM-as-Judgeパターンが有効です。
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
# 動作環境: Python 3.11+, openai>=1.30.0
# pip install pytest openai
# tests/e2e/test_conversation_flow.py
import pytest
import openai
from agent.core import AgentCore
@pytest.fixture
def real_agent():
"""実際のLLMを使うエージェント(E2Eテスト用)"""
return AgentCore() # 環境変数からAPIキーを読む
@pytest.mark.e2e # マーカーでE2Eテストを識別
@pytest.mark.asyncio
async def test_multi_turn_task_completion(real_agent):
"""3ターンの会話でタスクが完了するかE2Eテスト"""
agent = real_agent
state = None
# ターン1
state = await agent.run("今日の天気を調べて", state=state)
assert state.last_response is not None
# ターン2:フォローアップ
state = await agent.run("東京だよ", state=state)
assert "東京" in str(state.last_response) or state.tool_calls_count > 0
# ターン3:タスク完了確認
state = await agent.run("ありがとう", state=state)
assert state.is_complete
async def evaluate_response_quality(response: str, criteria: str) -> bool:
"""LLM-as-Judgeで応答品質を評価"""
client = openai.AsyncOpenAI()
judge_prompt = f"""
以下の応答が基準を満たしているか評価してください。
応答: {response}
基準: {criteria}
満たしている場合は "PASS"、満たしていない場合は "FAIL" とだけ回答してください。
"""
result = await client.chat.completions.create(
model="gpt-4o-mini", # 評価にはコストの低いモデルを使用
messages=[{"role": "user", "content": judge_prompt}],
)
return "PASS" in result.choices[0].message.content
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_response_quality_with_llm_judge(real_agent):
"""LLM-as-Judgeで応答品質を検証"""
state = await real_agent.run("Pythonのリスト内包表記を説明して")
is_quality_ok = await evaluate_response_quality(
response=state.last_response,
criteria="Pythonのリスト内包表記について正確かつ簡潔に説明し、コード例が含まれている"
)
assert is_quality_ok, f"品質基準を満たしていない: {state.last_response}"
テスト実行の設定(pytest.ini / pyproject.toml)
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = [
"e2e: エンドツーエンドテスト(実際のLLMを使用)",
"integration: 統合テスト",
"unit: ユニットテスト(LLMモック)",
]
# E2Eを除外して高速テストだけ実行
addopts = "-m 'not e2e'"
[tool.pytest.ini_options.env]
OPENAI_API_KEY = "test-key" # ユニットテスト用のダミーキー
# 使い分けコマンド
pytest -m unit # ユニットテストのみ(CI/CD用、高速)
pytest -m integration # 統合テストのみ
pytest -m e2e # E2Eテストのみ(APIコスト発生)
pytest # ユニット + 統合(e2eは除外)
【要注意】よくある失敗パターンと回避策
失敗1:LLMの出力内容をassertで直接検証する
❌ assert result.content == "こんにちは!お手伝いします。"
⭕ assert result.content is not None and len(result.content) > 0、またはLLM-as-Judgeパターン
なぜ重要か:LLMの出力は非決定的なので、完全一致チェックは必ず失敗します。内容の検証はLLM-as-Judgeに任せましょう。
失敗2:全テストでリアルLLMを使う
❌ すべてのテストでopenai.chat.completions.createを実際に呼び出す
⭕ ユニットテストと統合テストはモックを使い、E2Eは本番前のみ実行
なぜ重要か:リアルLLMを全テストで使うとCI/CDのコストが月数万円になります。
失敗3:テストをシングルターンのみで設計する
❌ 1回のagent.run()だけをテストして「動いた」とする
⭕ マルチターン会話シナリオ、エラーリカバリー、エッジケースも網羅
なぜ重要か:AIエージェントの多くのバグは会話の2ターン目以降、特に状態管理に起因します。
失敗4:モックとリアルの動作が乖離する
❌ モックが返す構造と実際のAPIレスポンス構造が異なる
⭕ 実際のAPIレスポンスをrecord & replay(VCR)で録画してモックに使用
なぜ重要か:モックが通ってもリアルで落ちるというパターンの最大の原因です。
参考・出典
- ai-agent-testing — pytest examples for testing AI agents — GitHub(参照日: 2026-04-09)
- Testing — Pydantic AI Docs — Pydantic AI公式ドキュメント(参照日: 2026-04-09)
- How to Test AI Agent Tool Calls with Pytest — DEV Community(参照日: 2026-04-09)
- Agentic Testing: The Complete Guide to AI-Powered Software Testing in 2026 — VtestCorp(参照日: 2026-04-09)
まとめ:今日から始める3つのアクション
- 今日やること:既存エージェントのツール関数を1つ選び、LLMなしのユニットテストを
pytestで書く - 今週中:
pytest.iniでunit/integration/e2eのマーカーを設定し、CI/CDでunit+integrationだけ自動実行する - 今月中:LLM-as-Judgeを使ったE2Eテストを3シナリオ作り、リリース前の品質ゲートに組み込む
あわせて読みたい:
- AIエージェント構築完全ガイド — テスト対象のエージェントをゼロから設計する
- AIエージェントのコスト最適化5つの戦略 — テストコストも含めたAPI使用量の最適化
この記事はAIgent Lab編集部がお届けしました。