FastMock
CompletedPython middleware that mocks FastAPI endpoints from their own declared response models, with deterministic seeded responses, field-name-aware data, fault injection, and FastAPI-shaped request validation. Published on PyPI.
Highlights
- Published on PyPIA FastAPI middleware that mocks each route from the response model it already declares.
- 100% line coverageAsync pytest suite, with CI across Python 3.10-3.13.
- FastAPI's own 422Request validation reuses FastAPI's argument resolution for identical errors, without running Depends(...).
- Deterministic mocksThe same request returns the same payload across refreshes, restarts and machines.
Overview
A PyPI-published FastAPI middleware that mocks a route’s response from the model that route already declares:
@app.get("/items/{item_id}", responses={200: {"model": Item}})
async def get_item(item_id: int) -> Item: ...
Add the middleware, and that endpoint returns a polyfactory-generated Item
instead of a hand-written stub, so the mocked shape can’t drift from the real
one: there is only one model to begin with. Individual routes opt into
different behaviour with a @mock(...) decorator.
That response is also a pure function of the request. The same call returns the same plausible, field-name-aware data every time, while fault injection stays genuinely random on top of it.
Problem
- Hand-written stubs drift from the real model. A stub for a not-yet-implemented endpoint is repetitive to write and easy to leave out of sync the moment the response model changes.
- A mock still needs to reject a malformed request, without a real
implementation to reject it, and without the eventual implementation’s
Depends(...)(auth, DB session) having to exist or run just to validate. - Random output makes a mock unusable for demos and integration tests. Two identical requests returning different data means a demo jumps around on refresh and a test can’t assert against a literal payload.
- A field’s type alone doesn’t say what it means. A
country: strfield generated an arbitrary 20-character string, correct by type and useless at a glance, and some invariants, like an order total matching its line items, aren’t expressible as a type at all.
Approach
Every feature reuses FastAPI’s, Pydantic’s, or polyfactory’s own machinery
instead of reimplementing it: request validation reuses FastAPI’s own
argument-resolution functions for byte-identical 422 shapes, seeding
reuses polyfactory’s own seed_random rather than a bespoke RNG, and
route matching delegates to Starlette’s real APIRoute.matches instead of
an approximation.
Configuration follows one consistent rule as the surface has grown:
MockData holds per-route and per-request knobs (element_size, delay,
seed, factory), and the FastMockMiddleware constructor holds global
policy (retrieve_data_function_list, provider_map). Every scalar on
MockData is therefore overridable per-request through an X-FASTMOCK-*
header for free; the two things that can’t travel that way, factory and
provider_map, are exactly the two non-scalar options, which is why both
ended up on a different layer rather than by accident.
What I built
Core mockingDoneAny route with a declared response model returns a generated instance of it, with no stub bodies to maintain.
GoalReturn a mock built from what a route already declares, so the mocked shape can't drift from the real one.
FastMockDecorator stores a MockData instance as an attribute on the
decorated route function. FastMockMiddleware.dispatch matches the incoming
request to a FastAPI APIRoute (middleware runs before Starlette’s router
has resolved anything), merges MockData from four sources in precedence
order, and hands the result to get_response, which looks up the declared
model for whichever status code applies and generates it via polyfactory.
Data source
- Chose
- The route's own declared responses={} model
- Instead of
- A separately maintained mock schema or fixture file
- Why
- The mocked shape can't drift from the real declared model, since there's only one model to begin with.
Override mechanism
- Chose
- A layered precedence chain (middleware default → decorator init → per-route decorator → request header)
- Instead of
- One global config
- Why
- Every MockData field becomes overridable per-route and per-request for free, since each layer is generic over the same model rather than a bespoke per-feature mechanism.
Route matching
- Chose
- Delegate to Starlette's own APIRoute.matches
- Instead of
- A hand-rolled regex over the route's path template
- Why
- The regex could not express path converters such as {item_id:int} or {file_path:path}; both mismatched under the earlier implementation.
Modernize toolingDonePackaging on hatchling + uv, CI across Python 3.10-3.13, and the last Pydantic v1 calls gone.
GoalPick a two-year-dormant codebase back up and get it onto current packaging and a current Pydantic API.
setup.py + requirements*.txt gave way to a hatchling pyproject.toml
and uv.lock with test/lint/docs/dev dependency groups,
fastmock/py.typed was added for PEP 561, and CI now runs the test matrix
across Python 3.10-3.13. The OpenAPI test was rewritten to assert only the
paths, status codes, and component schemas the middleware is responsible
for, rather than snapshotting the entire /openapi.json body; the old
version broke on any FastAPI patch release that reformatted schema output.
Fault injectionDonedelay and fail_rate simulate a slow or unreliable upstream, settable per route or per request.
GoalLet a client's failure handling be exercised without standing up a real flaky upstream.
delay and fail_rate/fail_status_code on MockData simulate a slow or
unreliable upstream without writing any extra code. get_response became
async to support delay via asyncio.sleep; fail_status_code must be one
of the route’s declared responses. Both fields were reachable via
decorator, middleware default, and the X-FASTMOCK-* header the moment they
were added to MockData, since that path is generic over whatever fields
the model has.
Merged MockData for the request
delay and fail_rate resolved through the same override chain as every other field
Roll succeeds (probability 1 − fail_rate)
falls through to the normal status-code/model lookup
Roll fails (probability fail_rate)
short-circuits to fail_status_code, which must be a declared response for the route
JSONResponse
either the generated model, or the fail_status_code payload
Request validationDoneA malformed request gets FastAPI's own 422, without ever resolving the route's Depends(...).
GoalReject a malformed request the way the real endpoint would, before any real implementation exists to reject it.
validate_request (default True) checks a request’s path, query, header,
cookie, and body parameters against the endpoint’s declared types before
generating a response. It reuses FastAPI’s own
request_params_to_args/request_body_to_args rather than reimplementing
type coercion, but deliberately never calls solve_dependencies. A mocked
route has no real implementation to protect, so running its Depends(...)
just to validate a request nobody will actually serve would defeat the point.
Type coercion
- Chose
- Reuse FastAPI's own request_params_to_args / request_body_to_args
- Instead of
- Hand-rolled parameter and body type checking
- Why
- Stays correct as FastAPI's own validation logic evolves, and produces byte-identical 422 error shapes without a second implementation to keep in sync.
Validation order
- Chose
- After delay, before the fail_rate roll
- Instead of
- Before delay, or after the fail_rate roll
- Why
- A malformed request always gets a deterministic 422, not a simulated random failure, while delay still applies either way.
Deterministic responsesDoneThe same request returns the same payload across refreshes, restarts and machines.
GoalMake a response a pure function of the request, so a demo doesn't jump on refresh and a test can assert a literal payload.
fastmock/seeding.py derives a seed from the configured base MockData.seed,
the request method, path, sorted query string, and a canonicalised body (JSON
re-serialised with sorted keys, only the first 64KB hashed), then reseeds
polyfactory immediately before generation with no await in between, since
the seeded state is shared process-wide. Canonicalising rather than hashing
raw bytes means two clients sending the same logical payload with different
key ordering get the same response. seed=1 is the default, a deliberate
breaking change made on the grounds that most callers want stable demos more
than request-to-request variety, and variety is still one field away.
Incoming request
method + path + sorted query string + canonicalised body
Response payload, seeded
blake2b hash of the request, mixed with MockData.seed, reseeds polyfactory + Faker right before generation: deterministic
Fault injection, unseeded
fail_rate calls random.random() directly; polyfactory's seed_random never touches the stdlib random module, so failures stay genuinely random
JSONResponse
deterministic payload, non-deterministic outcome
Plausible generationDoneA field named country returns a country, not a 20-character random string.
GoalMake generated data readable at a glance, not merely correct by type.
Factory selection moved out of scanning BaseFactory.__subclasses__() into
an explicit, ordered DEFAULT_BASE_FACTORIES registry. A new
DEFAULT_PROVIDER_MAP then teaches those factories to look at a field’s
name, not just its type, via a NameAwareFactoryMixin. Deliberately absent
from the default map: a bare name, too ambiguous to guess, since a
product, a bank, and a person are all equally plausible, so it is left to
polyfactory itself.
Factory selection
- Chose
- An explicit ordered registry in fastmock/factories.py
- Instead of
- Scanning BaseFactory.__subclasses__() at call time
- Why
- __subclasses__() only reports direct subclasses and is populated by import side effects, so selection order depended on what happened to already be imported, and any factory subclassing ModelFactory rather than BaseFactory directly was never found.
Name-based inference
- Chose
- A small, exact-match field-name → Faker provider map, gated on an exact type match
- Instead of
- Guessing from a substring or suffix, or trusting a name match unconditionally
- Why
- A plausible-looking but wrong value is worse than obvious gibberish: gibberish tells you to reach for a custom factory, a wrong value silently misleads. The exact-type gate keeps a city field typed int an int.
Custom factory escape hatchDoneCallers supply their own factory for invariants no schema can express, like a total matching its line items.
GoalGive callers a way to express invariants that live in their domain rather than in the schema.
Schema-driven generation produces fields that are individually plausible but
unrelated: an order total that doesn’t match its line_items, a shipped
order with no tracking_number. Those invariants live in the caller’s
domain, so fastmock doesn’t guess at them. MockData.factory accepts a
caller-supplied polyfactory factory that builds the response instead,
applied per element for list responses.
Precedence
- Chose
- factory overrides type whenever both are set
- Instead of
- Treating the two as independent or mutually exclusive
- Why
- A caller who supplies a factory has already opted out of schema-driven generation for that route; type would otherwise be ambiguous about which generation path wins.
Configuration channel
- Chose
- A MockData field that cannot travel over an X-FASTMOCK-* header
- Instead of
- Trying to make it header-settable like every other MockData field
- Why
- It's a callable, and there's no sane string encoding for a polyfactory factory in a header value, the same non-scalar rule provider_map already followed on the middleware constructor.
Worked example & roadmapDoneThree permanent scope boundaries stated, and a storefront example whose every quoted figure is test-asserted.
GoalState what fastmock is for and what it will never do, and prove the docs match the library.
documentation/docs/roadmap.md states fastmock’s positioning as a
realistic fake backend for QA, staging, demos, and integration tests,
plus three permanent scope boundaries. examples/storefront.py and its
walkthrough exercise every feature together against one realistic API:
name- and type-inferred customer data, seed-stable pagination, an
OrderFactory encoding the total/tracking-number invariants, a custom sku
provider, and the full header-override table.
tests/test_example_storefront.py asserts the exact figures the walkthrough
quotes, so the documentation cannot silently drift from the library.
Cross-field coherence
- Chose
- A per-route factory= escape hatch
- Instead of
- Guessing relationships between fields (e.g. total = sum(line_items))
- Why
- Those invariants live in the caller's domain. A guessed one would be right for some callers and silently wrong for others.
Statefulness
- Chose
- No store, ever; responses vary with the request but never echo it
- Instead of
- Making a POST followed by a GET return what was posted
- Why
- Keeps fastmock a realistic fake backend for QA/staging/demos/integration tests, not an attempt at a full fake server with persistence.
Positioning vs. the placeholder use case
- Chose
- Explicitly defer to fastapi-mock's raise NotImplementedError() for that case
- Instead of
- Trying to be the lowest-friction option for every use case
- Why
- fastmock trades setup simplicity for per-route/per-request control over shape, status, failure, and reproducibility: a different point on the trade-off curve, not a strictly better one.
Tech stack
What each piece of the stack is actually doing, and where to look for more:
Languages
- Python
Package language, tested across 3.10-3.13.
Frameworks
Libraries
- Pydantic
MockData is itself a Pydantic model, and the response models being mocked are the caller's own Pydantic models.
- polyfactory
Generates fake instances of a route's declared response model; seeded per request and mixed with name-aware and stable-temporal factory variants.
- Faker
Backs polyfactory's own generation and the field-name-aware provider map (country, email, city, and similar).
Tooling
- pytest
Async test suite (pytest-asyncio) at 100% line coverage, parametrized across decorator/middleware/header/seeding/provider-map test clients.
- uv
Dependency management, dependency groups (including an examples group for uvicorn), and the one-click release GitHub Action.
- MkDocs
mkdocs-material docs site (quickstart, mock-data, middleware, roadmap, worked example), deployed on tag via GitHub Actions.
Future improvements
FastMock is stable and not under active development, so this is a list of
known gaps rather than a roadmap. The repo’s own roadmap.md separates
permanent non-goals from genuine gaps; two of the latter remain:
- Multiple examples, randomly chosen per request. The one row FastMock’s
own comparison table concedes to
fastapi-mock:type=examplereads a single fixedmodel_configexample rather than picking from several. - Non-JSON response mocking.
get_responsealways returns aJSONResponse; a route documented with a non-JSON response type has no way to be mocked to that shape.