responses, mock.patch, and when the mock needs to be a real URLHonest 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.
responses only patches requests โ httpx and urllib sail past itWe 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.
mock.patch("requests.get") and the where-to-patch trapThe 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.)
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.
# 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.
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.
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 world | Mockbird | Notes |
|---|---|---|
responses.get(url, json=...) | a resource on a real URL | list/get/create/update/delete generated, plus filtering, sorting, pagination, relations |
| Hand-rolled fixture dicts | seeded realistic records | faker-style names/emails/prices/dates; or import your exact records from db.json/CSV/OpenAPI |
responses.get(url, status=500) | ?mock_status=500 | on any URL, no re-registration |
body=ConnectTimeout() | ?mock_delay=3000 / ?mock_jitter | real time passes โ your timeout= genuinely fires (verified) |
| (no flakiness simulation) | ?mock_chaos=0.5 | real 5xx/429s for urllib3 Retry to absorb |
| Ordered registrations | snapshot pinning | named data states, safe across parallel workers |
responses.calls assertions | request inspector | last 50 requests with method, path, query, headers, body |
rsps.add_passthru() | n/a | keep using it for the hosts you don't mock |
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).
responses for millisecond unit tests of requests-based code. Point runserver, celery workers, frontend fetches, httpx SDK configs and Playwright runs at a Mockbird base URL via settings. App code doesn't change.responses and add_passthru() the hosted mock for everything else.GET /m/<project>/db.json ejects your entire dataset any time; openapi.json and postman.json are generated from your live schema.responses / respx | Mockbird | |
|---|---|---|
| What it is | pip-installable Python libraries | free hosted service |
| Reachable from | one HTTP client library, in the test process only | anything with HTTP: browser, Playwright, curl, httpx, SDKs, celery workers, mobile, CI, teammates |
httpx / urllib / aiohttp under responses | not intercepted (verified) | it's a real URL โ every client works by definition |
| Unmatched request | ConnectionError for requests-lib calls; invisible for other clients | n/a โ real endpoints answer real queries |
| Setup | register stubs per test | one curl or one click; no code |
| Stateful CRUD | no โ scripted answers only | default โ writes persist |
| Latency/retry/timeout testing | instant raises โ durations never run (measured 0.8 ms) | ?mock_delay/?mock_jitter/?mock_chaos, real time passes |
| Works offline | yes | no โ it's a real network call |
| Request assertions | responses.calls, precise, in-test | request inspector (last 50, headers/body) |
| Request cap | none | 10,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 โ