The frontend is ready, but the backend endpoint it needs won't exist until next sprint. Or your integration tests call a payment provider that rate-limits the sandbox, or that fails just often enough to make CI flaky in ways you can't reproduce. In both cases you want a fake API that behaves like the real thing: it keeps state between calls, returns realistic-looking data, and fails on purpose when you ask it to.
MockForge is a mock API server written in pure Python, standard library only. You describe endpoints in a few lines of Python, and you get a stateful CRUD resource, templated responses with fresh fake data, configurable latency, and a failure rate for chaos testing. Everything below was run locally with the shipped file and the outputs are copied as-is.
Setup
The download ships the tool as main.py. There's a built-in demo you can start right away:
python3 main.py start --port 8080
That serves a demo with customers, orders, an analytics endpoint and a flaky payment endpoint, so you can click around. For your own API you import it. I saved the file as mockforge.py next to my script so the import reads naturally.
Define your fake API
# mock_api.py
from mockforge import MockForge, DataForge
forge = MockForge(port=8089, host="127.0.0.1")
# Stateful CRUD: GET/POST /api/v1/users, GET/PUT/PATCH/DELETE /api/v1/users/{id}
forge.resource("users", base_path="/api/v1/users")
forge.seed("users", 2, lambda: {"name": DataForge.name(), "plan": "free"})
# Templated endpoint: fresh synthetic values on every call
forge.mock("/api/v1/users/{user_id}/invoices/latest", response={
"invoice_id": "{{uuid}}",
"customer": "{{name}}",
"amount": "{{currency}}",
"issued_at": "{{iso_date}}",
}, latency_ms=(5, 20))
# Flaky third-party dependency: roughly 1 in 3 calls returns HTTP 500
forge.mock("/api/v1/payments", method="POST", response={"status": "succeeded", "id": "{{uuid}}"},
latency_ms=(100, 300), failure_rate=0.33)
forge.serve()
python3 mock_api.py
A CRUD resource that remembers
One resource() call registers six routes, backed by an in-memory store. The two seeded users are already there:
curl -s localhost:8089/api/v1/users
{
"data": [
{"name": "Morgan Nakamura", "plan": "free", "id": "59d4dfa6-8cc6-4199-a900-4aa73d0028b4", "_created_at": "2026-10-19T23:50:50.512371+00:00"},
{"name": "Elena Patel", "plan": "free", "id": "25413ad9-737c-4889-8739-7a3811f621c7", "_created_at": "2026-10-19T23:50:50.512424+00:00"}
],
"total": 2
}
Create, update, read, delete. If the body includes an id, it's used; otherwise a UUID is generated:
curl -s -X POST localhost:8089/api/v1/users -H 'Content-Type: application/json' \
-d '{"id":"u_100","name":"Ada Park","plan":"pro"}'
{"id": "u_100", "name": "Ada Park", "plan": "pro", "_created_at": "2026-10-19T23:50:51.995803+00:00"}
curl -s -X PATCH localhost:8089/api/v1/users/u_100 -d '{"plan":"team"}'
{"id": "u_100", "name": "Ada Park", "plan": "team", "_created_at": "...", "_updated_at": "2026-10-19T23:50:52.056231+00:00"}
curl -s -o /dev/null -w "DELETE %{http_code}\n" -X DELETE localhost:8089/api/v1/users/u_100
DELETE 204
curl -s localhost:8089/api/v1/users/u_100
{"error": "NotFound", "message": "users with id 'u_100' not found."}
That's enough for a frontend to build its full create/edit/delete flow, 404 handling included, before the real backend exists.
Templates for read-only endpoints
Endpoints registered with mock() return the template with the macros filled in again on every call:
curl -s localhost:8089/api/v1/users/u_1/invoices/latest
{
"invoice_id": "2cd0f124-88ef-447b-a7a9-b3c34e7aea7f",
"customer": "Siddharth Al-Mansoor",
"amount": 1256.04,
"issued_at": "2026-10-20T00:55:09.847633+00:00"
}
The available macros are {{uuid}}, {{name}}, {{email}}, {{company}}, {{status}}, {{currency}}, {{iso_date}} and {{int:min:max}}. A value that is exactly one {{currency}} or {{int:min:max}} macro comes back as a JSON number, as with amount above. Inside a longer string it's substituted as text, so "tier-{{int:1:3}}" gave me "tier-1". Macros work at any depth in nested dicts and lists. Path parameters like {user_id} turn into named regex groups, so any value matches.
Failing on purpose
The payments mock has failure_rate=0.33. Nine calls in a row:
for i in $(seq 1 9); do curl -s -o /dev/null -w "%{http_code} " -X POST localhost:8089/api/v1/payments -d '{}'; done
200 200 200 200 200 500 200 500 200
The failures return a JSON body:
{"error": "InjectedChaosFailure", "message": "Chaos fault injection triggered by contract specification.", "timestamp": "..."}
Each call also waits a random 100–300 ms first, so your client's timeouts, retries and loading states get exercised against something that behaves like a slow, flaky third-party API.
Introspection
Unmatched requests return a helpful 404 instead of hanging, and a CRUD route that receives a body that isn't a JSON object answers 400:
{"error": "Contract Unmatched", "message": "No mock or state contract matches GET /api/v1/nope",
"mockforge_hint": "Inspect registered endpoints at /_mockforge/routes"}
/_mockforge/routes lists every registered pattern, /_mockforge/logs shows the last 50 requests with status and latency, and /_mockforge/metrics gives request and failure counts. Responses include permissive CORS headers and OPTIONS is answered, so a browser app on another port can call the mock directly.
How it works
resource() compiles two regexes and registers one route per verb, each tagged with an action:
base = base_path or f"/api/v1/{resource_name}"
list_pat = re.compile(f"^{base}/?$")
item_pat = re.compile(f"^{base}/(?P<id>[^/]+)/?$")
self.routes.append(RouteContract(list_pat, "GET", stateful_resource=resource_name, is_stateful=True, action="list"))
self.routes.append(RouteContract(list_pat, "POST", stateful_resource=resource_name, is_stateful=True, action="create"))
# ... GET / PUT / PATCH / DELETE on item_pat
The handler (a ThreadingMixIn HTTP server) checks routes in registration order and applies the simulated latency. It then rolls against failure_rate and either runs the CRUD action against a lock-protected StateStore or renders the template. Because it's plain Python, you can seed data from a JSON fixture, a loop or a factory function.
Limitations
- State lives in memory. Restart the process and you're back to the seeded data, which is usually what you want for tests.
- Macros embedded in longer strings stay strings, and there's no boolean or date-range macro. For anything more specific, return a seeded record from a stateful resource.
-
export-specprints the OpenAPI outline of the built-in demo, not of your own routes. - Route matching is by path and method only. There's no matching on headers or bodies, and no request validation against a schema.
FAQ
Do I need Flask or FastAPI? No. It's built on http.server and has no dependencies.
Can I run it in CI? Yes. Start your mock_api.py in the background, point the app's base URL at it, and run your tests.
Can I make a single test deterministic? Set failure_rate=0.0 (the default) for the routes the test relies on, and keep a separate flaky route for resilience tests.
MockForge is free and MIT-licensed: get it on GitHub. Details are on the MockForge product page.
Want every PyForge tool? See the All-Access Pass.
Top comments (0)