AIエージェント入門

AIエージェントのテスト戦略完全ガイド:ユニット・統合・E2Eテスト設計と実装

AIエージェントのテスト戦略完全ガイド:ユニット・統合・E2Eテスト設計と実装

この記事の結論

AIエージェントをどうテストするか。ユニットテスト・統合テスト・E2Eテストの設計方針とpytestによる実装パターンを体系的に解説します。

「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)で録画してモックに使用

なぜ重要か:モックが通ってもリアルで落ちるというパターンの最大の原因です。

参考・出典

まとめ:今日から始める3つのアクション

  1. 今日やること:既存エージェントのツール関数を1つ選び、LLMなしのユニットテストをpytestで書く
  2. 今週中pytest.iniでunit/integration/e2eのマーカーを設定し、CI/CDでunit+integrationだけ自動実行する
  3. 今月中:LLM-as-Judgeを使ったE2Eテストを3シナリオ作り、リリース前の品質ゲートに組み込む

あわせて読みたい:


この記事はAIgent Lab編集部がお届けしました。

Need help moving from reading to rollout?

この記事を読んで導入イメージが固まってきた方へ

Uravationでは、AIエージェントの要件整理、PoC設計、社内導入、研修まで一気通貫で支援しています。

この記事をシェア

X Facebook LINE

※ 本記事の情報は2026年4月時点のものです。サービスの料金・仕様は変更される可能性があります。最新情報は各サービスの公式サイトをご確認ください。

関連記事