โ† All guides

Mock a REST API for Python โ€” requests, httpx and pytest against a real URL

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:

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.

1. Get an API (10 seconds, no signup)

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.

2. requests quickstart โ€” real pagination headers

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.

Using bare 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.

3. Writes are real

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.

4. Sad paths: force any status, hit a real timeout

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

5. Exercise real retry logic with injected chaos

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.

6. A pagination helper you can actually test

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.

7. httpx, async

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())

8. pytest: pin each test to a frozen snapshot

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.

9. Honest comparison

The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:

responses / requests-mockVCR.pypytest-httpserverMockbird
What it isPatches the transport in-processRecords real traffic to cassettes, replaysReal local HTTP server per testHosted mock API
Works offline / zero latencyโœ”โœ” (after recording)โœ”โœ– (real network)
Reachable by teammates, frontend, CI, other servicesโœ– one processโœ– one processSame 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 responsesSimulated at bestโœ– instant replayโœ” with codeโœ” mock_delay/mock_ratelimit
CRUD, filters, pagination, searchYou write every stubOnly what was recordedYou write itBuilt in
State persists across processes/runsโœ–โœ–โœ–โœ” (+ snapshots)
Record real traffic & replayโœ–โœ” its superpowerโœ–Partial: HAR import + proxy record
Free tierfree (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.

10. Ship day

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.

Also mocking for a frontend? The same project feeds React, Vue, Angular, Next.js, React Native, Flutter and Android at once โ€” one dataset for the whole team. Node.js, Go, Java, Ruby, PHP, Rust, Elixir or C#/.NET service in the mix too? Same drill. Docs โ†’
โšก Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.