Python has excellent libraries for faking HTTP inside one process: responses and requests-mock patch the transport, VCR.py replays recorded cassettes, pytest-httpserver spins up a throwaway local server. They're the right tools for fast unit tests โ and this guide's comparison table says so plainly.
But a patched transport can't help when you need a URL:
requests and want a free API students can also write to (JSONPlaceholder fakes its writes โ POST returns an id, then the record isn't there).The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing โ you can paste and run them too.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"py-demo"}'
# โ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}
curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/resources \
-H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
-d '{"name":"users","template":"users","seed":50}'
That's 50 realistic users (names, emails, avatars, cities) with full CRUD, filtering, sorting, search and pagination. The snippets below use the public demo project so they run with zero setup.
import requests
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
r = requests.get(f"{BASE}/products",
params={"page": 1, "limit": 5, "sortBy": "price", "order": "desc"})
r.raise_for_status()
products = r.json()
total = int(r.headers["X-Total-Count"]) # real header, not a fixture
print(f"{total} products total; page of {len(products)}")
Output when we ran it: 30 products total; page of 5. Exact-match filters (?category=tools), range operators (?price_gte=100), substring match (?name_like=river) and full-text ?q= all compose โ see the parameter reference.
urllib? The default Python-urllib/3.x User-Agent is blocked at the CDN edge with a 403 (error 1010) โ that's platform-wide behaviour on workers.dev, not a Mockbird rule. Set any User-Agent header and it works. requests/httpx/aiohttp are unaffected.new = requests.post(f"{BASE}/products", json={"name": "Test Widget", "price": 9.99}).json()
back = requests.get(f"{BASE}/products/{new['id']}").json()
assert back["name"] == "Test Widget" # it persisted
requests.delete(f"{BASE}/products/{new['id']}") # 404s afterwards
POST returns 201 with the stored record; PUT/PATCH/DELETE behave like a real backend. (This is the part JSONPlaceholder, FakeStoreAPI and DummyJSON fake โ details with receipts in their respective guides.) Add ?mock_validate=1 to get real 422s with per-field errors when the body doesn't match the schema.
# any status code on demand
r = requests.get(f"{BASE}/products", params={"mock_status": 503})
assert r.status_code == 503
# a genuinely slow response vs your client timeout
try:
requests.get(f"{BASE}/products", params={"mock_delay": 3000}, timeout=1)
except requests.Timeout:
print("timeout branch actually ran")
That second one matters: an in-process stub returns instantly, so your timeout= handling is never truly exercised. Here the server really holds the response for 3 seconds and requests.Timeout really fires.
urllib3.Retry is subtle (which methods, which statuses, backoff, Retry-After honoring). Point it at an endpoint where ~half the requests fail with a random 5xx/429 and watch it earn its keep:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(
total=5, backoff_factor=0.3,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"],
)))
url = "https://mockbird.mockbird.workers.dev/m/demo/products"
for i in range(5):
r = session.get(url, params={"mock_chaos": 0.5, "limit": 1})
print(i, r.status_code) # all 200 โ the adapter absorbed the injected failures
mock_chaos=0.5 fails that fraction of requests with a random status from {500, 502, 503, 504, 429}; injected failures carry an x-mockbird-chaos: injected header, and chaos-failed writes are not applied โ so it's safe to point retrying write logic at it too. For deterministic 429 testing (fixed window, real Retry-After and x-ratelimit-* headers), use ?mock_ratelimit=N.
def all_records(resource, limit=10):
page, out, total = 1, [], None
while total is None or len(out) < total:
r = requests.get(f"{BASE}/{resource}", params={"page": page, "limit": limit})
r.raise_for_status()
total = int(r.headers["X-Total-Count"])
out += r.json()
page += 1
return out
assert len(all_records("products")) == 30 # 3 real round-trips of 10
There's also opaque cursor pagination (?cursor=) if the API you're mimicking paginates that way.
import asyncio, httpx
async def main():
async with httpx.AsyncClient(base_url="https://mockbird.mockbird.workers.dev/m/demo") as client:
results = await asyncio.gather(
client.get("/products", params={"limit": 3}),
client.get("/orders", params={"limit": 3}),
client.get("/customers", params={"limit": 3}),
)
for r in results:
print(r.request.url.path, r.status_code, len(r.json()))
asyncio.run(main())
Shared mutable test data is how suites go flaky. Mockbird lets you save named snapshots of a project's entire dataset, then answer any GET from a snapshot โ read-only, live data untouched โ by sending one header. Set the scenarios up once:
ADMIN='x-admin-key: YOUR_KEY'
P=https://mockbird.mockbird.workers.dev/api/projects/abc123
curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"baseline"}'
curl -X POST $P/resources/users -H "$ADMIN" -H 'content-type: application/json' -d '{"seed":0}' # wipe
curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"empty"}'
curl -X POST $P/snapshots/baseline/restore -H "$ADMIN" # put live data back
Then each test pins the scenario it wants. No beforeEach resets, no ordering constraints, and parallel workers (pytest-xdist) can't race each other because nobody mutates anything:
import pytest, requests
BASE = "https://mockbird.mockbird.workers.dev/m/abc123"
@pytest.fixture
def api(request):
snapshot = getattr(request, "param", "baseline")
s = requests.Session()
s.headers["X-Mockbird-Snapshot"] = snapshot # reads come from the frozen dataset
return s
def test_user_list(api):
assert len(api.get(f"{BASE}/users").json()) == 5 # baseline: always 5
@pytest.mark.parametrize("api", ["empty"], indirect=True)
def test_empty_state(api):
r = api.get(f"{BASE}/users")
assert r.json() == []
assert r.headers["X-Total-Count"] == "0"
We ran exactly this file before publishing: 2 passed in 0.51s. Want to try pinning with zero setup? The public demo ships with built-in empty and edge-cases snapshots (100-char names, unicode, price 0, HTML-ish strings):
requests.get("https://mockbird.mockbird.workers.dev/m/demo/products",
params={"mock_snapshot": "empty"}).json() # โ []
The same trick scales per-scenario โ edge-cases, bug-repro-1234 โ see the deterministic test data guide.
The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:
| responses / requests-mock | VCR.py | pytest-httpserver | Mockbird | |
|---|---|---|---|---|
| What it is | Patches the transport in-process | Records real traffic to cassettes, replays | Real local HTTP server per test | Hosted mock API |
| Works offline / zero latency | โ | โ (after recording) | โ | โ (real network) |
| Reachable by teammates, frontend, CI, other services | โ one process | โ one process | Same machine only | โ one https URL |
| Exists before the real API does | โ (hand-written stubs) | โ (needs traffic to record) | โ (hand-written) | โ (seeded or imported) |
| Real timeouts / retry-after / slow responses | Simulated at best | โ instant replay | โ with code | โ mock_delay/mock_ratelimit |
| CRUD, filters, pagination, search | You write every stub | Only what was recorded | You write it | Built in |
| State persists across processes/runs | โ | โ | โ | โ (+ snapshots) |
| Record real traffic & replay | โ | โ its superpower | โ | Partial: HAR import + proxy record |
| Free tier | free (library) | free (library) | free (library) | free: 20 projects, 10k req/project/day |
Use both: keep responses for millisecond-fast unit tests, and point integration tests, notebooks, teaching materials and not-yet-built-backend work at a hosted URL.
Mockbird follows plain REST conventions, so switching to the real backend is one base-URL change (BASE = os.environ["API_URL"]). Hand your backend team the live contract while they build it: https://mockbird.mockbird.workers.dev/m/abc123/openapi.json, a Postman collection, or generated TypeScript types for the frontend half.