Lesson CH02-L02
TestModelとの併用
DIに差し替え可能なTestModelを噛ませてユニットテストする。
- 読了目安
- 12 min
- Colab目安
- 18 min
- 合計
- 30 min
- 前提
- DI入門
関連: 公式ドキュメント
一行サマリ
TestModel を agent.override(model=TestModel()) で差し込めば、LLM を一切呼ばずに Agent コードのロジック (instructions / validators / tools) を pytest で検証 できる。Ch2-L01 で組んだ DI と組み合わせると、外部 API も含めて完全にローカルで再現できる。
ヒーロー: 本番 vs テスト — 入れ替わるのは "モデル + deps" だけ
Agent のロジック (instructions / output_validator / tool 呼び出しの組み立て) は本番もテストも同じです。LLM 接続を TestModel に、外部 deps を Fake に差し替える だけで、ローカル CI に閉じた高速ユニットテストが書けます。
概念: なぜ TestModel が必要か
LLM を本物のまま叩いてテストすると、
- 遅い (1 件あたり 1〜10 秒、CI で大量に走らせるとキツイ)
- コストがかかる (PR ごとに API 課金が乗る)
- 不安定 (レスポンスが揺らぐので assert がフレーキー)
- オフラインで動かない (鍵が CI に必要、ネットワーク前提)
これを全部解消するのが TestModel。Agent 自体のコードは変えず、テスト時だけ LLM を Mock に差し替えます。LLM の "返答内容" は pytest 側で固定するので、フィールドの存在・instructions の組み立て・validator の発火・tool 呼び出しの順序、といった ロジック検証 に集中できます。
⚠️ TestModel は LLM の知能を検証しません。「instructions の文面が良いか」「LLM が望む応答を返すか」のような質的検証は、本物のモデルで E2E テストとして別建てで実施します (Ch9 Evals で扱う)。本レッスンの
TestModelは コードのロジックの単体テスト が目的です。
コード: 4 つのパターン
パターン 1: TestModel の最小例 — agent.override
override は context manager として使います。with ブロックを抜けたら元のモデルに自動で戻ります。
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
agent = Agent(
'google-gla:gemini-3-flash-preview',
instructions='短い日本語で答えてください。',
)
def test_basic_run():
# テスト時だけ TestModel に差し替え
with agent.override(model=TestModel(custom_output_text='テスト用の固定応答')):
result = agent.run_sync('何でも良いので答えて')
assert result.output == 'テスト用の固定応答'ポイント:
TestModel(custom_output_text='...')で 応答を固定 するagent.override(model=TestModel())だけだと、デフォルトの "ツール呼び出しを要約した JSON 文字列" が返る- with ブロック内では本物の Gemini を一切呼ばないため オフラインで実行可能
パターン 2: deps も同時に差し替える
Ch2-L01 で組んだ DI を活かして、本物の依存も Fake に差し替え ます。override は model と deps (と builtin_tools) を同時に渡せます。
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.test import TestModel
@dataclass
class CityDb:
cities: dict[str, str]
agent = Agent(
'google-gla:gemini-3-flash-preview',
deps_type=CityDb,
instructions='ユーザーの国名から首都を答えてください。',
)
@agent.instructions
def known_cities(ctx: RunContext[CityDb]) -> str:
return f'参照可能な国: {", ".join(ctx.deps.cities.keys())}'
def test_instructions_includes_country_list():
fake_deps = CityDb(cities={'X国': 'X市', 'Y国': 'Y市'})
with agent.override(model=TestModel(), deps=fake_deps):
result = agent.run_sync('テスト', deps=fake_deps)
# TestModel が組み立てた "ツール要約" 文字列を見て、
# known_cities が呼ばれた痕跡 (LLM への system message に X国/Y国 が出ている) を確認
# → メッセージ履歴を見る方が確実
msgs = result.all_messages()
sys_text = ''.join(
part.content for m in msgs for part in m.parts if hasattr(part, 'content')
)
assert 'X国' in sys_text and 'Y国' in sys_textポイント:
agent.override(model=..., deps=...)で モデル + deps を同時に上書きresult.all_messages()で LLM に送られた system / user メッセージの履歴 を取り出して assert できる- これにより、Dynamic Instructions が 意図通りに deps の値を埋め込んでいるか を LLM 不要で検証
パターン 3: output_validator が ModelRetry を投げる経路をテスト
Ch1-L03 で書いた validator が「想定通り NG を弾くか」を、TestModel で固定の "NG 出力" を返させて検証します。
from pydantic_ai import Agent, ModelRetry
from pydantic_ai.exceptions import UnexpectedModelBehavior
from pydantic_ai.models.test import TestModel
agent = Agent('google-gla:gemini-3-flash-preview', instructions='短く答えて。')
@agent.output_validator
def no_secrets(output: str) -> str:
if '秘密' in output:
raise ModelRetry('「秘密」を含めずに言い換えて')
return output
def test_validator_rejects_then_passes():
# 1 回目は NG、2 回目は OK を返す TestModel
test_model = TestModel(custom_output_text='秘密の手順') # NG な文字列
with agent.override(model=test_model):
# retries のデフォルトは 1。TestModel は毎回同じ文字列を返すので、
# 2 回目も同じ NG → 上限を超えて UnexpectedModelBehavior が飛ぶことを検証
try:
agent.run_sync('教えて')
assert False, '例外が飛ぶはず'
except UnexpectedModelBehavior as e:
assert '秘密' in str(e) or 'retry' in str(e).lower()ポイント:
- "1 回目で NG、2 回目で OK" を表現したいときは
FunctionModel(パターン 4) を使う - 単に "ずっと NG" を流したいときは
TestModel(custom_output_text='NG文字列')で十分
パターン 4: FunctionModel で呼び出しごとに違う応答を返す
FunctionModel は 「メッセージ履歴とエージェント情報を受け取って ModelResponse を返す関数」を渡せる 強力な仕組みです。リトライや多ターン対話の挙動を細かく検証したいときに使います。
from pydantic_ai.messages import ModelResponse, TextPart
from pydantic_ai.models.function import FunctionModel, AgentInfo
calls = {'n': 0}
def model_logic(messages, info: AgentInfo) -> ModelResponse:
calls['n'] += 1
# 1 回目は NG、2 回目以降は OK を返す
if calls['n'] == 1:
return ModelResponse(parts=[TextPart(content='秘密の手順を説明します')])
return ModelResponse(parts=[TextPart(content='ログイン手順を一般的に説明します')])
def test_validator_passes_after_retry():
calls['n'] = 0
with agent.override(model=FunctionModel(model_logic)):
result = agent.run_sync('教えて')
assert '秘密' not in result.output
assert calls['n'] == 2 # 1 回目で ModelRetry → 2 回目で OKポイント:
FunctionModel(callback)の callback は(messages, info) -> ModelResponseのシグネチャ- 状態 (
calls['n']) を closure で持って、呼ばれた回数で挙動を変える のが定石 - 多ターンの validator / retry / tool 呼び出しを 完全に制御 できる
pytest との組み合わせ
実プロジェクトでは pytest fixture に Fake Deps を集約し、各テスト関数で agent.override するのが楽です。
import pytest
from dataclasses import dataclass
@dataclass
class FakeDeps:
db: dict[str, str]
@pytest.fixture
def fake_deps() -> FakeDeps:
return FakeDeps(db={'Tokyo': 'sunny', 'Paris': 'rainy'})
@pytest.fixture
def fake_agent():
# 全テストで共通の TestModel
return TestModel(custom_output_text='ok')
def test_run_with_fake(fake_deps, fake_agent):
with agent.override(model=fake_agent, deps=fake_deps):
result = agent.run_sync('Tokyo の天気は?', deps=fake_deps)
assert result.output == 'ok'CI では pip install pydantic-ai pytest だけで、Gemini API キーなしで全テストが走る 状態を作れます。
まとめ
agent.override(model=TestModel(), deps=FakeDeps(...))で本物の LLM・本物の依存を切り離せる- 固定応答は
TestModel(custom_output_text='...')、状態を持つ応答はFunctionModel(callback) - pytest fixture に Fake Deps を集約 + with override がプロジェクトの定石
- TestModel は コードのロジック を検証する。LLM 応答の品質 は Ch9 Evals で別建てで
🎉 Foundation グループ完結
これで Ch2「Dependencies」全 2 レッスン、ひいては Foundation グループ全 3 章 (Ch0-Ch2) / 9 レッスンが完結 です。これ以降の Ch3「Function Tools」からは Core グループに入り、Agent に "外と連携する手段" を与えていきます。
Colab で実際に動かす
本レッスンの内容を Google Colab 上で実行できるノートブックを用意しています。下のボタンから自分のColab環境に開けます (要 Google アカウント / GOOGLE_API_KEY)。
notebooks/ch02/02-test-model.ipynb