Quick answer: Test Pydantic JSON validation at six boundaries: strict input mode, extra fields, nullable requirements, default validation, alias direction, and output representation. A model that accepts one happy-path document does not establish the behavior of omitted fields, rejected values or the JSON response clients actually receive.
An endpoint switches from raw JSON validation to a parsed dictionary, and a date that previously passed starts failing. The model did not necessarily change. The input mode did. These checks turn configuration assumptions into visible release fixtures.
The examples were executed with Pydantic 2.14.1 on Python 3.12.14. They target the shown behavior rather than every v2 release or framework adapter. Our Python JSON module guide covers basic decoding; the dataclass JSON checks cover ordinary dataclasses without this validation layer.
1. Does strict validation use the same input mode as the endpoint?
Test raw JSON and already decoded Python input through the exact validation entry points your endpoint uses. Pydantic's strictness can differ between those modes for types without native JSON equivalents. Keep a valid date fixture and a wrong numeric type together so strict mode is verified by behavior rather than a configuration label.
The official strict-mode guide explains the mode distinction. The JSON validation guide documents model_validate_json. A framework that parses a request body before invoking a model can therefore exercise a different route from a unit test that passes raw JSON text directly.
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, ValidationError
import json
def rejects(call):
try:
call()
except ValidationError:
return
raise AssertionError("expected validation rejection")
class Booking(BaseModel):
model_config = ConfigDict(strict=True)
day: date
seats: int
wire = '{"day":"2026-10-31","seats":2}'
assert Booking.model_validate_json(wire).day == date(2026, 10, 31)
rejects(lambda: Booking.model_validate(json.loads(wire)))
rejects(lambda: Booking.model_validate_json('{"day":"2026-10-31","seats":"2"}'))
Do not switch to lax validation solely to make a failing integration test pass. First decide which conversions the API permits, then select the entry point and field rules that implement that contract. A deliberately accepted date string does not mean a numeric string should also be accepted as an integer.
Keep request-adapter tests alongside model tests. Feed the same serialized body through the deployed framework's normal parsing path, then assert the resulting model or error. This catches a middleware change that alters the input representation while leaving the model declaration untouched.
Record per-call overrides too. A model-level configuration is only part of the effective policy when a caller can supply validation arguments. Include the service wrapper in the fixture so reviewers can see the options actually used before an accepted value reaches the operation.
2. Does the chosen extra-field policy catch accidental inputs?
Send a valid document with one unexpected field and assert the intended decision. Unknown-field handling is a model policy, not an automatic guarantee of typed validation. If the contract rejects extras, configure that behavior explicitly, then test nested models separately because an outer rejection policy does not prove every child model uses it.
Pydantic's configuration reference documents extra-field handling. For a strict request contract, a synthetic unknown flag is a useful fixture: it proves a typo or an unsupported option cannot disappear silently while the application reports a successful request.
class Search(BaseModel):
model_config = ConfigDict(extra='forbid', strict=True)
query: str
assert Search.model_validate_json('{"query":"books"}').query == 'books'
rejects(lambda: Search.model_validate_json('{"query":"books","admin":true}'))
rejects(lambda: Search.model_validate_json('{"qurey":"books"}'))
Use exact expected output keys as well as input rejection cases. Accepting an extra field, retaining it, ignoring it and serializing it later are different behaviors. A successful construction assertion alone does not establish which of those behaviors occurred or whether a framework response path exposes the value.
For an intentionally extensible metadata object, document the different rule at that location. Do not apply a blanket rejection policy to every mapping merely because the top-level request is closed. Positive fixtures should prove legitimate extension fields remain accepted where the product contract allows them.
Treat a policy change as a compatibility change. Clients may already send unused properties that an older model ignored. Review representative sanitized requests before making rejection mandatory, and include a clear client error for unsupported fields. The test should encode an intentional contract, not an undocumented tightening introduced during cleanup.
Give rejected fixtures names that identify their purpose, such as unknown outer field or misspelled query. A test asserting only that some error occurred can pass because a different required field was missing. Keep the rest of each negative fixture valid so the error actually exercises the extra-field policy you intended to verify, and inspect the structured error location when the distinction matters.
3. Are missing values tested separately from explicit null?
Construct fixtures for omission, explicit null and a valid value whenever a field is nullable. A nullable annotation permits a value state; it does not by itself promise that the member can be absent. Use an explicit default for an omittable field, and test the endpoint's patch or creation semantics independently from the annotation's appearance.
The official fields documentation distinguishes required fields and defaults. This distinction matters for APIs where omission means leave unchanged but null means clear the value. A convenient loader should not collapse those intentions before application code decides what the operation means.
class RequiredNote(BaseModel):
note: str | None
class OptionalNote(BaseModel):
note: str | None = None
rejects(lambda: RequiredNote.model_validate_json('{}'))
assert RequiredNote.model_validate_json('{"note":null}').note is None
assert RequiredNote.model_validate_json('{"note":"ok"}').note == 'ok'
assert OptionalNote.model_validate_json('{}').note is None
assert OptionalNote.model_validate_json('{"note":null}').note is None
Keep a fixture that records whether the caller supplied the field, not only its final attribute value, when implementing partial updates. Two instances can end with the same value while representing different requests. Your update mapper needs an explicit policy for preserving that distinction at the storage boundary.
Our JSON null versus undefined guide covers the broader wire distinction. Here the validation model adds another layer: a default can fill an omitted field. Do not infer the original request from the completed model's value alone when that provenance affects the operation.
For a partial-update route, test omission and null through the storage mapper as separate operations. Start with a record containing a note, submit an update without that field, and require the note to remain. Then submit explicit null and assert the documented clearing behavior. This goes beyond checking model construction and catches a mapper that writes every completed attribute back to the database indiscriminately.
4. Do defaults pass the constraints promised by the model?
Instantiate a model without a field and verify its resulting value and type, then test the same field when explicitly supplied. Default validation is a separate control. Enable it where the contract requires defaults to satisfy validation, and retain a deliberately invalid default fixture to prove that omission cannot bypass the intended constraint.
The example uses a string default for an integer field to make the difference observable. It is a regression control, not a suggested production default. A model with sensible defaults should still have a test proving those defaults remain valid after a constraint changes.
class UncheckedDefault(BaseModel):
count: int = 'bad'
class CheckedDefault(BaseModel):
count: int = Field(default='bad', validate_default=True)
assert UncheckedDefault().count == 'bad'
rejects(lambda: CheckedDefault())
assert CheckedDefault(count=2).count == 2
Review configuration-driven defaults with the same care as literal defaults. A value supplied by a factory or application setting can drift outside a field's allowed range after deployment changes. Put representative configured values through the real construction path instead of testing only explicit client values.
Defaults also deserve compatibility review. Changing the value used for omission can change application behavior even when the accepted JSON schema appears similar. Keep an accepted omitted-field request in the release corpus and assert the resulting operation, especially when the default controls pagination, scheduling or resource consumption.
5. Do validation and serialization aliases match their separate contracts?
Test accepted input names and emitted output names independently. A model can use a validation alias for incoming data and a different serialization alias for outgoing data. Passing input validation does not prove the response uses the public key spelling, so assert the exact serialized keys with the output options your endpoint supplies.
The alias guide documents these separate directions. Explicit fixtures help when Python naming conventions differ from the API's field names. Avoid renaming a wire field accidentally because an internal attribute was refactored or a dumping call omitted an alias option.
class Customer(BaseModel):
model_config = ConfigDict(extra='forbid')
customer_id: int = Field(validation_alias='customerId',
serialization_alias='id')
customer = Customer.model_validate_json('{"customerId":12}')
assert customer.customer_id == 12
assert customer.model_dump(by_alias=True) == {'id': 12}
assert customer.model_dump(by_alias=False) == {'customer_id': 12}
rejects(lambda: Customer.model_validate_json('{"customer_id":12}'))
For migration periods that accept more than one name, define and test conflict behavior when both names arrive. Do not rely on an assumption about which spelling wins. A client receiving a successful response should not be left guessing which submitted value the application actually used.
Keep the outbound fixture independent from the inbound fixture when the endpoint intentionally uses different representations. Round-tripping a response back through the request model may not be the contract. Test each direction with its documented consumer rather than forcing symmetry that the API never promised.
6. Does the response use the intended JSON representation and exclusions?
Assert the exact response document after choosing Python-mode dumping, JSON-mode dumping or JSON text serialization. These outputs are not interchangeable at every boundary. Test converted values and intentionally omitted fields, then compare the final document with an approved fixture so a valid model cannot silently produce an incompatible or overexposed response.
Pydantic's serialization documentation explains the output modes. A date can remain a Python date in a Python-mode dictionary while becoming text in JSON mode. The response adapter must receive the representation it expects; otherwise it may fail or apply another unintended conversion.
class PublicBooking(BaseModel):
day: date
internal_note: str = Field(exclude=True)
public = PublicBooking(day=date(2026, 10, 31), internal_note='fake-internal-marker')
assert isinstance(public.model_dump()['day'], date)
assert public.model_dump(mode='json') == {'day': '2026-10-31'}
output = public.model_dump_json()
assert json.loads(output) == {'day': '2026-10-31'}
assert 'internal_note' not in output and 'fake-internal-marker' not in output
Use JSON Compare to review expected and actual output during an API change. It reports structural differences while ignoring object-key order and whitespace. It does not run Pydantic validation or establish that a framework uses your intended dump options, so keep executable endpoint assertions alongside that visual review.
Run a synthetic accepted and rejected request through the service boundary and confirm rejected values never reach side effects. Likewise, inspect the actual response body for exclusions rather than assuming a model-level test proves every route uses that model. A hand-written fallback response can bypass the configured serializer.
Keep dependency-upgrade checks reproducible. Store the library version, the effective model settings and the expected accepted or rejected result with the fixture corpus. When an upgrade changes a result, review the documented behavior and consumer impact before rewriting the expected output. A green test produced by automatically accepting every new snapshot is weak evidence that the API still preserves its promised contract.
Wikipedia's data-validation overview gives background terminology. These six tests provide narrower evidence for a specific library version and configuration. They do not prove authorization, complete domain correctness or compatibility with every downstream client. Keep the fixtures with the API contract and rerun them when the model, adapter or dependency version changes.