PydanticAI ビジュアルガイド

Lesson CH02-L02

TestModelとの併用

DIに差し替え可能なTestModelを噛ませてユニットテストする。

読了目安
12 min
Colab目安
18 min
合計
30 min
前提
DI入門

関連: 公式ドキュメント

一行サマリ

TestModelagent.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 に閉じた高速ユニットテストが書けます。

図を読み込み中…
図1. 本番と TestModel の構成差

概念: なぜ TestModel が必要か

LLM を本物のまま叩いてテストすると、

  • 遅い (1 件あたり 1〜10 秒、CI で大量に走らせるとキツイ)
  • コストがかかる (PR ごとに API 課金が乗る)
  • 不安定 (レスポンスが揺らぐので assert がフレーキー)
  • オフラインで動かない (鍵が CI に必要、ネットワーク前提)

これを全部解消するのが TestModelAgent 自体のコードは変えず、テスト時だけ LLM を Mock に差し替えます。LLM の "返答内容" は pytest 側で固定するので、フィールドの存在・instructions の組み立て・validator の発火・tool 呼び出しの順序、といった ロジック検証 に集中できます。

⚠️ TestModel は LLM の知能を検証しません。「instructions の文面が良いか」「LLM が望む応答を返すか」のような質的検証は、本物のモデルで E2E テストとして別建てで実施します (Ch9 Evals で扱う)。本レッスンの TestModelコードのロジックの単体テスト が目的です。

図を読み込み中…
図2. TestModel が肩代わりするもの / しないもの

コード: 4 つのパターン

パターン 1: TestModel の最小例 — agent.override

overridecontext 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 に差し替え ます。overridemodeldeps (と 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 呼び出しを 完全に制御 できる
図を読み込み中…
図3. TestModel と FunctionModel の使い分け

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)。

Open in Colab

notebooks/ch02/02-test-model.ipynb