連載: AIに仕事を奪われる不安から始めるハーネス作成入門
第18回: MCPサーバーの単体テスト・結合テスト観点を作る
第1〜17回: ✅ 公開済み | 当該記事: 📖 | 次回: 第19回
はじめに――MCPサーバーのテスト、どう書く?
前回(第17回)では、AIエージェント/ハーネス開発のためのQA観点表を作成しました。今回はその観点を、MCPサーバーの具体的なテストケースに落とし込みます。
「MCPサーバーのテストって何を書けばいいの?」という問いに、テストケース表とpytestコード例で答えます。
MCPサーバーのテストが難しい理由
MCPサーバーのテストには、通常のWeb APIテストとは異なる難しさがあります。
| 観点 | Web API | MCPサーバー |
|---|---|---|
| プロトコル | HTTP/REST | JSON-RPC over stdio/SSE |
| クライアント | ブラウザ・アプリ | AIエージェント |
| 入力の予測性 | スキーマで明確 | LLMが生成するため不定 |
| 状態管理 | ステートレスが多い | セッション状態あり |
これらの違いを踏まえた上で、単体テストと結合テストの観点を整理します。
単体テストの観点
単体テストでは、各ツール(ハンドラー)を個別にテストします。外部依存はモックで置き換えます。
テスト観点
- 正常系: 有効な入力で期待通りの出力が返るか
- 異常系: 不正な入力で適切なエラーが返るか
- 境界値: 空文字列、最大長、特殊文字での動作
- 型検証: 不正な型の引数での動作
- 必須パラメータ: 必須パラメータ欠損時の動作
pytestコード例(単体テスト)
import pytest
from unittest.mock import AsyncMock, patch
class TestSearchTool:
"""検索ツールの単体テスト"""
@pytest.mark.asyncio
async def test_search_normal(self, mock_db):
"""正常系: 有効なクエリで結果が返る"""
result = await search_tool.handle({"query": "テスト"})
assert result["status"] == "ok"
assert len(result["items"]) > 0
@pytest.mark.asyncio
async def test_search_empty_query(self):
"""異常系: 空クエリでエラー"""
result = await search_tool.handle({"query": ""})
assert result["status"] == "error"
assert "query" in result["message"].lower()
@pytest.mark.asyncio
async def test_search_max_length(self):
"""境界値: 最大長クエリ"""
long_query = "a" * 10000
result = await search_tool.handle({"query": long_query})
assert result["status"] in ["ok", "error"] # クラッシュしないこと
@pytest.mark.asyncio
async def test_search_missing_param(self):
"""必須パラメータ欠損"""
result = await search_tool.handle({})
assert result["status"] == "error"
結合テストの観点
結合テストでは、ツール間の連携や外部サービスとの接続をテストします。
テスト観点
- ツール連携: 複数ツールの順次呼び出しが正しく動作するか
- セッション管理: セッション状態が正しく維持されるか
- エラー伝播: 上流ツールのエラーが下流に適切に伝わるか
- タイムアウト: 外部サービスタイムアウト時の動作
- 同時アクセス: 複数リクエストの並行処理
pytestコード例(結合テスト)
import pytest
class TestToolIntegration:
"""ツール間連携の結合テスト"""
@pytest.mark.asyncio
async def test_create_then_search(self, mcp_server):
"""作成→検索の連携が動作する"""
# 作成
create_result = await mcp_server.call_tool(
"create_item", {"name": "テストアイテム"}
)
item_id = create_result["item_id"]
# 検索で見つかることを確認
search_result = await mcp_server.call_tool(
"search_items", {"query": "テスト"}
)
found_ids = [item["id"] for item in search_result["items"]]
assert item_id in found_ids
@pytest.mark.asyncio
async def test_error_propagation(self, mcp_server):
"""エラーが適切に伝播される"""
# 存在しないIDで更新を試みる
result = await mcp_server.call_tool(
"update_item", {"item_id": "nonexistent", "name": "更新"}
)
assert result["status"] == "error"
assert "not_found" in result.get("error_code", "")
@pytest.mark.asyncio
async def test_timeout_handling(self, mcp_server):
"""タイムアウト時にクラッシュしない"""
with patch("external_api.call", side_effect=asyncio.TimeoutError):
result = await mcp_server.call_tool(
"fetch_data", {"source": "slow_api"}
)
assert result["status"] == "error"
assert "timeout" in result["message"].lower()
テストケース表
MCPサーバーのテストケースを一覧表にまとめました。
| # | テスト種別 | 観点 | テスト内容 | 期待結果 | 優先度 |
|---|---|---|---|---|---|
| 1 | 単体 | 正常系 | 有効な入力でツール呼び出し | 期待通りの結果が返る | 高 |
| 2 | 単体 | 異常系 | 不正な入力でツール呼び出し | 適切なエラーが返る | 高 |
| 3 | 単体 | 境界値 | 空文字列・最大長・特殊文字 | クラッシュしない | 高 |
| 4 | 単体 | 型検証 | 不正な型の引数 | 型エラーが返る | 中 |
| 5 | 単体 | 必須パラメータ | 必須パラメータ欠損 | バリデーションエラー | 高 |
| 6 | 単体 | 権限 | 無権限でのツール呼び出し | アクセス拒否 | 高 |
| 7 | 結合 | ツール連携 | 作成→検索の順次実行 | 作成したデータが検索で見つかる | 高 |
| 8 | 結合 | エラー伝播 | 上流エラー後の下流動作 | 適切なエラー伝播 | 高 |
| 9 | 結合 | タイムアウト | 外部APIタイムアウト時 | クラッシュせずエラーを返す | 高 |
| 10 | 結合 | セッション | セッション状態の維持 | セッション内で状態が保持される | 中 |
| 11 | 結合 | 同時アクセス | 並行リクエスト | データ競合が起きない | 中 |
| 12 | 結合 | リトライ | APIエラー後のリトライ | 指定回数リトライ後にエラー | 高 |
テストの構成パターン
テストファイルの構成は以下のようにすると管理しやすくなります。
tests/
├── conftest.py # 共通フィクスチャ
├── unit/
│ ├── test_search.py # 検索ツールの単体テスト
│ ├── test_create.py # 作成ツールの単体テスト
│ └── test_update.py # 更新ツールの単体テスト
└── integration/
├── test_workflow.py # ツール連携テスト
└── test_session.py # セッション管理テスト
フィクスチャの活用
pytestのフィクスチャを使って、テストのセットアップを共通化します。
# conftest.py
import pytest
@pytest.fixture
async def mcp_server():
"""テスト用MCPサーバーインスタンス"""
server = create_test_server()
await server.initialize()
yield server
await server.cleanup()
@pytest.fixture
def mock_db():
"""テスト用モックDB"""
db = InMemoryDatabase()
db.seed_test_data()
yield db
db.clear()
SE経験との接点
テスト設計は、SEの最も得意とする領域の一つです。
- テスト観点の洗い出し → MCPツールごとのテスト観点に応用
- 異常系を先に考える習慣 → AI特有のエラーパターンのテストに活きる
- テストデータの設計 → フィクスチャでのテストデータ管理に応用
- CI/CDパイプライン設計 → 自動テスト実行の組み込みに活きる
まずはここから始める
すべてのテストを一度に書く必要はありません。以下の順番で進めることをおすすめします。
- まず正常系1件: 各ツールの基本動作を確認
- 次に異常系: 不正入力・エラーハンドリングをテスト
- 最後に結合テスト: ツール連携の動作を確認
まとめ
- MCPサーバーのテストは単体テスト(5観点)と結合テスト(5観点)に分けて考える
- 単体テストではモックを活用し、結合テストではツール間連携を検証
- pytestのフィクスチャでテストのセットアップを共通化
- まずは正常系1件から始めるのが現実的
- SEのテスト設計経験がそのまま活かせる
次回予告
第19回「機密情報をLLMに渡さないハーネス設計」では、セキュリティの観点からハーネスが扱う機密情報のリスクを整理します。APIキー、個人情報、社内コードなどのデータ種類ごとにリスクレベルと対策を整理し、マスキング・アクセス制御の設計を行います。
📖 連載一覧: 第1〜17回 ✅ 公開済み | 第18回(本記事) | 第19回〜 続刊予定