Skip to main content
友田 陽大
Pydantic & type-safe validation
Python
Pydantic
テスト
型安全
バリデーション

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
Reading time
9 min read
Author
友田 陽大
Share

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.

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.

ContractWhat to testPitfall
① Happy pathvalid input passes and the type is guaranteed
② Error pathinvalid input throws ValidationErroralways hit both boundary values (17/18)
③ Transformthe validator normalizes/transformsassert the value after transformation
④ Round-tripdump→reconstruct matchesboth 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.

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"

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

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.

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.

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

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

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)  # スキーマ適合の入力は検証を通過するはず
MethodWhat it generatesSuited use
st.builds / st.from_typethe model's instanceverifying object behavior / invariants
from_schema(...)schema-conforming dictfuzzing 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.

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


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

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.


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

Organizing the three layers by role.

LayerToolPurpose
Contract testpytest + pytest.raisesthe 4 contracts: happy, error, transform, round-trip. Cover boundary values
Data generationpolyfactoryauto-generate realistic fixtures. State only the test's concern
Property verificationHypothesisverify invariants over countless inputs. Mechanically unearth boundary/abnormal values
Invalid fixturemodel_constructquickly create an out-of-validation state and try downstream defenses
Settings testdirect injection / monkeypatchoverride 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:


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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading