
AIMockでAIエージェントのテストを決定論的にする — LLM・ベクトルDB・MCP・A2Aを1つのポートでモックする
AIMockはLLM・ベクトルDB・MCP・A2Aを1つのポートでモックします。エージェントのテストスイートが3秒以内に、毎回同じ出力で完了します。
目次
はじめに
LLMを使うアプリケーションのテストには厄介な問題があります。同じプロンプトを送っても、毎回微妙に違うレスポンスが返ってくるのです。月曜に通ったテストが火曜に落ちる。原因はコードのバグではなく、LLMの非決定性です。
さらに2026年のエージェントアプリは、LLMだけでなくベクトルDB(RAG)、MCPツール、A2Aプロトコルによるエージェント間通信など、1回のリクエストで6〜7個のサービスに触れることも珍しくありません。それぞれのサービスをどうモックするかが課題になります。
CopilotKitが2026年4月にリリースしたオープンソースのモックサーバー「AIMock」は、これらすべてを1つのポートでモックします。この記事で構築するテストスイートは、モック化されたLLM・ベクトルDB・A2Aに対して18個のテストを2.35秒で実行し、毎回まったく同じ出力を返します。
この記事では、デモエージェントとそのテスト、実APIレスポンスの録画・再生、LLMockのカオス注入APIを扱います。後半では、GoogleのCodelabのバーガーエージェントをa2a-sdk v0.2からv1.0に移行し、Cloud Runにデプロイして、同じTypeScriptクライアントから接続するまでを追います。
AIMockとは何か
AIMockは、AIアプリが通信するすべてのサービスをモックするためのnpmパッケージです。
- 11以上のLLMプロバイダーに対応(OpenAI、Claude、Gemini、Bedrock、Azure、Vertex AI、Ollama、Cohereなど)
- MCP JSON-RPC 2.0のフルサポート
- A2Aエージェントカードの探索とSSEストリーミング
- ベクトルDB(Pinecone、Qdrant、ChromaDB)のモック
- カオステスト(500エラー、不正JSON、ストリーム途中切断の注入。手動サーバーと
setChaos()API両方) - ゼロ依存、Node.jsの組み込みモジュールのみ
「1つの設定ファイル、1つのポートで、AIスタック全体をモックする」というコンセプトです。
類似ツールとの位置づけ
AIMockの競合は何かと調べてみましたが、実はこのニッチには直接の競合がほぼ存在しません。
| ツール | フォーカス | AIMockとの重複 |
|---|---|---|
| Keploy | 実トラフィック録画→テスト自動生成。汎用API向け | AI固有プロトコル(MCP、A2A、ストリーミングSSE)非対応 |
| VCR.py / Polly.js | 古典的なHTTPリクエスト録画・再生 | LLMモックに使えるが、ツールコール等のAI固有機能なし |
| Portkey / Helicone | LLMゲートウェイ。キャッシュ・リプレイ可能 | プロダクション向け。フォールト注入やMCPモックなし |
エージェントアプリをMCP・A2A含めてテストしたい場合、現時点でAIMockが唯一の選択肢と言えます。単純にOpenAI APIのモックだけが必要なら、VCR系のHTTPリプレイで十分です。
フィクスチャの録画・再生はなぜ重要か
AIMockの中核機能の一つが「Record & Replay」です。実APIのレスポンスを録画し、テスト時にそのまま再生する仕組みです。
最初にこの機能を見たとき、「ワークフローの途中ステップをリトライする機能(n8nのような)とどう違うのか?」と疑問に思いました。調べてみると、解決する問題が根本的に異なります。
Record & Replayが解決する問題:
# 月曜: テスト通るassert response.includes("SQL injection risk") ✅
# 火曜: 同じコード、同じプロンプト、OpenAIが言い換えたassert response.includes("SQL injection risk") ❌# "potential SQL vulnerability" が返ってきたフィクスチャを使えば、LLMの呼び出しが純粋関数のように振る舞います。同じ入力に対して、毎回同じ出力です。
| 観点 | フィクスチャなし | フィクスチャあり |
|---|---|---|
| コスト | テスト実行ごとに実APIコール | ゼロ |
| 速度 | LLMコールあたり2〜30秒 | 1ms未満 |
| 決定性 | 毎回異なる出力 | 完全に同一 |
| CI安定性 | ネットワーク・レートリミット・API変更で不安定 | 安定 |
| エッジケース | 再現不可能 | 永続的にキャプチャ |
CI/CDでモックを使うのは「逃げ」ではないか?
正直なところ、モックされたCIで本番の問題を検知できるのかという疑問は正当です。答えは「テストしているものが違う」です。
| テスト対象 | 実APIが必要? |
|---|---|
| JSONパーサーがこのレスポンス形状を処理できるか? | No。フィクスチャで十分 |
| リトライロジックが500エラーで動作するか? | No。カオス注入の方が効果的 |
| プロンプトが良い出力を生成するか? | Yes。モックでは不可能 |
| UIがレスポンスを正しくレンダリングするか? | No。フィクスチャで十分 |
実務では2層のCIパイプラインが最適なパターンです。
- 全PR: モック化された高速テスト(コードロジックの検証)
- ナイトリー/週次: 実APIを使った統合テスト(ドリフト検知、プロンプト検証)
AIMockのドリフト検知機能がこれを補完します。CIで毎日、SDKの型定義・実APIレスポンス・AIMockの出力を三方向比較し、プロバイダーのAPI変更をユーザーより先にキャッチします。
デモの構築: 購入代行エージェント
AIMockの主要機能を実際に使い、テストを書いてみました。題材は「購入代行エージェント」です。
アーキテクチャ
1 リクエストでエージェントは 4 回外に出ます。その呼び出し先はすべて localhost 上のモックです。
テストでは以下を使い分けます。
- LLMock: OpenAI APIのモック(
@copilotkit/aimockのLLMockクラス) - VectorMock: Pinecone互換のベクトルDBモック(
VectorMockクラス) - A2Aモック:
http.createServerで自作したA2Aセラーエージェント
プロジェクト構成
aimock-a2a-demo/├── src/│ ├── buyer-agent/│ │ ├── index.ts # メインのBuyerAgentオーケストレーター│ │ ├── a2a-client.ts # A2Aプロトコルクライアント│ │ ├── knowledge-base.ts # ベクトルDB / RAGレイヤー│ │ └── reasoner.ts # LLM推論│ └── types/│ ├── a2a.ts # A2Aプロトコル型定義│ └── product.ts # 商品関連の型├── tests/│ ├── setup.ts # AIMockのセットアップ│ ├── buyer-agent.test.ts # メインテストスイート│ └── chaos.test.ts # カオステスト├── fixtures/llm/ # 録画されたLLMレスポンス├── aimock.json # AIMock設定ファイル└── package.jsonStep 1: LLMockのセットアップ
LLMockは systemMessage や userMessage のパターンマッチでフィクスチャを登録します。
import { LLMock, VectorMock } from "@copilotkit/aimock";
const llmock = new LLMock({ port: 0 }); // ランダムポート
// システムプロンプトの内容でフィクスチャを振り分けllmock.on({ systemMessage: "evaluating product offers" }, { content: JSON.stringify({ topPickIndex: 0, reasoning: "The MacBook Air M4 offers the best combination...", confidence: 0.92, }),});
llmock.on({ systemMessage: "purchasing concierge" }, { content: JSON.stringify({ refinedQuery: "ultrabook laptop under 1.3kg...", priorities: ["weight under 1.3kg", "battery life 15+ hours"], priceRange: { min: 900, max: 1300 }, }),});
await llmock.start();process.env.OPENAI_BASE_URL = `${llmock.url}/v1`;process.env.OPENAI_API_KEY = "mock-key";実装時の学び: onMessage() はユーザーメッセージのテキストのみにマッチします。同じアプリ内でLLMを複数回呼び出す場合(検索戦略の立案とオファー評価で2回など)、ユーザーメッセージの内容が類似していると意図しないフィクスチャにマッチすることがあります。on({ systemMessage: "..." }) を使ってシステムプロンプトの内容で振り分けるのが確実です。
また、フィクスチャの登録順序が重要です。LLMockは最初にマッチしたフィクスチャを返すため、より具体的なパターンを先に登録し、キャッチオール(onMessage(/.*/))を最後に登録します。
Step 2: VectorMockのセットアップ
VectorMockはPinecone互換のAPIを提供します。
const vectorMock = new VectorMock({ port: 0 });
vectorMock.addCollection("product-knowledge", { dimension: 1536 });
// 静的な結果を登録 — 毎回同じ結果が返るvectorMock.onQuery("product-knowledge", [ { id: "review-macbook-air", score: 0.94, metadata: { brand: "Apple", model: "MacBook Air M4", text: "MacBook Air M4: 1.24kg, 18hr battery life...", }, }, // ... 他のエントリ]);
await vectorMock.start();実装時の学び: QueryResult 型には text フィールドがありません。テキストデータは metadata.text に格納し、アプリ側で m.metadata?.text として取得する必要があります。
また、VectorMockはLLMockの mount() で同一ポートにマウントすることも理論上は可能ですが、パスのプレフィックスが剥がされないため、別ポートで起動する方が確実に動作します。
Step 3: A2Aプロトコルのモック
A2AはGoogleとLinux Foundationが策定した、エージェント間通信のオープンプロトコルです(v1.2、150以上の組織が参加)。
A2Aが真に価値を発揮するのは組織をまたいだエージェント通信です。自社のエージェントが他社のエージェント(例: 決済処理エージェント、物流エージェント)と通信するケースです。同じアプリ内のサブエージェントにA2Aを使うのはオーバーキルで、関数呼び出しで十分です。
今回のデモでは、買い手エージェントと売り手エージェントの間にA2Aを使います。これは異なる組織のエージェントが通信する想定なので、A2Aの正当なユースケースです。
A2Aモックは http.createServer で自作しました。A2A v1.0プロトコルに準拠しています。
const server = createServer((req, res) => { // Agent Card探索: GET /.well-known/agent-card.json (v1.0) if (req.url === "/.well-known/agent-card.json" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ name: "Mock Electronics Seller", skills: [{ id: "product-search", name: "Product Search", ... }], ... })); return; }
// JSON-RPC: POST / — SendMessage (v1.0) if (req.method === "POST") { const rpc = JSON.parse(body); if (rpc.method === "SendMessage") { res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result: { message: { role: "ROLE_AGENT", parts: [{ data: { offers: mockOffers } }], messageId: `reply-${rpc.id}`, contextId: rpc.params?.message?.contextId, }, }, })); } }});Step 4: テストの実行
18個のテストケースを用意しました。すべて3秒以内で完了します。
$ pnpm test
✓ tests/buyer-agent.test.ts (8 tests) 46ms ✓ A2A: Seller Agent Discovery > discovers seller agent card ✓ A2A: Seller Agent Discovery > checks skill availability ✓ A2A: Seller Agent Discovery > sends message and receives offers ✓ Vector DB: Knowledge Base > returns deterministic results ✓ Vector DB: Knowledge Base > same query twice returns identical results ✓ Vector DB: Knowledge Base > queryWithFallback returns empty on unreachable server ✓ Integration: Full Purchasing Flow > end-to-end: query → RAG → LLM → A2A → LLM ✓ Integration: Full Purchasing Flow > works without RAG knowledge
✓ tests/chaos.test.ts (10 tests) 2026ms ✓ Chaos: Vector DB failures > survives connection refused ✓ Chaos: Vector DB failures > respects timeout ✓ Chaos: Vector DB failures > throws on server error ✓ Chaos: LLM failures > malformed JSON ✓ Chaos: LLM failures > empty response ✓ Chaos: LLMock setChaos() > malformedRate — every response is garbled ✓ Chaos: LLMock setChaos() > dropRate — every request gets 500 ✓ Chaos: LLMock setChaos() > clearChaos() restores normal operation ✓ Chaos: LLMock nextRequestError() > single request fails, next succeeds ✓ Resilience > agent degrades gracefully
Test Files 2 passed (2) Tests 18 passed (18) Duration 2.35s特に注目すべきテストケースを紹介します。
決定性の証明:
test("same query twice returns identical results (determinism proof)", async () => { const results1 = await kb.query("laptop"); const results2 = await kb.query("laptop");
// 実際のPineconeでは保証されない expect(results1).toEqual(results2);});グレースフルデグラデーション:
test("works without RAG knowledge (graceful degradation)", async () => { const agent = new BuyerAgent({ sellerAgentUrl: sellerUrl, knowledgeBase: { apiKey: "mock-key", baseUrl: "http://localhost:1", // 到達不能 → フォールバック indexName: "product-knowledge", }, ragTimeoutMs: 500, });
const result = await agent.findProduct({ query: "I need a lightweight laptop for travel", });
// RAGが落ちてもエージェントはLLM + A2Aで動作する expect(result.topPick).toBeDefined(); expect(result.knowledgeUsed).toHaveLength(0);});このテストはモック基盤なしでは書くのが難しいものです。実際にPineconeインスタンスを停止してテストするわけにはいきません。
A2Aエコシステムの現状
A2Aプロトコルの調査で、公開されていて自由にインタラクトできるエージェントを探してみました。
見つかったもの
- Hello World Agent:
https://hello-world-gxfr.onrender.com/.well-known/agent.json。最初の公開A2Aエージェント。エコー応答のみですが、クライアントの動作確認には使えます - A2A Registry Playground: a2a-registry.org/playground。登録されたエージェントと対話できるプレイグラウンド
- Google Codelab: Purchasing Concierge。Cloud Runに2つのエージェントをデプロイし、A2Aで通信させるハンズオン。無料枠で実行可能
率直な評価
| 存在するもの | まだないもの |
|---|---|
| Hello World / エコーエージェント | 実企業が公開する本番用A2Aエンドポイント |
| Google Codelabのデモ | Stripe、Twilioなどが公開するA2A API |
| コミュニティのトイエージェント | 実用的な機能を持つエージェントマーケットプレイス |
A2Aは150以上の組織が参加し、GitHubスターも22,000以上ですが、公開されていて自由にインタラクトできる実用的なエージェントはほぼ存在しません。エンタープライズでのA2A導入(Azure AI Foundry、Bedrock AgentCore)はほとんど認証の背後にあります。
プロトコルは成熟しています(v1.2、Linux Foundation管轄)。エージェントが追いついていない状況です。
Google Codelabの実A2Aエージェントをローカルで動かす
前半ではモックされたA2Aセラーエージェントを http.createServer で自作しました。次のステップとして、Google CodelabのPurchasing Conciergeに含まれる実A2Aエージェントをローカルで動かしてみました。
リポジトリのクローンと調整
git clone https://github.com/alphinside/purchasing-concierge-intro-a2a-codelab-starter.gitcd purchasing-concierge-intro-a2a-codelab-starter/remote_seller_agents/burger_agentこのburger agentはCrewAI + LiteLLM + Vertex AI Geminiで構築されたA2Aセラーエージェントです。ユーザーからのバーガー注文を受け付けて処理します。
元のリポジトリから以下の調整が必要でした。
1. Python 3.13への更新
requires-python = ">=3.13"FROM python:3.13-slim2. uv.lock の再生成
uv.lock は各パッケージのダウンロードURLを完全な形でハードコードするため、生成した環境が向いていたインデックスがそのまま残ります。手元の uv.lock はセキュリティプロキシミラー配下で生成されたもので、全URLがそのミラー経由になっていました。このミラーに到達できない環境ではダウンロードがタイムアウトします。
rm uv.lockUV_DEFAULT_INDEX=https://pypi.org/simple/ uv lock --python 3.13これは uv sync --frozen がロックファイルにハードコードされたURLをそのまま使う仕様によるものです。インデックス設定を変更しても、ロックファイル内のURLは変わりません。再生成が必要です。
プロジェクト内でもインデックスをオーバーライドしておくと安心です。
[[tool.uv.index]]url = "https://pypi.org/simple/"default = true3. litellm の明示的な依存追加
agent.py が import litellm しているのに、pyproject.toml の依存には含まれていませんでした。以前はCrewAIの推移的依存として入っていたようですが、新しいバージョンでは外れています。
UV_DEFAULT_INDEX=https://pypi.org/simple/ uv add litellma2a-sdk v0.2 → v1.0 マイグレーション
元のコードは a2a-sdk v0.2系向けに書かれていますが、>=0.2.16 の指定で v1.1.2 がインストールされ、破壊的変更によりインポートエラーが発生します。v1.0に合わせてコードを修正しました。
主な変更点:
| 項目 | v0.2 | v1.0 |
|---|---|---|
| サーバー構築 | A2AStarletteApplication クラス |
create_jsonrpc_routes() / create_agent_card_routes() ルートファクトリ |
| AgentCard | url フィールド |
supported_interfaces リスト(AgentInterface) |
| RequestHandler | agent_card 不要 |
agent_card が必須パラメータ |
| Part構築 | Part(root=TextPart(text="...")) |
Part(text="...")(Protobufベース) |
| JSON-RPCメソッド | tasks/send |
SendMessage |
| エージェントカードパス | /.well-known/agent.json |
/.well-known/agent-card.json |
| プロトコルバージョン | なし(暗黙的に0.3) | A2A-Version: 1.0 ヘッダーが必要 |
| エラー型 | ServerError |
UnsupportedOperationError を直接使用 |
__main__.py の変更(サーバー構築):
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routesfrom a2a.types import AgentCard, AgentInterface, AgentCapabilities, AgentSkillfrom starlette.applications import Starlette
agent_card = AgentCard( name="burger_seller_agent", description="Helps with creating burger orders", version="1.0.0", capabilities=AgentCapabilities(streaming=True), supported_interfaces=[ AgentInterface(protocol_binding="JSONRPC", url=agent_host_url) ], skills=[skill],)
request_handler = DefaultRequestHandler( agent_executor=BurgerSellerAgentExecutor(), task_store=InMemoryTaskStore(), agent_card=agent_card, # v1.0で必須に)
routes = []routes.extend(create_agent_card_routes(agent_card))routes.extend(create_jsonrpc_routes(request_handler, "/"))
app = Starlette(routes=routes)uvicorn.run(app, host=host, port=port)agent_executor.py の変更(Protobufベースの型):
from a2a.types import Message, Part, Role, Taskfrom a2a.utils.errors import UnsupportedOperationError
class BurgerSellerAgentExecutor(AgentExecutor): async def execute(self, context, event_queue): query = context.message.parts[0].text if context.message else "" result = await self.agent.invoke(query, context.context_id)
# v1.0: Part(text="...") で直接構築(TextPartラッパー不要) await event_queue.enqueue_event( Message( role=Role.ROLE_AGENT, parts=[Part(text=str(result))], message_id=context.task_id or "msg", context_id=context.context_id, ) )agent.py の変更(async化):
CrewAIの kickoff() は同期メソッドですが、a2a-sdk v1.0ではリクエストハンドラが非同期コンテキストから呼ばれます。同期メソッドを非同期コンテキスト内で呼ぶと Agent execution was invoked synchronously from within a running event loop エラーが発生します。
# Before: def invoke(self, query, sessionId) -> str:async def invoke(self, query, sessionId) -> str: # ... # Before: response = crew.kickoff(inputs) response = await crew.kickoff_async(inputs) return response動作確認
# Vertex AI APIの有効化が必要gcloud services enable aiplatform.googleapis.com
# ローカルで起動uv run . --host 127.0.0.1 --port 8080エージェントカードの確認:
$ curl http://127.0.0.1:8080/.well-known/agent-card.json | jq .name"burger_seller_agent"A2A v1.0プロトコルでのメッセージ送信:
$ curl -X POST http://127.0.0.1:8080/ \ -H "Content-Type: application/json" \ -H "A2A-Version: 1.0" \ -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage", "params":{"message":{"role":"ROLE_USER", "parts":[{"text":"What burgers do you have?"}], "messageId":"m1","contextId":"ctx1"}}}'レスポンス:
{ "result": { "message": { "role": "ROLE_AGENT", "parts": [{ "text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. Do any of these sound good?" }] } }, "id": "1", "jsonrpc": "2.0"}Vertex AI Gemini → CrewAI → A2A v1.0プロトコルの全経路が動作しています。
Cloud Runへのデプロイ
ローカルで動作確認ができたら、Cloud Runにデプロイします。このburger agentはStarlette ASGIサーバー(uvicornで起動)なので、Cloud FunctionsではなくCloud Runが適しています。Cloud FunctionsはリクエストごとにPython関数を呼び出すモデルですが、A2AエージェントはStarletteの複数ルート(/.well-known/agent-card.json、/ のJSON-RPCエンドポイント)を持つ常駐HTTPサーバーです。
gcloud run deploy burger-agent \ --source . \ --port 8080 \ --allow-unauthenticated \ --region us-central1 \ --min-instances 0 \ --max-instances 1 \ --memory 1Gi--source . でDockerfileを使ったビルドがCloud Build上で実行され、Artifact Registryにイメージが保存された後、Cloud Runにデプロイされます。
デプロイ後、エージェントカードの url フィールドにCloud RunのURLを反映させるため、環境変数を設定します。
gcloud run services update burger-agent \ --region us-central1 \ --set-env-vars HOST_OVERRIDE=https://burger-agent-XXXXXXXXXX.us-central1.run.app動作確認:
$ curl https://burger-agent-XXXXXXXXXX.us-central1.run.app/.well-known/agent-card.json | jq .name"burger_seller_agent"
$ curl -X POST https://burger-agent-XXXXXXXXXX.us-central1.run.app/ \ -H "Content-Type: application/json" \ -H "A2A-Version: 1.0" \ -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage", "params":{"message":{"role":"ROLE_USER", "parts":[{"text":"What burgers do you have?"}], "messageId":"m1","contextId":"ctx1"}}}'{ "result": { "message": { "role": "ROLE_AGENT", "parts": [{ "text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. What would you like to order?" }] } }}コストに関する注意: --min-instances 0 を指定しているので、リクエストがない間はインスタンスが0にスケールダウンし、課金されません。ただしコールドスタートに10〜15秒かかります。デモやテスト用途であれば問題ありません。
TypeScriptバイヤーエージェントをA2A v1.0に対応させる
Cloud Runにデプロイした実セラーエージェントに接続するため、TypeScript側のA2Aクライアントもv1.0プロトコルに対応させました。
A2Aクライアントの変更
export class A2AClient { // v1.0: agent-card.json(旧: agent.json) async discover(): Promise<AgentCard> { const url = `${this.agentUrl}/.well-known/agent-card.json`; const res = await fetch(url); this.card = await res.json(); return this.card; }
// v1.0: SendMessage + A2A-Version ヘッダー async sendMessage(text: string, contextId?: string): Promise<A2AMessage> { const rpcRequest = { jsonrpc: "2.0", id: messageId, method: "SendMessage", // 旧: "tasks/send" params: { message: { role: "ROLE_USER", // 旧: "user" parts: [{ text }], // 旧: { type: "text", text } messageId, contextId, }, }, };
const res = await fetch(this.agentUrl, { method: "POST", headers: { "Content-Type": "application/json", "A2A-Version": "1.0", // v1.0で必須 }, body: JSON.stringify(rpcRequest), });
const rpcResponse = await res.json(); return rpcResponse.result.message; // 旧: result(タスクオブジェクト) }}v0.3 → v1.0 変更点まとめ(TypeScript側)
| 項目 | v0.3 | v1.0 |
|---|---|---|
| エージェントカードパス | /.well-known/agent.json |
/.well-known/agent-card.json |
| JSON-RPCメソッド | tasks/send |
SendMessage |
| ロール値 | "user" / "agent" |
"ROLE_USER" / "ROLE_AGENT" |
| パート構造 | { type: "text", text: "..." } |
{ text: "..." } |
| ヘッダー | なし | A2A-Version: 1.0 |
| レスポンス | result = Task(messagesの配列) |
result.message = 単一Message |
実Cloud Runエージェントとの接続テスト
TypeScript A2Aクライアントから実Cloud Runバーガーエージェントへの接続を確認しました。
const client = new A2AClient("https://burger-agent-XXXXXXXXXX.us-central1.run.app");
const card = await client.discover();// → Name: burger_seller_agent, Skills: ["create_burger_order"]
const reply = await client.sendMessage("What burgers do you have?");// → role: ROLE_AGENT// → "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, ..."モックテストと実エージェントが同じA2Aクライアントコード、同じv1.0プロトコルで動作します。テスト時はAIMockのモックサーバー、本番では実Cloud Runエージェントに向き先を変えるだけです。
フィクスチャの録画を実際にやってみる
記事の前半で「Record & Replay」の概念を説明しましたが、実際にやってみました。
llmockの起動
@copilotkit/aimock パッケージには llmock というCLIバイナリが同梱されています。--record フラグで録画モードのプロキシサーバーとして起動します。
# llmockを録画モードで起動(ポート4010でリッスン)pnpm mock:record# → llmock --record --provider-openai https://api.openai.com -f ./fixtures/llmアプリをllmock経由で実行
OPENAI_BASE_URL を llmock に向けるだけで、アプリのOpenAI SDK呼び出しがすべてllmock経由になります。APIキーは .env からアプリが読み込み、llmockが透過的にOpenAIに転送します。
OPENAI_BASE_URL=http://localhost:4010/v1 \SELLER_URL=https://burger-agent-XXXXXXXXXX.us-central1.run.app \pnpm dev録画されたフィクスチャ
fixtures/llm/recorded/ に以下のようなJSONが保存されます。
{ "fixtures": [{ "match": { "userMessage": "User request: \"I need a lightweight laptop...\"", "model": "gpt-4o-mini", "turnIndex": 0, "hasToolResult": false }, "response": { "content": "{\"refinedQuery\": \"lightweight travel laptop under $1200\", ...}" }, "metadata": { "systemHash": "a11524e7" } }]}match のフィールド(userMessage、model、turnIndex)がマッチング条件になります。次回以降、同じ条件のリクエストが来たらOpenAIを呼ばずにこのレスポンスを返します。
Record & Replayの実感
実際にやってみると、このワークフローのシンプルさに驚きます。
pnpm mock:recordでプロキシ起動OPENAI_BASE_URL=http://localhost:4010/v1 pnpm devで普通にアプリを動かすfixtures/llm/recorded/にフィクスチャが自動保存- 次回以降は
pnpm mock:startでフィクスチャを再生するだけ
コードの変更は一切不要。OPENAI_BASE_URL の向き先を変えるだけです。
LLMockのカオス注入機能を試す
先ほどのテスト結果のうち、setChaos() と nextRequestError() の4つはまだ説明していませんでした。これらはLLMockに組み込まれたプログラマティックなカオス注入APIを使っています。最初の5つのカオステストが500エラーやタイムアウトを再現するために手動でHTTPサーバーを立てているのに対し、こちらはテストサーバーを自作せずに済みます。
setChaos():確率的フォールト注入
setChaos() で全リクエストに対する障害確率を設定します。
test("setChaos({ malformedRate: 1 }) — every response is garbled", async () => { const mock = new LLMock({ port: 0 });
// 正常なフィクスチャを登録(/.*/で全メッセージにマッチ) mock.onMessage(/.*/, { content: '{"refinedQuery": "test", "priorities": [], "priceRange": {"min": 0, "max": 100}}', });
// カオス設定: 100%の確率でレスポンスを破壊 mock.setChaos({ malformedRate: 1.0 });
await mock.start(); process.env.OPENAI_BASE_URL = `http://localhost:${mock.port}/v1`;
const reasoner = new Reasoner("gpt-4o-mini");
// フィクスチャはマッチするが、カオスが応答を壊す await expect( reasoner.buildSearchStrategy("laptop", []) ).rejects.toThrow();
await mock.stop();});ChaosConfig には3つの確率パラメータがあります。
| パラメータ | 効果 | フィクスチャ不要 |
|---|---|---|
dropRate |
500エラーを返す | はい |
malformedRate |
{malformed json: <<<chaos>>>} を返す |
いいえ(要マッチ) |
disconnectRate |
TCP接続を途中切断する | いいえ(要マッチ) |
注意点として、onMessage("*") はワイルドカードではなくリテラル文字列 * としてマッチします。全メッセージにマッチさせるには正規表現 /.*/ を使います。
clearChaos():カオスの解除
clearChaos() でカオス設定を解除すると、正常なフィクスチャ応答に戻ります。障害からの復旧をテストするのに便利です。
test("clearChaos() restores normal operation", async () => { const mock = new LLMock({ port: 0 }); mock.onMessage(/.*/, { content: validJson }); mock.setChaos({ malformedRate: 1.0 });
await mock.start(); // ... env setup ...
const reasoner = new Reasoner("gpt-4o-mini");
// カオスON — 失敗する await expect( reasoner.buildSearchStrategy("laptop", []) ).rejects.toThrow();
// カオス解除 — 正常に動作する mock.clearChaos();
const strategy = await reasoner.buildSearchStrategy("laptop", []); expect(strategy.refinedQuery).toBe("laptop"); // ✓ 正常なJSON});nextRequestError():ワンショットエラー注入
nextRequestError() は次の1リクエストだけにエラーを返し、その後は自動的に消費されます。「1回だけ失敗して、リトライで復旧する」シナリオのテストに最適です。
test("nextRequestError(400) — single request fails, next succeeds", async () => { const mock = new LLMock({ port: 0 }); mock.onMessage(/.*/, { content: validJson });
// ワンショット: 次の1リクエストだけ400を返す mock.nextRequestError(400, { message: "Bad Request" });
await mock.start(); // ... env setup ...
const reasoner = new Reasoner("gpt-4o-mini");
// 1回目: 400エラー await expect( reasoner.buildSearchStrategy("laptop", []) ).rejects.toThrow();
// 2回目: ワンショットが消費済み → 正常応答 const strategy = await reasoner.buildSearchStrategy("laptop", []); expect(strategy.refinedQuery).toBe("laptop"); // ✓});注意: OpenAI SDKは5xxエラー(500、503等)を自動リトライします。ワンショットで確実にエラーをテストするには、リトライされない400番台を使います。
テスト実行結果
$ pnpm test
✓ tests/buyer-agent.test.ts (8 tests) 42ms ✓ tests/chaos.test.ts (10 tests) 1918ms ✓ Chaos: Vector DB failures > survives connection refused ✓ Chaos: Vector DB failures > respects timeout ✓ Chaos: Vector DB failures > throws on server error ✓ Chaos: LLM failures > malformed JSON ✓ Chaos: LLM failures > empty response ✓ Chaos: LLMock setChaos() > malformedRate — every response is garbled ✓ Chaos: LLMock setChaos() > dropRate — every request gets 500 ✓ Chaos: LLMock setChaos() > clearChaos() restores normal operation ✓ Chaos: LLMock nextRequestError() > single request fails, next succeeds ✓ Resilience > agent degrades gracefully
Test Files 2 passed (2) Tests 18 passed (18) Duration 2.2s手動でHTTPサーバーを立てるカオステスト(最初の5つ)と、LLMockの組み込みカオスAPI(それに続く4つ。最後の agent degrades gracefully はどちらにも属しません)の両方が動作しています。どちらのアプローチも有効ですが、LLMockのAPIを使う方がコードが少なく、意図が明確です。
まとめ
AIMockを使ったAIエージェントのテストを構築し、さらにGoogle Codelabの実A2Aエージェントをa2a-sdk v1.0にマイグレーションし、ローカルでの動作確認からCloud Runへのデプロイ、そしてTypeScriptバイヤーエージェントからの接続まで一気通貫でやってみました。
AIMockが効果的なケース:
- LLM + ベクトルDB + 複数サービスを組み合わせたエージェントアプリのテスト
- CI/CDでの高速・低コスト・安定したテスト実行
- フォールト注入による耐障害性の検証
AIMockがオーバーキルなケース:
- OpenAI APIだけをモックしたい場合(VCR系のHTTPリプレイで十分)
- プロンプトの品質を評価したい場合(モックでは不可能)
AIMock実装上の注意点:
onMessage()はユーザーメッセージのみにマッチ。on({ systemMessage })でシステムプロンプトでの振り分けが可能onMessage("*")はワイルドカードではなくリテラル*にマッチ。全メッセージにマッチさせるにはonMessage(/.*/)を使う- フィクスチャの登録順序が重要(先にマッチしたものが返る)
- VectorMockの
QueryResultにtextフィールドはない。metadata内に格納する - VectorMockはLLMockとは別ポートで起動するのが安全
setChaos()のmalformedRateとdisconnectRateはフィクスチャのマッチが必要。dropRateのみフィクスチャ不要で動作する- OpenAI SDKは5xxエラーを自動リトライする。
nextRequestError()でエラーをテストする場合は400番台を使う
a2a-sdk v1.0マイグレーションとデプロイの学び:
A2AStarletteApplicationは廃止。ルートファクトリ(create_jsonrpc_routes/create_agent_card_routes)を使って標準のStarletteアプリに組み込む- 型システムがPydanticからProtobufベースに変更。
Part(text="...")のように直接構築する - エージェントカードのパスが
agent.json→agent-card.jsonに変更 - JSON-RPCメソッド名が
tasks/send→SendMessageに変更、A2A-Version: 1.0ヘッダーが必須 uv.lockにPyPIミラーのURLがハードコードされる問題はUV_DEFAULT_INDEXで再生成して解決- A2AエージェントはStarlette ASGIサーバーのため、Cloud Functions(単一関数ハンドラモデル)ではなくCloud Run(Dockerコンテナ)でデプロイする
- Cloud Runでは
HOST_OVERRIDE環境変数でエージェントカードの公開URLを設定する - TypeScript側のA2Aクライアントも同様にv1.0対応が必要(エンドポイントパス、メソッド名、ロール値、ヘッダーの変更)
- モックと実エージェントで同じクライアントコード・同じプロトコルが使えるため、向き先(URL)を変えるだけでテスト↔本番を切り替えられる