# Pydantic testing strategy: thoroughly testing validation logic with polyfactory and Hypothesis

> A practical guide to testing Pydantic v2 models and validation at production quality. With real code, from the viewpoints of testability and CI, it covers: contract tests with pytest.raises, automatic test-data generation with polyfactory, property-based testing with Hypothesis (the correct way after the plugin was dropped in v2), fast invalid fixtures with model_construct, and testing pydantic-settings.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, Pydantic, テスト, 型安全, バリデーション
- URL: https://tomodahinata.com/en/blog/pydantic-testing-polyfactory-hypothesis-strategy-guide
- Category: Pydantic & type-safe validation
- Pillar guide: https://tomodahinata.com/en/blog/pydantic-v2-production-validation-type-safety

## Key points

- Validation logic is code, and it is the target of tests. There are 4 contracts to test: the happy path passes / the error path throws ValidationError / the validator normalizes / serialization round-trips.
- Generate test data automatically with polyfactory. Define ModelFactory[Model] and build()/batch() yield realistic values; register_fixture turns it into a pytest fixture.
- Property-based testing is Hypothesis. The bundled plugin was dropped in Pydantic v2; now use st.builds/st.from_type (constraint types auto-supported) or hypothesis-jsonschema.
- model_construct skips validation, so you can quickly create deliberately-invalid edge-case fixtures (never use it for external input).
- For pydantic-settings, direct injection into Settings(...) is the most hermetic. Use monkeypatch to set prefixed env vars only when verifying the env-loading path.

---

## **Introduction: validation is "code that should be tested"**

Introduce Pydantic and the validation logic becomes declarative. `Field(gt=0)`, `@field_validator`, `@model_validator` — these are short and readable. That is exactly why **it's easy to fall into the illusion that "because it's declarative, tests aren't needed."**

This is a dangerous assumption. Validation is your system's **front-line defense.** "18 or older," "a positive amount," "normalizing an email address," "matching passwords" — the guarantee that these are working correctly can only be obtained through tests. Even if you write a validator condition wrong with `<` vs. `<=`, the code runs as if nothing happened, and **just one boundary value slips through in production.**

This article explains a **strategy to test Pydantic v2 models and validation at production quality** across three layers — ① test the model's contracts (pytest), ② auto-generate test data (polyfactory), ③ verify invariants property-based (Hypothesis). It's a concretization of CLAUDE.md's "Verification First (build the verification path first)" principle for Pydantic.

> ⚠️ **There is no official "Testing" page**: the Pydantic v2 official documentation has no consolidated "Testing" page (at writing time). The pytest patterns in this article are composed by combining **the certain specifications of official APIs** like `ValidationError` / `model_dump` / `model_construct`. polyfactory and Hypothesis are third-party; their respective official docs are noted as primary sources.

---

## **1. What to test: the model's "4 contracts"**

The contracts a Pydantic model should guarantee in tests can be organized into four.

```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
```

Restating the **4 contracts.**

| Contract | What to test | Pitfall |
| --- | --- | --- |
| ① Happy path | valid input passes and the type is guaranteed | — |
| ② Error path | invalid input throws `ValidationError` | **always hit both boundary values (`17`/`18`)** |
| ③ Transform | the validator normalizes/transforms | assert the value *after* transformation |
| ④ Round-trip | dump→reconstruct matches | both `model_dump()` and `model_validate_json()` |

> 💡 **The boundary value is the crux**: most bugs in `ge=18` are caught only by testing both `17` (outside) and `18` (boundary). Covering inside and outside the boundary with `pytest.mark.parametrize` structurally prevents the `<` / `<=` mistake.

---

## **2. Auto-generate test data: polyfactory**

As models grow, assembling a valid instance by hand for each test becomes painful. Writing `age=20, email="a@example.com", name="alice", ...` every time is a DRY violation. **polyfactory** (the successor of `pydantic-factories`) generates **realistic fake data** from the model definition.

```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"
```

In tests where you want determinism (reproducibility), you can fix the seed and Faker locale.

```python
from faker import Faker


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

You can also register it as a pytest fixture.

```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()  # フィクスチャ経由でファクトリを受け取る
    ...
```

**Why does this work?**
polyfactory makes it possible to "**state only the test's concern explicitly and delegate the rest to auto-generation.**" Write `build(role="admin")` and the test's intent of "a user whose role is admin" appears clearly in the code, and the noise of unrelated fields (name, email…) disappears. Test readability and maintainability rise, and existing tests are less likely to break even as fields are added to the model (ETC).

---

## **3. Property-based testing: try "every input" with Hypothesis**

Example-based tests can only cover "inputs you thought of." **Hypothesis** **auto-generates a vast number of inputs** from the types and verifies "properties that should hold for any input." It hits cases humans don't think of, like boundary values and strange Unicode.

> ⚠️ **The Hypothesis plugin bundled with Pydantic was dropped in v2**: v1 bundled a `pydantic` Hypothesis plugin, but it was **dropped in v2.0** and hasn't been revived as of writing (June 2026). The old articles' step of "enable the plugin" no longer applies. The two correct current approaches are as follows.

### **H3: Method A — `st.builds` / `st.from_type` (recommended, standard)**

Hypothesis's standard `st.builds(Model)` can directly generate model instances. Per the Hypothesis docs, *"Pydantic auto-registers constraint types, so `builds()` and `from_type()` work as-is regardless of the implementation."*

```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: Method B — `hypothesis-jsonschema` (fuzzing the input boundary)**

From the JSON Schema that `Model.model_json_schema()` emits, generate **raw dicts** that conform to the schema, feed them to `model_validate`, and confirm "validation doesn't fail." It's suited for **fuzzing the boundary (the input side).**

```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)  # スキーマ適合の入力は検証を通過するはず
```

| Method | What it generates | Suited use |
| --- | --- | --- |
| `st.builds` / `st.from_type` | the model's **instance** | verifying object behavior / invariants |
| `from_schema(...)` | schema-conforming **dict** | fuzzing the input boundary (external input) |

> ⚠️ **`hypothesis-jsonschema` is third-party with an unstable API**: this is a separate PyPI package from Pydantic and Hypothesis themselves, is below 1.x (beta), and explicitly states that backward-incompatible changes may arrive. Make the standard `st.builds` / `st.from_type` your first choice, and use `from_schema` as a fallback when the constraint types are complex and `st.builds` gets stuck — that's safe.

---

## **4. `model_construct`: quickly create invalid edge cases**

"I want to try the **outside** of the validation logic" — for example, to try "if invalid data slipped through, how would downstream code behave," you need to create an instance without passing validation. `model_construct()` **completely skips** validation, so it's usable for this.

```python
from app.models import User


def test_downstream_handles_impossible_state():
    # 検証を通さず、敢えて "ありえない" 値を持つフィクスチャを高速に作る
    broken = User.model_construct(age=-1, email="not-an-email")  # ValidationError は出ない
    # この壊れた状態を下流コードに渡したときの防御的挙動を検証する
    ...
```

> ⚠️ **Validation doesn't run**: `model_construct` performs no validation at all, so it calmly creates objects that violate the schema (which is why it's handy for fixtures). Moreover, per the docs, **`model_config.extra='forbid'` is not enforced either.** Naturally, **never use it for external input** — its uses are limited to "deliberately creating an invalid state in tests" and "speeding up the reconstruction of already-validated data" (see the [performance optimization guide](/blog/pydantic-v2-performance-optimization-guide)).

---

## **5. Testing `pydantic-settings`: override without polluting the environment**

For testing settings (`BaseSettings`), the iron rule is to **not pollute the process's environment variables.** The cleanest is **direct injection** into `Settings(...)` (it can override reliably because it has higher priority than env vars, and is safe even in parallel tests).

```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
```

> 💡 **Make direct injection the default**: use `monkeypatch.setenv` when verifying the **path** of "loading from env vars," and use direct injection for any other logic verification. Direct injection doesn't touch the process environment, so tests don't interfere with each other and stay stable even in parallel execution. For the full picture of configuration management, see the [pydantic-settings practical guide](/blog/pydantic-settings-configuration-management-secrets-guide).

---

## **6. Summary of the testing strategy: which tool, where**

Organizing the three layers by role.

| Layer | Tool | Purpose |
| --- | --- | --- |
| Contract test | **pytest** + `pytest.raises` | the 4 contracts: happy, error, transform, round-trip. Cover boundary values |
| Data generation | **polyfactory** | auto-generate realistic fixtures. State only the test's concern |
| Property verification | **Hypothesis** | verify invariants over countless inputs. Mechanically unearth boundary/abnormal values |
| Invalid fixture | **`model_construct`** | quickly create an out-of-validation state and try downstream defenses |
| Settings test | **direct injection / monkeypatch** | override settings without polluting the environment |

Run these continuously in CI. Since a Pydantic model is also a **declarative spec of business rules**, the tests become continuous proof that the spec isn't broken. Combined with type checking (mypy/Pyright), you complete a triple defense of "static types + runtime validation + tests of them."

---

## **Conclusion: because it's declarative, guarantee it with tests**

Pydantic's validation is declarative and readable. But "readable" and "correct" are different things. Restating the key points of this article.

1. Fix the model's **4 contracts** (happy, error, transform, round-trip) with pytest, and **always hit the boundary values.**
2. Auto-generate test data with **polyfactory**, and state only the test's concern with `build(...)`.
3. Do property-based testing with **Hypothesis.** Since the bundled plugin was dropped in v2, use **`st.builds` / `st.from_type`** or `hypothesis-jsonschema`.
4. Quickly create invalid edge-case fixtures with **`model_construct`** (don't use it for external input).
5. Make direct injection the default for **`pydantic-settings`**, and use monkeypatch only when verifying the env-loading path.

"Building the verification path first" is, even by Anthropic's own data, the action that most boosts the one-shot success rate. Applying tests to Pydantic models is the only means of proving that the front line of defense is truly functioning.

Primary sources:

- [Pydantic (BaseModel / ValidationError / model_construct)](https://pydantic.dev/docs/validation/latest/)
- [polyfactory](https://polyfactory.litestar.dev/) (third-party)
- [Hypothesis](https://hypothesis.readthedocs.io/) (third-party)

---

### **Consulting on testable, type-safe backends**

The author has designed and operated systems where test coverage and production quality directly tie to business trust, starting with payment reliability layers. Testing validation, verifying invariants with property-based testing, building quality gates in CI — I specialize in building mechanisms that achieve both "build fast" and "don't break." Using generative AI, I support **designing testing strategies, retrofitting tests onto existing code, and building CI quality gates** with Pydantic / FastAPI, quickly and at high quality. Feel free to reach out.
