# Pydantic テスト戦略：polyfactory と Hypothesis で検証ロジックを徹底的にテストする

> Pydantic v2のモデルとバリデーションを本番品質でテストする実践ガイド。pytest.raisesでの契約テスト、polyfactoryによるテストデータ自動生成、Hypothesisによるプロパティベーステスト（v2でプラグイン廃止後の正しいやり方）、model_constructでの高速な不正フィクスチャ、pydantic-settingsのテストまでを、テスト容易性とCIの観点で実コード解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, Pydantic, テスト, 型安全, バリデーション
- URL: https://tomodahinata.com/blog/pydantic-testing-polyfactory-hypothesis-strategy-guide

## 要点

- バリデーションロジックはコードであり、テストの対象である。テストすべき契約は4つ：正常系が通る／異常系がValidationErrorを投げる／バリデータが正規化する／直列化がラウンドトリップする
- テストデータはpolyfactoryで自動生成する。ModelFactory[Model]を定義すればbuild()/batch()でリアルな値が得られ、register_fixtureでpytestフィクスチャ化できる
- プロパティベーステストはHypothesis。Pydantic v2で同梱プラグインは廃止され、現在はst.builds/st.from_type（制約型も自動対応）かhypothesis-jsonschemaを使う
- model_constructは検証をスキップするので、敢えて不正なエッジケースのフィクスチャを高速に作れる（外部入力には絶対使わない）
- pydantic-settingsはSettings(...)への直接注入が最もhermetic。環境ロード経路を検証する時だけmonkeypatchでプレフィックス付き環境変数を設定する

---

## **導入：バリデーションは「テストすべきコード」である**

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 つに整理できます。

```python
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` の後継）が、モデル定義から**リアルなフェイクデータ**を生成します。

```bash
pip install polyfactory
```

```python
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 のロケールを固定できます。

```python
from faker import Faker


class UserFactory(ModelFactory[User]):
    __faker__ = Faker(locale="ja_JP")  # 日本語のフェイクデータ
    __random_seed__ = 42               # 毎回同じ値を生成（再現可能）
```

pytest のフィクスチャとしても登録できます。

```python
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()` は実装に関係なくそのまま動く」*。

```python
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` に流して「検証が落ちないこと」を確かめます。**境界（入力側）のファジング**に向きます。

```python
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()` は検証を**完全にスキップ**するので、これに使えます。

```python
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'` も強制されません**。当然ながら**外部入力には絶対に使わない**こと——用途は「テストで不正状態を意図的に作る」「検証済みデータの再構築を高速化する」（[パフォーマンス最適化ガイド](/blog/pydantic-v2-performance-optimization-guide) 参照）に限ります。

---

## **5. `pydantic-settings` のテスト：環境を汚さずに上書きする**

設定（`BaseSettings`）のテストは、**プロセスの環境変数を汚さない**のが鉄則です。最もクリーンなのは、`Settings(...)` への**直接注入**です（優先順位が環境変数より高いため確実に上書きでき、並列テストでも安全）。

```python
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 実践ガイド](/blog/pydantic-settings-configuration-management-secrets-guide) を参照してください。

---

## **6. テスト戦略のまとめ：どの道具をどこで使うか**

3 つのレイヤーを役割で整理します。

| レイヤー | 道具 | 目的 |
| --- | --- | --- |
| 契約テスト | **pytest** ＋ `pytest.raises` | 正常系・異常系・変換・ラウンドトリップの 4 契約。境界値を網羅 |
| データ生成 | **polyfactory** | リアルなフィクスチャを自動生成。テストの関心だけを明示 |
| プロパティ検証 | **Hypothesis** | 無数の入力で不変条件を検証。境界・異常値を機械的に発掘 |
| 不正フィクスチャ | **`model_construct`** | 検証外の状態を高速に作り、下流の防御を試す |
| 設定テスト | **直接注入 / monkeypatch** | 環境を汚さず設定を上書き |

これらは CI で常時回します。Pydantic のモデルは**ビジネスルールの宣言的な仕様書**でもあるので、テストはその仕様が壊れていないことの継続的な証明になります。型チェック（mypy/Pyright）と合わせれば、「静的な型 ＋ 実行時の検証 ＋ それらのテスト」という三重の防御が完成します。

---

## **結論：宣言的だからこそ、テストで保証する**

Pydantic のバリデーションは宣言的で読みやすい。しかし「読みやすい」と「正しい」は別物です。本記事の要点を再掲します。

1. モデルの **4 つの契約**（正常系・異常系・変換・ラウンドトリップ）を pytest で固定し、**境界値を必ず突く**。
2. テストデータは **polyfactory** で自動生成し、テストの関心だけを `build(...)` で明示する。
3. **Hypothesis** でプロパティベーステスト。v2 で同梱プラグインは廃止されたので、**`st.builds` / `st.from_type`** か `hypothesis-jsonschema` を使う。
4. **`model_construct`** で不正なエッジケースのフィクスチャを高速に作る（外部入力には使わない）。
5. **`pydantic-settings`** は直接注入を既定にし、環境ロード経路の検証時だけ monkeypatch を使う。

「検証パスを先に作る」のは、Anthropic 自身のデータでも 1 ショット成功率を最も押し上げる行動です。Pydantic のモデルにテストを当てることは、防御の最前線が確かに機能していることの、唯一の証明手段です。

一次情報：

- [Pydantic（BaseModel / ValidationError / model_construct）](https://pydantic.dev/docs/validation/latest/)
- [polyfactory](https://polyfactory.litestar.dev/)（サードパーティ）
- [Hypothesis](https://hypothesis.readthedocs.io/)（サードパーティ）

---

### **テスト容易な型安全バックエンドのご相談**

筆者は、決済の信頼性レイヤーをはじめ、テストカバレッジと本番品質が事業の信頼に直結するシステムを設計・運用してきました。バリデーションのテスト、プロパティベーステストによる不変条件の検証、CI での品質ゲート構築——「速く作る」と「壊さない」を両立させる仕組みづくりを得意としています。Pydantic / FastAPI を用いた **テスト戦略の設計・既存コードのテスト整備・CI 品質ゲートの構築**を、生成 AI を活用して高速かつ高品質に支援します。お気軽にご相談ください。
