Testing Pydantic Applications: Unit Tests to Integration
Pydantic has become the de facto standard for data validation in Python, powering frameworks like FastAPI, LangChain, and countless internal tools. But validating data at runtime is only half the battle — without a robust testing strategy, your schemas can silently drift, your validators can break under edge cases, and your integrations can fail in production. This tutorial walks you through a complete testing strategy for Pydantic applications, from isolated unit tests of individual models to full integration tests that exercise your entire stack.
Why Testing Pydantic Matters
Pydantic models look deceptively simple. A few type annotations and you have validation, serialization, and documentation for free. However, this simplicity hides several failure modes that only surface under specific conditions:
- Schema drift: A field type changes in one model but dependent code is not updated.
- Validator edge cases: Custom
@field_validatorlogic breaks on unexpected inputs likeNone, empty strings, or timezone-naive datetimes. - Serialization mismatches: A model serializes differently than an external API expects, causing silent data corruption.
- Performance regressions: Complex validators or nested models slow down request handling as the schema grows.
- Configuration coupling: Changing
model_configsettings likeextra="forbid"orstr_strip_whitespacehas unintended downstream effects.
A layered testing approach catches these issues early. Unit tests verify individual models and validators in isolation. Integration tests verify that models behave correctly when wired into services, databases, and external APIs. Together, they form a safety net that lets you refactor schemas with confidence.
Setting Up the Test Environment
Before writing tests, set up a clean, reproducible environment. We will use pytest as the test runner, pytest-cov for coverage, and hypothesis for property-based testing. The examples in this tutorial use Pydantic v2.
# requirements-dev.txt
pytest>=8.0
pytest-cov>=5.0
pytest-asyncio>=0.23
hypothesis>=6.100
Create a conftest.py at the root of your test directory. This file holds shared fixtures and configuration that keep your test files focused on behavior rather than boilerplate.
# tests/conftest.py
import pytest
from datetime import datetime, timezone
@pytest.fixture
def utc_now():
"""A fixed timestamp for deterministic datetime tests."""
return datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
@pytest.fixture
def sample_user_payload():
"""A valid payload that passes all User model validations."""
return {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"full_name": "Alice Johnson",
"age": 30,
"roles": ["admin", "editor"],
"created_at": "2025-01-15T12:00:00Z",
}
Unit Testing Pydantic Models
Unit tests focus on a single model in isolation. The goal is to verify that valid inputs produce the expected model instance and that invalid inputs raise ValidationError with the right error structure. Let us start with a model we will use throughout the tutorial.
# src/app/models.py
from datetime import datetime, timezone
from enum import Enum
from uuid import UUID, uuid4
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
class Role(str, Enum):
ADMIN = "admin"
EDITOR = "editor"
VIEWER = "viewer"
class User(BaseModel):
model_config = {"extra": "forbid", "str_strip_whitespace": True}
id: UUID = Field(default_factory=uuid4)
email: EmailStr
full_name: str = Field(min_length=1, max_length=100)
age: int = Field(ge=13, le=130)
roles: list[Role] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@field_validator("full_name")
@classmethod
def full_name_must_contain_space(cls, v: str) -> str:
if " " not in v:
raise ValueError("full_name must include first and last name")
return v.title()
@model_validator(mode="after")
def admin_requires_age_check(self) -> "User":
if Role.ADMIN in self.roles and self.age < 18:
raise ValueError("admins must be at least 18 years old")
return self
Testing Valid Instantiation
The first test category verifies that valid inputs produce the expected model. These tests are straightforward but valuable — they document the intended happy path and catch regressions when you refactor validators.
# tests/test_user_model.py
from datetime import datetime, timezone
from uuid import UUID
import pytest
from pydantic import ValidationError
from app.models import User, Role
class TestUserValidInstantiation:
def test_creates_user_from_valid_payload(self, sample_user_payload):
user = User(**sample_user_payload)
assert user.email == "alice@example.com"
assert user.full_name == "Alice Johnson"
assert user.age == 30
assert user.roles == [Role.ADMIN, Role.EDITOR]
def test_full_name_is_title_cased(self):
user = User(email="bob@example.com", full_name="bob smith", age=25)
assert user.full_name == "Bob Smith"
def test_whitespace_is_stripped_from_string_fields(self):
user = User(email=" carol@example.com ", full_name=" Carol Lee ", age=40)
assert user.email == "carol@example.com"
assert user.full_name == "Carol Lee"
def test_created_at_defaults_to_utc_now(self):
before = datetime.now(timezone.utc)
user = User(email="dave@example.com", full_name="Dave Doe", age=22)
after = datetime.now(timezone.utc)
assert before <= user.created_at <= after
assert user.created_at.tzinfo is not None
def test_id_defaults_to_uuid4(self):
user = User(email="eve@example.com", full_name="Eve Eve", age=28)
assert isinstance(user.id, UUID)
assert user.id.version == 4
Testing Validation Errors
Invalid inputs are where most bugs hide. For each validation rule, write at least one test that confirms the rule fires and that the resulting ValidationError contains the expected field and message. Pydantic exposes error details via the errors() method on the exception.
class TestUserValidationErrors:
def test_rejects_email_without_at_sign(self):
with pytest.raises(ValidationError) as exc_info:
User(email="not-an-email", full_name="Bad Email", age=25)
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("email",)
assert "email" in errors[0]["msg"].lower()
def test_rejects_age_below_minimum(self):
with pytest.raises(ValidationError) as exc_info:
User(email="kid@example.com", full_name="Kid Doe", age=12)
errors = exc_info.value.errors()
assert errors[0]["loc"] == ("age",)
assert "greater_than_equal" in errors[0]["type"]
def test_rejects_age_above_maximum(self):
with pytest.raises(ValidationError):
User(email="old@example.com", full_name="Old Doe", age=200)
def test_rejects_full_name_without_space(self):
with pytest.raises(ValidationError) as exc_info:
User(email="single@example.com", full_name="Madonna", age=30)
errors = exc_info.value.errors()
assert errors[0]["loc"] == ("full_name",)
assert "first and last name" in errors[0]["msg"]
def test_rejects_admin_under_18(self):
with pytest.raises(ValidationError) as exc_info:
User(
email="teen@example.com",
full_name="Teen Admin",
age=16,
roles=["admin"],
)
errors = exc_info.value.errors()
assert errors[0]["loc"] == ()
assert "at least 18" in errors[0]["msg"]
def test_rejects_unknown_role_value(self):
with pytest.raises(ValidationError) as exc_info:
User(
email="role@example.com",
full_name="Role Test",
age=30,
roles=["superuser"],
)
errors = exc_info.value.errors()
assert errors[0]["loc"] == ("roles", 0)
def test_rejects_extra_fields_when_forbid_is_set(self):
with pytest.raises(ValidationError) as exc_info:
User(
email="extra@example.com",
full_name="Extra Field",
age=30,
unexpected="value",
)
errors = exc_info.value.errors()
assert errors[0]["loc"] == ("unexpected",)
assert errors[0]["type"] == "extra_forbidden"
def test_collects_multiple_errors_at_once(self):
with pytest.raises(ValidationError) as exc_info:
User(email="bad", full_name="", age=5, roles=["nope"])
errors = exc_info.value.errors()
locs = [e["loc"] for e in errors]
assert ("email",) in locs
assert ("full_name",) in locs
assert ("age",) in locs
assert ("roles", 0) in locs
Testing Custom Validators in Isolation
Complex validators deserve their own focused tests. If a validator contains branching logic, test each branch independently. This keeps failures localized and makes the intent of each test obvious.
class TestFullNameValidator:
@pytest.mark.parametrize(
"raw,expected",
[
("alice jones", "Alice Jones"),
("JOHN DOE", "John Doe"),
("mary jane watson", "Mary Jane Watson"),
(" trim me ", "Trim Me"),
],
)
def test_title_cases_valid_names(self, raw, expected):
user = User(email="test@example.com", full_name=raw, age=25)
assert user.full_name == expected
@pytest.mark.parametrize("invalid", ["", " ", "NoSpace"])
def test_rejects_invalid_names(self, invalid):
with pytest.raises(ValidationError):
User(email="test@example.com", full_name=invalid, age=25)
class TestAdminAgeValidator:
def test_admin_at_exactly_18_is_allowed(self):
user = User(
email="admin@example.com",
full_name="Admin Eighteen",
age=18,
roles=["admin"],
)
assert Role.ADMIN in user.roles
def test_admin_at_17_is_rejected(self):
with pytest.raises(ValidationError):
User(
email="admin@example.com",
full_name="Admin Seventeen",
age=17,
roles=["admin"],
)
def test_viewer_under_18_is_allowed(self):
user = User(
email="viewer@example.com",
full_name="Young Viewer",
age=14,
roles=["viewer"],
)
assert user.roles == [Role.VIEWER]
Property-Based Testing with Hypothesis
Example-based tests verify specific inputs, but they cannot explore the full input space. Hypothesis generates hundreds of test cases automatically based on your model's type annotations. This is especially powerful for Pydantic because the type system already encodes the constraints.
# tests/test_user_properties.py
from hypothesis import given, strategies as st, settings
from pydantic import ValidationError
from app.models import User, Role
# Generate strings that contain at least one space
full_name_strategy = st.builds(
lambda first, last: f"{first} {last}",
st.text(min_size=1, max_size=40, alphabet=st.characters(whitelist_categories=("Ll", "Lu"))),
st.text(min_size=1, max_size=40, alphabet=st.characters(whitelist_categories=("Ll", "Lu"))),
)
email_strategy = st.builds(
lambda local, domain: f"{local}@{domain}.com",
st.text(min_size=1, max_size=20, alphabet=st.characters(whitelist_categories=("Ll", "Lu"), whitelist_characters="._-")),
st.text(min_size=1, max_size=20, alphabet=st.characters(whitelist_categories=("Ll", "Lu"))),
)
@settings(max_examples=100)
@given(
email=email_strategy,
full_name=full_name_strategy,
age=st.integers(min_value=18, max_value=130),
roles=st.lists(st.sampled_from([Role.VIEWER, Role.EDITOR, Role.ADMIN]), min_size=0, max_size=3),
)
def test_valid_inputs_always_round_trip(email, full_name, age, roles):
"""Any input satisfying the constraints should produce a valid User."""
# Filter out the admin-under-18 edge case handled by the model validator
if Role.ADMIN in roles and age < 18:
return
user = User(email=email, full_name=full_name, age=age, roles=roles)
dumped = user.model_dump()
reconstructed = User(**dumped)
assert reconstructed == user
@given(age=st.integers(max_value=12))
def test_young_age_always_rejected(age):
with pytest.raises(ValidationError):
User(email="test@example.com", full_name="Test Name", age=age)
Property-based tests are excellent at finding edge cases you would never think to write manually — empty lists, unicode names, boundary integers, and more. Run them in CI to catch regressions that example-based tests miss.
Testing Serialization and Deserialization
Pydantic models frequently sit at the boundary between your application and external systems. Testing that model_dump() and model_validate() produce the expected wire format is critical for API contracts.
# tests/test_serialization.py
import json
from app.models import User, Role
class TestUserSerialization:
def test_model_dump_produces_json_serializable_dict(self, sample_user_payload):
user = User(**sample_user_payload)
dumped = user.model_dump(mode="json")
# Every value must be JSON-serializable
json.dumps(dumped)
def test_model_dump_json_returns_string(self, sample_user_payload):
user = User(**sample_user_payload)
json_str = user.model_dump_json()
assert isinstance(json_str, str)
parsed = json.loads(json_str)
assert parsed["email"] == "alice@example.com"
def test_round_trip_preserves_equality(self, sample_user_payload):
user = User(**sample_user_payload)
json_str = user.model_dump_json()
reconstructed = User.model_validate_json(json_str)
assert reconstructed == user
def test_uuid_serializes_as_string_in_json_mode(self, sample_user_payload):
user = User(**sample_user_payload)
dumped = user.model_dump(mode="json")
assert isinstance(dumped["id"], str)
def test_datetime_serializes_as_iso_string_in_json_mode(self, sample_user_payload):
user = User(**sample_user_payload)
dumped = user.model_dump(mode="json")
assert isinstance(dumped["created_at"], str)
assert "T" in dumped["created_at"]
def test_enum_serializes_as_value_in_json_mode(self, sample_user_payload):
user = User(**sample_user_payload)
dumped = user.model_dump(mode="json")
assert dumped["roles"] == ["admin", "editor"]
def test_model_validate_accepts_dict_with_string_uuid(self, sample_user_payload):
user = User.model_validate(sample_user_payload)
assert user.email == "alice@example.com"
def test_model_validate_accepts_dict_with_string_datetime(self):
payload = {
"email": "time@example.com",
"full_name": "Time Test",
"age": 25,
"created_at": "2025-06-01T08:30:00Z",
}
user = User.model_validate(payload)
assert user.created_at.year == 2025
Integration Testing with FastAPI
Unit tests verify models in isolation, but real applications wire models into web frameworks, databases, and message queues. Integration tests verify that these components work together. FastAPI is the most common framework paired with Pydantic, so we will use it as the integration target.
# src/app/api.py
from fastapi import FastAPI, HTTPException, status
from app.models import User, Role
app = FastAPI(title="User Service")
# In-memory store for this tutorial; replace with a real database in production
_users: dict[str, User] = {}
@app.post("/users", response_model=User, status_code=status.HTTP_201_CREATED)
def create_user(user: User):
if user.id in _users:
raise HTTPException(status_code=409, detail="User already exists")
_users[str(user.id)] = user
return user
@app.get("/users/{user_id}", response_model=User)
def get_user(user_id: str):
user = _users.get(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.get("/users", response_model=list[User])
def list_users(role: Role | None = None):
users = list(_users.values())
if role is not None:
users = [u for u in users if role in u.roles]
return users
FastAPI uses TestClient from Starlette, which wraps httpx and runs requests against the app in-process. This is fast and requires no external server.
# tests/test_api_integration.py
import pytest
from fastapi.testclient import TestClient
from app.api import app, _users
client = TestClient(app)
@pytest.fixture(autouse=True)
def reset_store():
_users.clear()
yield
_users.clear()
class TestCreateUser:
def test_creates_user_with_valid_payload(self, sample_user_payload):
response = client.post("/users", json=sample_user_payload)
assert response.status_code == 201
body = response.json()
assert body["email"] == "alice@example.com"
assert body["full_name"] == "Alice Johnson"
def test_returns_422_for_invalid_email(self):
payload = {
"email": "not-an-email",
"full_name": "Bad Email",
"age": 25,
}
response = client.post("/users", json=payload)
assert response.status_code == 422
detail = response.json()["detail"]
assert any(d["loc"] == ["body", "email"] for d in detail)
def test_returns_422_for_missing_required_field(self):
payload = {"full_name": "No Email", "age": 25}
response = client.post("/users", json=payload)
assert response.status_code == 422
detail = response.json()["detail"]
assert any(d["loc"] == ["body", "email"] for d in detail)
def test_returns_422_for_extra_field(self, sample_user_payload):
sample_user_payload["hacker_field"] = "malicious"
response = client.post("/users", json=sample_user_payload)
assert response.status_code == 422
def test_returns_422_for_admin_under_18(self):
payload = {
"email": "teen@example.com",
"full_name": "Teen Admin",
"age": 16,
"roles": ["admin"],
}
response = client.post("/users", json=payload)
assert response.status_code == 422
detail = response.json()["detail"]
assert any("at least 18" in d["msg"] for d in detail)
def test_returns_409_for_duplicate_user(self, sample_user_payload):
client.post("/users", json=sample_user_payload)
response = client.post("/users", json=sample_user_payload)
assert response.status_code == 409
class TestGetUser:
def test_retrieves_existing_user(self, sample_user_payload):
create_response = client.post("/users", json=sample_user_payload)
user_id = create_response.json()["id"]
response = client.get(f"/users/{user_id}")
assert response.status_code == 200
assert response.json()["email"] == "alice@example.com"
def test_returns_404_for_missing_user(self):
response = client.get("/users/nonexistent-id")
assert response.status_code == 404
class TestListUsers:
def test_lists_all_users(self, sample_user_payload):
client.post("/users", json=sample_user_payload)
response = client.get("/users")
assert response.status_code == 200
assert len(response.json()) == 1
def test_filters_by_role(self, sample_user_payload):
client.post("/users", json=sample_user_payload)
viewer_payload = {
"email": "viewer@example.com",
"full_name": "Viewer Person",
"age": 22,
"roles": ["viewer"],
}
client.post("/users", json=viewer_payload)
response = client.get("/users?role=viewer")
assert response.status_code == 200
users = response.json()
assert len(users) == 1
assert users[0]["email"] == "viewer@example.com"
def test_returns_422_for_invalid_role_filter(self):
response = client.get("/users?role=superuser")
assert response.status_code == 422
Integration Testing with a Database
Most real applications persist data. When Pydantic models map to database tables, you need integration tests that verify the mapping round-trips correctly. The example below uses SQLAlchemy with an in-memory SQLite database for speed and isolation.
# src/app/repositories.py
from sqlalchemy import create_engine, Column, String, Integer, JSON
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from app.models import User
Base = declarative_base()
class UserRecord(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
email = Column(String, nullable=False, unique=True)
full_name = Column(String, nullable=False)
age = Column(Integer, nullable=False)
roles = Column(JSON, nullable=False, default=list)
def make_engine():
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
return engine
def save_user(session: Session, user: User) -> User:
record = UserRecord(
id=str(user.id),
email=user.email,
full_name=user.full_name,
age=user.age,
roles=[r.value for r in user.roles],
)
session.add(record)
session.commit()
return user
def load_user(session: Session, user_id: str) -> User | None:
record = session.get(UserRecord, user_id)
if record is None:
return None
return User(
id=record.id,
email=record.email,
full_name=record.full_name,
age=record.age,
roles=record.roles,
)
# tests/test_repository_integration.py
import pytest
from sqlalchemy.orm import Session
from app.models import User, Role
from app.repositories import make_engine, save_user, load_user, sessionmaker
@pytest.fixture
def session():
engine = make_engine()
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
yield session
session.close()
class TestUserRepository:
def test_save_and_load_round_trips(self, session, sample_user_payload):
user = User(**sample_user_payload)
save_user(session, user)
loaded = load_user(session, str(user.id))
assert loaded is not None
assert loaded.email == user.email
assert loaded.full_name == user.full_name
assert loaded.age == user.age
assert loaded.roles == user.roles
def test_load_returns_none_for_missing_user(self, session):
result = load_user(session, "does-not-exist")
assert result is None
def test_roles_persist_as_enum_values(self, session, sample_user_payload):
user = User(**sample_user_payload)
save_user(session, user)
loaded = load_user(session, str(user.id))
assert all(isinstance(r, Role) for r in loaded.roles)
assert Role.ADMIN in loaded.roles
def test_empty_roles_list_persists(self, session):
user = User(email="noroles@example.com", full_name="No Roles", age=30)
save_user(session, user)
loaded = load_user(session, str(user.id))
assert loaded.roles == []
Testing Async Validators and Models
Pydantic v2 supports async validators via @model_validator(mode="after") on async methods. Testing these requires pytest-asyncio and careful handling of the event loop.
# src/app/async_models.py
from pydantic import BaseModel, model_validator
import httpx
class RepositoryConfig(BaseModel):
url: str
token: str | None = None
@model_validator(mode="after")
async def validate_url_reachable(self) -> "RepositoryConfig":
async with httpx.AsyncClient() as client:
try:
response = await client.head(self.url, timeout=5.0)
if response.status_code >= 400:
raise ValueError(f"Repository returned status {response.status_code}")
except httpx.ConnectError:
raise ValueError(f"Cannot reach repository at {self.url}")
return self
# tests/test_async_validators.py
import pytest
from unittest.mock import AsyncMock, patch
from app.async_models import RepositoryConfig
@pytest.mark.asyncio
async def test_validates_reachable_url():
mock_response = AsyncMock()
mock_response.status_code = 200
with patch("httpx.AsyncClient.head", new_callable=AsyncMock, return_value=mock_response):
config = RepositoryConfig(url="https://api.example.com")
assert config.url == "https://api.example.com"
@pytest.mark.asyncio
async def test_rejects_unreachable_url():
import httpx
with patch("httpx.AsyncClient.head", new_callable=AsyncMock, side_effect=httpx.ConnectError("refused")):
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
RepositoryConfig(url="https://unreachable.example.com")
assert "Cannot reach" in exc_info.value.errors()[0]["msg"]
@pytest.mark.asyncio
async def test_rejects_error_status():
mock_response = AsyncMock()
mock_response.status_code = 500
with patch("httpx.AsyncClient.head", new_callable=AsyncMock, return_value=mock_response):
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
RepositoryConfig(url="https://broken.example.com")
assert "status 500" in exc_info.value.errors()[0]["msg"]
Testing Model Configurations
Pydantic's model_config controls behavior like strict mode, extra field handling, and alias generation. Changes to these settings can have sweeping effects, so test them explicitly.
# tests/test_model_config.py
from pydantic import BaseModel, ValidationError, ConfigDict
class StrictModel(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
value: int
name: str
class CoercionModel(BaseModel):
model_config = ConfigDict(strict=False, extra="ignore")
value: int
name: str
class TestStrictMode:
def test_rejects_string_for_int_field(self):
with pytest.raises(ValidationError):
StrictModel(value="42", name="test")
def test_accepts_actual_int(self):
model = StrictModel(value=42, name="test")
assert model.value == 42
class TestCoercionMode:
def test_coerces_string_to_int(self):
model = CoercionModel(value="42", name="test")
assert model.value == 42
assert isinstance(model.value, int)
def test_ignores_extra_fields(self):
model = CoercionModel(value=1, name="test", extra="ignored")
assert not hasattr(model, "extra")
Best Practices
Organize Tests by Behavior, Not by Method
Group tests into classes that describe a behavior or scenario, such as TestUserValidInstantiation or TestAdminAgeValidator. This makes test failures self-documenting and keeps related tests together.
Test Both Sides of Every Validation Rule
For every constraint in your model, write at least one test for the valid case and one for the invalid case. Boundary values deserve special attention — test the exact boundary (age=18 for an 18+ rule) and the values just outside it (age=17).
Use Fixtures for Shared Payloads
Define valid payloads as fixtures in conftest.py and mutate copies in individual tests. This reduces duplication and ensures every test starts from a known-good state.
def test_rejects_missing_email(self, sample_user_payload):
payload = {**sample_user_payload}
del payload["email"]
with pytest.raises(ValidationError):
User(**payload)
Assert on Error Structure, Not Just Exception Type
Catching ValidationError is not enough. Always inspect exc_info.value.errors() to confirm the error points to the right field and contains the expected message. This catches subtle bugs where a different validator fires than intended.
Keep Integration Tests Independent
Use autouse fixtures to reset state between tests. Never rely on test execution order. Each integration test should set up its own data, assert its own outcome, and clean up after itself.
Use Coverage to Find Gaps
Run pytest --cov=app --cov-report=term-missing to identify untested validators or code paths. Aim for high coverage on model files specifically, since they encode your business rules.
pytest --cov=src/app --cov-report=html --cov-report=term-missing tests/
Snapshot Test Complex Serializations
For models with many fields and complex nested structures, consider snapshot testing with pytest-syrupy or pytest-regressions. This catches unintended changes to the serialized output without writing exhaustive assertions by hand.
Separate Unit and Integration Test Runs
Mark integration tests with @pytest.mark.integration and configure pytest.ini to allow filtering. This lets you run fast unit tests on every save and slower integration tests in CI.
# pytest.ini
[pytest]
markers =
integration: marks tests as integration tests (deselect with '-m "not integration"')
# Run only unit tests
pytest -m "not integration"
# Run only integration tests
pytest -m integration
# Run everything
pytest
Conclusion
Testing Pydantic applications effectively requires thinking in layers. Unit tests verify that individual models validate, serialize, and reject inputs as designed. Property-based tests with Hypothesis explore the input space far beyond what manual examples can cover. Integration tests confirm that models behave correctly when wired into FastAPI endpoints, databases, and async workflows. By investing in all three layers, you build a safety net that catches schema drift, validator regressions, and serialization mismatches before they reach production. The patterns in this tutorial — behavior-organized test classes, shared fixtures, explicit error structure assertions, and clear separation between unit and integration suites — scale from small scripts to large service-oriented codebases. Start with the unit tests for your most critical models, add property-based tests for complex validators, and layer in integration tests as your application grows. The result is a Pydantic codebase you can refactor with confidence.