FastMock

Completed

Python 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.

Started
June 2, 2024
Ended
August 11, 2026
Phases
8 of 8 done
Repository
GitHub ↗

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: str field 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

delay always applies first, then one random.random() < fail_rate roll

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

both paths still resolve to a JSONResponse for a declared status code

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

two independent sources of variation

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

both feed the same request/response cycle

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

  • FastAPI

    The framework being mocked; the middleware and decorator hook into a route's own declarations rather than a separate schema.

  • Starlette

    FastMockMiddleware subclasses BaseHTTPMiddleware; route and path-parameter matching now delegate to Starlette's own APIRoute.matches.

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=example reads a single fixed model_config example rather than picking from several.
  • Non-JSON response mocking. get_response always returns a JSONResponse; a route documented with a non-JSON response type has no way to be mocked to that shape.