Contract‑Driven API Testing with Pydantic and JSON Schema

In fast‑changing APIs, traditional assertions break when fields change, but a dual‑engine approach using JSON Schema for structural contracts and Pydantic for business rules provides a resilient, maintainable testing solution that adapts to evolution while keeping tests focused on critical data.

Test Development Learning Exchange
Test Development Learning Exchange
Test Development Learning Exchange
Contract‑Driven API Testing with Pydantic and JSON Schema

Problem

High‑frequency API changes often cause automated tests to fail because classic assertions tightly couple to exact response structures and values, leading to the "change one field, break many tests" dilemma.

Why Traditional Assertions Fail

Typical anti‑patterns include asserting the whole response object or hard‑coding field paths. Adding a non‑critical field or renaming an existing one can cause dozens of test failures, forcing manual fixes and eventually causing teams to abandon automation.

Contract‑Driven Solution

The core idea is to ignore irrelevant data and only verify that key fields exist, have the correct type, and satisfy business rules. This is achieved with a two‑layer validation strategy:

Structural validation – JSON Schema checks field presence, types, and required properties.

Business validation – Pydantic models enforce value ranges, formats, and cross‑field logic.

Step 1: Define JSON Schema

JSON Schema provides a language‑agnostic contract that can be generated from OpenAPI specifications. An example schemas/user.json defines required fields, types, formats, and enumerations.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "user_id": {"type": "integer"},
    "username": {"type": "string", "minLength": 1},
    "email": {"type": "string", "format": "email"},
    "status": {"type": "string", "enum": ["active", "inactive", "banned"]},
    "created_at": {"type": "string", "format": "date-time"}
  },
  "required": ["user_id", "username", "status"]
}

Step 2: Enhance Business Validation with Pydantic

Pydantic models add custom validators for domain‑specific rules, such as restricting email domains or preventing a newly created user from being banned within the first 24 hours.

from pydantic import BaseModel, validator, Field
from datetime import datetime

class UserResponse(BaseModel):
    user_id: int
    username: str = Field(..., min_length=1)
    email: str
    status: str
    created_at: datetime

    @validator('email')
    def validate_email_domain(cls, v):
        if not v.endswith('@company.com'):
            raise ValueError('Only company email allowed')
        return v

    @validator('status')
    def validate_status_transition(cls, v, values):
        if values.get('created_at') and (datetime.now() - values['created_at']).days < 1:
            if v == 'banned':
                raise ValueError('New users cannot be banned within 24h')
        return v

Step 3: Integrate into a Requests‑Based Test Framework

A utility function validate_response performs JSON Schema validation first, then Pydantic validation if a model class is supplied. Test cases call this function and then assert only the business‑critical fields.

# utils/validators.py
import json
from jsonschema import validate, ValidationError
from pydantic import ValidationError as PydanticError

def validate_response(resp, schema_path: str = None, model_class=None):
    """Dual validation: JSON Schema then Pydantic"""
    data = resp.json()
    if schema_path:
        with open(schema_path) as f:
            schema = json.load(f)
        try:
            validate(instance=data, schema=schema)
        except ValidationError as e:
            raise AssertionError(f"Schema validation failed: {e.message}")
    if model_class:
        try:
            model_class(**data)
        except PydanticError as e:
            raise AssertionError(f"Business rule validation failed: {e}")
    return data

# test_user_api.py
import requests
from utils.validators import validate_response
from models.user import UserResponse

def test_get_user():
    resp = requests.get("https://api.example.com/users/123")
    user_data = validate_response(resp, schema_path="schemas/user.json", model_class=UserResponse)
    assert user_data["status"] == "active"
    assert "@company.com" in user_data["email"]

Advanced Practices

Generate JSON Schemas automatically from OpenAPI using openapi-generator.

Version schemas (e.g., schemas/v1/user.json, schemas/v2/user.json) and select the appropriate version in tests.

Enhance validate_response to produce friendly error messages that pinpoint the failing field and reason.

Best Practices & Caveats

Validate only fields that matter; ignore auxiliary fields like trace_id.

Keep responsibilities clear: JSON Schema handles structure, Pydantic handles value ranges and cross‑field rules.

Collaborate with backend teams to keep OpenAPI docs up‑to‑date and store schemas in version‑controlled repositories.

Schema validation is lightweight; Pydantic adds modest overhead and should be limited to core business endpoints.

Conclusion

By combining Requests, Pydantic, and JSON Schema, teams obtain a contract‑driven testing stack that remains robust amid API evolution, focuses assertions on critical business logic, and dramatically reduces maintenance effort, turning test automation from a cost center into an efficiency engine.

PythonJSON SchemaAPI testingPydanticContract-driven
Test Development Learning Exchange
Written by

Test Development Learning Exchange

Test Development Learning Exchange

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.