[Python] How can I test entity relationships in schemathesis?

Discussão em 'Python' iniciado por Stack, Setembro 15, 2025.

  1. Stack

    Stack Membro Participativo

    TL;DR: How do I give FastAPI and OpenAPI the information about Relationships between Entities?

    --------------------------Kontext--------------------------

    So I recently started creating a python Backend Server with FastAPI that I wanted to test, while developing it.

    While trying out various approaches I found schemathesis and it worked really well for the simple stuff.

    But then I wanted it to handle relationships between entities properly. With that I mean stuff like customer -n:m-> addresses and similar.

    But that doesn't seem to work well, because I don't know if and how to generate an openapi doc that schemathesis can use to handle those relationships properly:

    These are the DTOs used to create the schemas for openapi.


    class CustomerCreate(BaseModel):
    username: str
    firstname: str
    lastname: str
    address_id: str
    email: Optional[str] = Field(..., json_schema_extra={
    "pattern": EMAIL_REGEX.pattern, "examples": ["user@example.com"]})
    phone: Optional[str] = ""
    customer_addresses: Optional[List[CustomerAddressCreate]] = None

    @field_validator("email")
    @classmethod
    def email_validators(cls, v):
    v = validate_email(v)
    return v

    class CustomerAddressCreate(BaseModel):
    customer_id: str
    address_id: str
    address_type: str

    class AddressCreate(BaseModel):
    street: str
    city: str
    region: str | None = None
    postal_code: str | None = None
    country: str
    subregion: str | None = None
    building: str | None = None
    locality: str | None = None
    address_line2: str | None = None
    address_line3: str | None = None
    latitude: float | None = None
    longitude: float | None = None



    Obviously these DTOs, don't contain information about Foreign Keys an such, therefore all schemathesis tests fail because they try to generate entities with empty ids and such.

    So the question is:

    --------------------------Question--------------------------

    How do I give FastAPI and OpenAPI the information about Relationships?

    My objective is that schemathesis is getting the information about the relationships and is able to e.g. create a customer, and address and a finally a customer-address based on existing ids of customer and address.

    Bonus points if I don't have to create my own test cases since I am using schemathesis mostly from the cli.

    In my mind having to test entities that have relationships with each other should be a common use-case but I

    ----------------Appendix (info that might help):----------------

    The general Setup is:

    • Python Backend with FastAPI
    • Documentation with in built OpenAPI
    • Database: PostgreSQL
    • Api Tests: Schemathesis using pure CLI
    • no Authentication yet. will be added later.
    • A bit of tests with pytest

    General goal for now: Add a lot more Entities and have schemathesis test the new ones along the way. I don't have the time and resources to test every single endpoint that is supposed to work the same anyways.

    All Endpoints are based on this Generic Entity_API:

    # entity_api.py
    from typing import Generic, Type, TypeVar, Optional
    from fastapi import APIRouter, Depends, Query
    from pydantic import BaseModel
    from sqlalchemy.orm import Session

    from .entity_service import EntityService
    from db.session import SessionLocal

    M = TypeVar("M")
    CreateT = TypeVar("CreateT", bound=BaseModel)
    UpdateT = TypeVar("UpdateT", bound=BaseModel)
    ReadT = TypeVar("ReadT", bound=BaseModel)
    IDT = TypeVar("IDT", int, str)


    def get_db():
    db = SessionLocal()
    try:
    yield db
    finally:
    db.close()


    class EntityAPI(Generic[M, CreateT, UpdateT, ReadT, IDT]):
    # ---- subclass must set these ----
    model: Type[M]
    Create: Type[CreateT]
    Update: Type[UpdateT]
    Read: Type[ReadT]
    ID: Optional[Type[IDT]] = None
    prefix: str = ""
    tags: tuple[str, ...] = ()
    service: EntityService[M, CreateT, UpdateT, ReadT, IDT]

    # ---- optional knobs (subclasses may override) ----
    # Add OpenAPI extras per route (e.g., links under responses)
    # e.g. {201: {"links": {...}}}
    create_responses: Optional[dict] = None
    read_responses: Optional[dict] = None
    search_responses: Optional[dict] = None
    update_responses: Optional[dict] = None
    delete_responses: Optional[dict] = None

    # Control status codes if you want 201 on POST, etc.
    create_status_code: int = 201
    update_status_code: int = 200
    read_status_code: int = 200
    search_status_code: int = 200
    delete_status_code: int = 204

    # Stable operationIds (auto if not overridden)
    op_create: Optional[str] = None
    op_read: Optional[str] = None
    op_search: Optional[str] = None
    op_update: Optional[str] = None
    op_delete: Optional[str] = None

    def __init__(self) -> None:
    if not hasattr(self, "service"):
    self.service = EntityService(
    model=self.model, read_schema=self.Read)

    self.router = APIRouter(prefix=self.prefix, tags=list(self.tags))

    # Bind concrete types so wrappers capture them
    Create = self.Create
    Update = self.Update
    Read = self.Read
    ID = self.ID

    # Derive default operationIds from the prefix if not provided
    base = self.prefix.strip(
    "/").replace("/", "_") or self.model.__name__.lower()
    op_create = self.op_create or f"{base}_create"
    op_read = self.op_read or f"{base}_read"
    op_search = self.op_search or f"{base}_search"
    op_update = self.op_update or f"{base}_update"
    op_delete = self.op_delete or f"{base}_delete"

    # ---- Per-instance wrapper functions ----
    def _route_create(item, db: Session = Depends(get_db)):
    return self.create(item, db)
    _route_create.__annotations__ = {"item": Create, "return": Read}

    def _route_read(item_id, db: Session = Depends(get_db)):
    return self.read(item_id, db)
    _route_read.__annotations__ = {"item_id": ID, "return": Read}

    def _route_search(q: Optional[str] = Query(None), db: Session = Depends(get_db)):
    return self.search(q, db)
    _route_search.__annotations__ = {"return": list[Read]}

    def _route_update(item_id, item, db: Session = Depends(get_db)):
    return self.update(item_id, item, db)
    _route_update.__annotations__ = {
    "item_id": ID, "item": Update, "return": Read}

    def _route_delete(item_id, db: Session = Depends(get_db)):
    return self.delete(item_id, db)

    # ---- Register routes (now with operation_id & responses) ----
    self.router.post(
    "/",
    response_model=Read,
    status_code=self.create_status_code,
    operation_id=op_create,
    responses=self.create_responses or {},
    )(_route_create)

    self.router.get(
    "/{item_id}",
    response_model=Read,
    status_code=self.read_status_code,
    operation_id=op_read,
    responses=self.read_responses or {},
    )(_route_read)

    self.router.get(
    "/",
    response_model=list[Read],
    status_code=self.search_status_code,
    operation_id=op_search,
    responses=self.search_responses or {},
    )(_route_search)

    self.router.put(
    "/{item_id}",
    response_model=Read,
    status_code=self.update_status_code,
    operation_id=op_update,
    responses=self.update_responses or {},
    )(_route_update)

    self.router.delete(
    "/{item_id}",
    status_code=self.delete_status_code,
    operation_id=op_delete,
    responses=self.delete_responses or {},
    )(_route_delete)

    # ---------- default CRUD logic ----------
    def create(self, item: CreateT, db: Session):
    return self.service.create(item, db)

    def read(self, item_id: IDT, db: Session):
    return self.service.read(item_id, db)

    def search(self, q: Optional[str], db: Session):
    return self.service.search(q, db)

    def update(self, item_id: IDT, item: UpdateT, db: Session):
    return self.service.update(item_id, item, db)

    def delete(self, item_id: IDT, db: Session):
    return self.service.delete(item_id, db)


    And a singular Entity can look like this:

    from fastapi import APIRouter
    from entity.entity_api import EntityAPI
    from entity.entity_service import EntityService
    from .customer_model import Customer
    from .customer_dto import CustomerCreate, CustomerRead, CustomerUpdate


    class CustomerAPI(EntityAPI[Customer, CustomerCreate, CustomerUpdate, CustomerRead, str]):
    model = Customer
    Create = CustomerCreate
    Update = CustomerUpdate
    Read = CustomerRead
    op_create = "customers_create"
    ID = str
    prefix = "/customers"
    tags = ("customers",)

    service = EntityService(model=Customer, read_schema=CustomerRead)


    # Export the router for app.py
    router: APIRouter = CustomerAPI().router

    Continue reading...

Compartilhe esta Página