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.
| 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=18are caught only by testing both17(outside) and18(boundary). Covering inside and outside the boundary withpytest.mark.parametrizestructurally 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
pydanticHypothesis 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."
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) # スキーマ適合の入力は検証を通過するはず
| 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-jsonschemais 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 standardst.builds/st.from_typeyour first choice, and usefrom_schemaas a fallback when the constraint types are complex andst.buildsgets 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_constructperforms 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.setenvwhen 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.
| 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.
- Fix the model's 4 contracts (happy, error, transform, round-trip) with pytest, and always hit the boundary values.
- Auto-generate test data with polyfactory, and state only the test's concern with
build(...). - Do property-based testing with Hypothesis. Since the bundled plugin was dropped in v2, use
st.builds/st.from_typeorhypothesis-jsonschema. - Quickly create invalid edge-case fixtures with
model_construct(don't use it for external input). - 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)
- polyfactory (third-party)
- Hypothesis (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.