導入:バリデーションは「テストすべきコード」である
Pydantic を導入すると、検証ロジックが宣言的になります。Field(gt=0)、@field_validator、@model_validator——これらは短く、読みやすい。だからこそ、「宣言的だからテストは要らない」と錯覚しがちです。
これは危険な思い込みです。バリデーションは、あなたのシステムの最前線の防御です。「18 歳以上」「正の金額」「メールアドレスの正規化」「パスワードの一致」——これらが正しく効いているという保証は、テストでしか得られません。バリデータの条件を < と <= で書き間違えても、コードは何事もなく動き、境界値の 1 件だけが本番ですり抜ける。
本記事は、Pydantic v2 のモデルとバリデーションを本番品質でテストする戦略を、3 つのレイヤーで解説します——①モデルの契約をテストする(pytest)、②テストデータを自動生成する(polyfactory)、③不変条件をプロパティベースで検証する(Hypothesis)。CLAUDE.md の「Verification First(検証パスを先に作る)」原則を、Pydantic に対して具体化したものです。
⚠️ 公式に「テスト専用ページ」はない:Pydantic v2 の公式ドキュメントに、まとまった「Testing」ページは存在しません(執筆時点)。本記事の pytest パターンは、
ValidationError/model_dump/model_constructといった公式 API の確実な仕様を組み合わせて構成しています。polyfactory・Hypothesis はサードパーティで、各公式ドキュメントを一次情報として明記します。
1. 何をテストするか:モデルの「4 つの契約」
Pydantic モデルがテストで保証すべき契約は、4 つに整理できます。
import pytest
from pydantic import BaseModel, Field, ValidationError, field_validator
class SignupForm(BaseModel):
email: str
age: int = Field(ge=18)
@field_validator("email", mode="after")
@classmethod
def normalize(cls, v: str) -> str:
return v.strip().lower()
# 契約①:正常系が通り、型付きオブジェクトになる
def test_valid_input_passes():
form = SignupForm(email=" Alice@Example.com ", age=20)
assert form.age == 20
# 契約②:異常系が ValidationError を投げる(境界値を必ず突く)
def test_underage_is_rejected():
with pytest.raises(ValidationError):
SignupForm(email="a@example.com", age=17) # ge=18 の境界の外側
def test_boundary_value_passes():
assert SignupForm(email="a@example.com", age=18).age == 18 # 境界そのものは通る
# 契約③:バリデータが値を正しく変換・正規化する
def test_email_is_normalized():
form = SignupForm(email=" Alice@Example.com ", age=20)
assert form.email == "alice@example.com" # 前後空白除去+小文字化
# 契約④:直列化がラウンドトリップする(dump して再構築すると一致)
def test_serialization_round_trips():
form = SignupForm(email="a@example.com", age=20)
assert SignupForm(**form.model_dump()) == form
assert SignupForm.model_validate_json(form.model_dump_json()) == form
4 つの契約を再掲します。
| 契約 | テスト内容 | 落とし穴 |
|---|---|---|
| ① 正常系 | 妥当な入力が通り、型が保証される | — |
| ② 異常系 | 不正な入力が ValidationError を投げる | 境界値(17/18)を必ず両方突く |
| ③ 変換 | バリデータが正規化・変換する | 変換「後」の値を assert する |
| ④ ラウンドトリップ | dump→再構築で一致する | model_dump() と model_validate_json() の両方 |
💡 境界値が肝:
ge=18のバグの大半は、17(外)と18(境界)の両方をテストして初めて捕まります。pytest.mark.parametrizeで境界の内外を網羅すると、</<=の書き間違いが構造的に防げます。
2. テストデータを自動生成する:polyfactory
モデルが増えると、テストのたびに有効なインスタンスを手で組むのが苦痛になります。age=20, email="a@example.com", name="alice", ... を毎回書くのは DRY 違反です。polyfactory(pydantic-factories の後継)が、モデル定義からリアルなフェイクデータを生成します。
pip install polyfactory
from polyfactory.factories.pydantic_factory import ModelFactory
from app.models import User
class UserFactory(ModelFactory[User]):
... # __model__ は 2.13.0 以降、ジェネリック引数から自動推論される
def test_with_generated_data():
user = UserFactory.build() # 全フィールドを自動生成
assert isinstance(user, User)
users = UserFactory.batch(size=10) # 10 件まとめて生成
assert len(users) == 10
# テストの関心がある値だけ上書きし、残りは自動生成に任せる
admin = UserFactory.build(role="admin", email="admin@example.com")
assert admin.role == "admin"
決定性(再現性)が欲しいテストでは、シードや Faker のロケールを固定できます。
from faker import Faker
class UserFactory(ModelFactory[User]):
__faker__ = Faker(locale="ja_JP") # 日本語のフェイクデータ
__random_seed__ = 42 # 毎回同じ値を生成(再現可能)
pytest のフィクスチャとしても登録できます。
from polyfactory.pytest_plugin import register_fixture
@register_fixture
class UserFactory(ModelFactory[User]):
...
def test_user(user_factory: type[UserFactory]) -> None:
user = user_factory.build() # フィクスチャ経由でファクトリを受け取る
...
なぜこれが効くのか?
polyfactory は「テストの関心ごとだけを明示し、それ以外は自動生成に委ねる」ことを可能にします。build(role="admin") と書けば「role が admin のユーザー」というテストの意図がコードに明確に表れ、無関係なフィールド(名前・メール…)のノイズが消えます。テストの可読性と保守性が上がり、モデルにフィールドが増えても既存テストが壊れにくくなります(ETC)。
3. プロパティベーステスト:Hypothesis で「あらゆる入力」を試す
例示ベースのテストは「思いついた入力」しかカバーできません。Hypothesis は、型から膨大な入力を自動生成し、「どんな入力でも成り立つべき性質(プロパティ)」を検証します。境界値や奇妙な Unicode など、人間が思いつかないケースを突いてくれます。
⚠️ Pydantic 同梱の Hypothesis プラグインは v2 で廃止された:v1 には
pydanticの Hypothesis プラグインが同梱されていましたが、v2.0 で廃止され、執筆時点(2026年6月)でも復活していません。古い記事の「プラグインを有効化」という手順は通用しません。現在の正しいやり方は次の 2 つです。
H3: 方法A — st.builds / st.from_type(推奨・標準)
Hypothesis 標準の st.builds(Model) でモデルのインスタンスを直接生成できます。Hypothesis 公式によれば、「Pydantic は制約型を自動登録するので、builds() と from_type() は実装に関係なくそのまま動く」。
from hypothesis import given, strategies as st
from app.models import User
@given(st.builds(User)) # 型ヒント・制約から有効な User を無数に生成
def test_dump_round_trips_for_any_user(user: User):
# どんな有効インスタンスでも、dump→再構築すれば一致するはず(不変条件)
assert User(**user.model_dump()) == user
H3: 方法B — hypothesis-jsonschema(入力境界のファジング)
Model.model_json_schema() が出す JSON Schema から、スキーマに適合する生の dictを生成し、model_validate に流して「検証が落ちないこと」を確かめます。境界(入力側)のファジングに向きます。
from hypothesis import given
from hypothesis_jsonschema import from_schema
from app.models import User
@given(from_schema(User.model_json_schema()))
def test_schema_valid_inputs_are_accepted(data: dict):
User.model_validate(data) # スキーマ適合の入力は検証を通過するはず
| 方法 | 生成するもの | 向いている用途 |
|---|---|---|
st.builds / st.from_type | モデルのインスタンス | オブジェクトの振る舞い・不変条件の検証 |
from_schema(...) | スキーマ適合のdict | 入力境界(外部入力)のファジング |
⚠️
hypothesis-jsonschemaはサードパーティで API が安定していない:これは Pydantic・Hypothesis 本体とは別の PyPI パッケージで、1.x 未満(ベータ)であり後方非互換な変更が入りうると明記されています。まずは標準のst.builds/st.from_typeを第一選択にし、制約型が複雑でst.buildsが詰まる場合の補助としてfrom_schemaを使うのが安全です。
4. model_construct:不正なエッジケースを高速に作る
「検証ロジックの外側を試したい」——たとえば「もし不正なデータがすり抜けたら、下流のコードはどう振る舞うか」を試すには、検証を通さずにインスタンスを作る必要があります。model_construct() は検証を完全にスキップするので、これに使えます。
from app.models import User
def test_downstream_handles_impossible_state():
# 検証を通さず、敢えて "ありえない" 値を持つフィクスチャを高速に作る
broken = User.model_construct(age=-1, email="not-an-email") # ValidationError は出ない
# この壊れた状態を下流コードに渡したときの防御的挙動を検証する
...
⚠️ 検証は走らない:
model_constructは検証を一切行わないので、スキーマに違反するオブジェクトを平然と作ります(だからフィクスチャに便利)。さらに公式によれば、model_config.extra='forbid'も強制されません。当然ながら外部入力には絶対に使わないこと——用途は「テストで不正状態を意図的に作る」「検証済みデータの再構築を高速化する」(パフォーマンス最適化ガイド 参照)に限ります。
5. pydantic-settings のテスト:環境を汚さずに上書きする
設定(BaseSettings)のテストは、プロセスの環境変数を汚さないのが鉄則です。最もクリーンなのは、Settings(...) への直接注入です(優先順位が環境変数より高いため確実に上書きでき、並列テストでも安全)。
from app.config import Settings
def test_direct_injection_overrides_env():
# 環境に一切依存せず、値を直接渡す(最も hermetic)
settings = Settings(database_url="sqlite://", max_connections=1)
assert settings.max_connections == 1
def test_loading_from_env(monkeypatch):
# 環境ロード経路そのものを検証したい時だけ monkeypatch を使う
monkeypatch.setenv("APP_MAX_CONNECTIONS", "20") # ← env_prefix 込みの名前で
assert Settings().max_connections == 20
💡 直接注入を既定にする:
monkeypatch.setenvは「環境変数からの読み込み」という経路を検証するときに使い、それ以外のロジック検証では直接注入を使う。直接注入はプロセス環境に触れないため、テストが互いに干渉せず、並列実行でも安定します。設定管理の全体像は pydantic-settings 実践ガイド を参照してください。
6. テスト戦略のまとめ:どの道具をどこで使うか
3 つのレイヤーを役割で整理します。
| レイヤー | 道具 | 目的 |
|---|---|---|
| 契約テスト | pytest + pytest.raises | 正常系・異常系・変換・ラウンドトリップの 4 契約。境界値を網羅 |
| データ生成 | polyfactory | リアルなフィクスチャを自動生成。テストの関心だけを明示 |
| プロパティ検証 | Hypothesis | 無数の入力で不変条件を検証。境界・異常値を機械的に発掘 |
| 不正フィクスチャ | model_construct | 検証外の状態を高速に作り、下流の防御を試す |
| 設定テスト | 直接注入 / monkeypatch | 環境を汚さず設定を上書き |
これらは CI で常時回します。Pydantic のモデルはビジネスルールの宣言的な仕様書でもあるので、テストはその仕様が壊れていないことの継続的な証明になります。型チェック(mypy/Pyright)と合わせれば、「静的な型 + 実行時の検証 + それらのテスト」という三重の防御が完成します。
結論:宣言的だからこそ、テストで保証する
Pydantic のバリデーションは宣言的で読みやすい。しかし「読みやすい」と「正しい」は別物です。本記事の要点を再掲します。
- モデルの 4 つの契約(正常系・異常系・変換・ラウンドトリップ)を pytest で固定し、境界値を必ず突く。
- テストデータは polyfactory で自動生成し、テストの関心だけを
build(...)で明示する。 - Hypothesis でプロパティベーステスト。v2 で同梱プラグインは廃止されたので、
st.builds/st.from_typeかhypothesis-jsonschemaを使う。 model_constructで不正なエッジケースのフィクスチャを高速に作る(外部入力には使わない)。pydantic-settingsは直接注入を既定にし、環境ロード経路の検証時だけ monkeypatch を使う。
「検証パスを先に作る」のは、Anthropic 自身のデータでも 1 ショット成功率を最も押し上げる行動です。Pydantic のモデルにテストを当てることは、防御の最前線が確かに機能していることの、唯一の証明手段です。
一次情報:
- Pydantic(BaseModel / ValidationError / model_construct)
- polyfactory(サードパーティ)
- Hypothesis(サードパーティ)
テスト容易な型安全バックエンドのご相談
筆者は、決済の信頼性レイヤーをはじめ、テストカバレッジと本番品質が事業の信頼に直結するシステムを設計・運用してきました。バリデーションのテスト、プロパティベーステストによる不変条件の検証、CI での品質ゲート構築——「速く作る」と「壊さない」を両立させる仕組みづくりを得意としています。Pydantic / FastAPI を用いた テスト戦略の設計・既存コードのテスト整備・CI 品質ゲートの構築を、生成 AI を活用して高速かつ高品質に支援します。お気軽にご相談ください。