โ† All guides

Mock APIs in Django โ€” responses, mock.patch, and when the mock needs to be a real URL

Honest split first: the responses library is the right default for fast offline unit tests of Django code that calls external APIs through requests. It patches the transport adapter (so even from requests import get is caught), its assertions are good, and it's been battle-tested for a decade. This page is not going to pretend otherwise.

One scoping note before anything else: Django's own test Client() and RequestFactory exercise your views โ€” they have nothing to do with mocking the external APIs your views call. That's the gap the tools below fill.

Everything below was verified the week of writing (Aug 2026: Django 6.0, Python 3.12, responses 0.26, respx 0.23, requests 2.34, httpx 0.28, pytest 9) โ€” every snippet on this page was actually run, in a 14-test suite that passes.

The three boundaries that send people searching

1. responses only patches requests โ€” httpx and urllib sail past it

We registered a mock for a URL and then fetched that same URL three ways:

@responses.activate
def test_boundary():
    responses.get(f"{API}/products/1", json={"id": 1, "name": "FAKE"})

    requests.get(f"{API}/products/1").json()   # {"name": "FAKE"}  โœ… mocked
    httpx.get(f"{API}/products/1").json()      # real record       โŒ real network
    urllib.request.urlopen(...)                # real record       โŒ real network

The httpx and urllib calls returned live data over the network, mid-test โ€” the mock never saw them. Any SDK that ships its own httpx/aiohttp client, any async view using httpx, any legacy urllib call: not covered. (respx is the same story mirrored โ€” we verified it patches httpx only, while requests walks past it. Each mock library covers exactly one client library.)

One honest point in responses' favor: its default for an unmatched requests-lib call is a raised ConnectionError, not silent passthrough โ€” safer than some ecosystems. But it can't raise for clients it never intercepts.

2. mock.patch("requests.get") and the where-to-patch trap

The classic. If a module does from requests import get, it holds its own reference โ€” patching requests.get globally misses it:

# services.py
from requests import get
def fetch_products():
    return get(f"{API}/products", params={"limit": 2}).json()

# test โ€” this "works" silently and hits the REAL network:
with patch("requests.get", return_value=fake):
    fetch_products()          # real records came back (verified)

# you must patch the use site:
with patch("myapp.services.get", return_value=fake):
    fetch_products()          # now it's mocked

We ran it: the first form returned live remote data while the patch sat there unused. (responses does not have this trap โ€” it patches the adapter layer below the import, verified. One more reason it beats hand-rolled mock.patch for HTTP.)

3. The mock never leaves your test process

responses, respx and mock.patch all mutate state inside the Python process running the test. manage.py runserver is a different process. So is a celery worker, a management command in a cron, and โ€” always โ€” the browser: your frontend's own fetch() calls never touch Python at all. Playwright or Selenium driving a real dev server gets nothing from your mock.

We proved it the direct way: a view that calls the external API with requests, served by runserver; the test process activated a responses mock for that exact upstream URL and then hit the view like a browser would:

@responses.activate
def test_runserver_ignores_my_mock():
    responses.get(f"{API}/products/1", json={"name": "FAKE"})
    assert services.price_with_requests()["name"] == "FAKE"   # works HERE

    # โ€ฆbut the same view under `manage.py runserver` (separate process):
    data = json.load(urllib.request.urlopen("http://127.0.0.1:8907/price/"))
    data["name"]    # real upstream record โ€” the mock never existed there (verified)

No amount of fixture engineering fixes any of these three โ€” the mock has to live somewhere every process, every client library and every browser can reach: a URL.

The 60-second version

# one curl, no signup โ€” a whole seeded e-commerce backend
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"shop","preset":"ecommerce"}'
# โ†’ {"id":"abc123","adminKey":"KEY", ...}   โ† save both

curl https://mockbird.mockbird.workers.dev/m/abc123/products?limit=3
# โ†’ 3 seeded products, CORS on, writes persist

Then wire it the way Django already wants external services wired โ€” settings plus an env var:

# settings.py
CATALOG_API_URL = os.environ.get("CATALOG_API_URL", "https://api.yourapp.com")

# services.py
from django.conf import settings
def price(pid):
    return requests.get(f"{settings.CATALOG_API_URL}/products/{pid}", timeout=5).json()
# .env for dev / CI job env
CATALOG_API_URL=https://mockbird.mockbird.workers.dev/m/abc123

Now runserver, the celery worker, the frontend's fetch, the httpx-based SDK, Playwright and your terminal all see the same mock โ€” because it's just a URL. (In tests you can flip it per-case with @override_settings(CATALOG_API_URL=...).) Prefer clicking? This link creates the same project in your browser โ€” no account. Have an OpenAPI spec (DRF's generateschema output works)? Paste it at /app#import.

Error, latency and chaos states your mocks can't exercise honestly

With responses, a "timeout" is body=ConnectTimeout() โ€” it raises instantly. We measured it: 0.8 ms, so the timeout=5 you passed was never exercised; the duration logic in your code never runs. Against a hosted URL, real time passes โ€” all three of these are from the verified suite:

# a real 503, no handler to write
requests.get(f"{API}/products", params={"mock_status": 503})           # โ†’ 503

# a real timeout: response held for 3s, timeout=1 genuinely fires after ~1.07s
requests.get(f"{API}/products", params={"mock_delay": 3000}, timeout=1)
# โ†’ requests.exceptions.ReadTimeout

# retry logic against a genuinely flaky endpoint (urllib3 Retry):
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=Retry(
    total=6, backoff_factor=0.2, status_forcelist=[500, 502, 503, 504, 429])))
s.get(f"{API}/products", params={"mock_chaos": 0.5}).status_code
# โ†’ 200 โ€” five runs in the suite, five 200s after real retries absorbed real 5xxs

More recipes in testing loading & error states.

State: scripted answers vs. an actual store

A responses stub is a canned script. A mocked POST "creates" nothing โ€” the next GET returns whatever you scripted, because there is no store behind it. Against Mockbird the write is real:

r = requests.post(f"{API}/products", json={"name": "Widget", "price": 9.99})
pid = r.json()["id"]                              # 201, real id
requests.get(f"{API}/products/{pid}").json()["name"]   # "Widget" โ€” actually there
requests.delete(f"{API}/products/{pid}")          # cleanup is real too

For deterministic test data across runs (and parallel pytest-xdist workers), save a named snapshot and pin it per-request with the X-Mockbird-Snapshot header โ€” deterministic test data guide.

responses concepts โ†’ Mockbird

Django testing worldMockbirdNotes
responses.get(url, json=...)a resource on a real URLlist/get/create/update/delete generated, plus filtering, sorting, pagination, relations
Hand-rolled fixture dictsseeded realistic recordsfaker-style names/emails/prices/dates; or import your exact records from db.json/CSV/OpenAPI
responses.get(url, status=500)?mock_status=500on any URL, no re-registration
body=ConnectTimeout()?mock_delay=3000 / ?mock_jitterreal time passes โ€” your timeout= genuinely fires (verified)
(no flakiness simulation)?mock_chaos=0.5real 5xx/429s for urllib3 Retry to absorb
Ordered registrationssnapshot pinningnamed data states, safe across parallel workers
responses.calls assertionsrequest inspectorlast 50 requests with method, path, query, headers, body
rsps.add_passthru()n/akeep using it for the hosts you don't mock

Try it in 10 seconds (shared demo, no setup)

curl 'https://mockbird.mockbird.workers.dev/m/demo/products?limit=2&select=name,price'
curl -i 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=503'
curl 'https://mockbird.mockbird.workers.dev/m/demo/products/1?mock_delay=2000'

All against the shared demo project (resets daily).

Or use both โ€” they compose

Honest comparison

responses / respxMockbird
What it ispip-installable Python librariesfree hosted service
Reachable fromone HTTP client library, in the test process onlyanything with HTTP: browser, Playwright, curl, httpx, SDKs, celery workers, mobile, CI, teammates
httpx / urllib / aiohttp under responsesnot intercepted (verified)it's a real URL โ€” every client works by definition
Unmatched requestConnectionError for requests-lib calls; invisible for other clientsn/a โ€” real endpoints answer real queries
Setupregister stubs per testone curl or one click; no code
Stateful CRUDno โ€” scripted answers onlydefault โ€” writes persist
Latency/retry/timeout testinginstant raises โ€” durations never run (measured 0.8 ms)?mock_delay/?mock_jitter/?mock_chaos, real time passes
Works offlineyesno โ€” it's a real network call
Request assertionsresponses.calls, precise, in-testrequest inspector (last 50, headers/body)
Request capnone10,000/project/day

Written by the Mockbird maker โ€” bias disclosed. Where responses genuinely wins: it runs offline at zero latency, catches the where-to-patch trap that raw mock.patch falls into, fails loudly on unmatched requests-lib calls, and responses.calls assertions are more precise than any log-based check. For fast unit tests of requests-based code it should stay your default. When the thing you need is a URL โ€” for runserver, a celery worker, an httpx SDK, a frontend fetch, Playwright, a teammate, or CI against a deployed preview โ€” that's us.

Full API reference in the docs. More guides: mock API for Python ยท mock APIs in FastAPI ยท mock APIs in Playwright ยท deterministic test data ยท testing loading & error states ยท free mock API tools compared. Create your API โ†’