API Testing · lesson 7 of 11
Mocking HTTP Calls with responses
Test the client code that calls an API without a real server anywhere — and hit the error responses throws when a test forgets to register the exact call it makes.
By SkillByExample · Published
Sometimes the thing under test isn’t the API — it’s the client code that calls it. Testing that in isolation means controlling the response without a server at all.
The client under test
# client.py
import requests
class BookNotFoundError(Exception):
pass
def get_book_title(base_url, book_id):
response = requests.get(f"{base_url}/books/{book_id}")
if response.status_code == 404:
raise BookNotFoundError(f"no book with id {book_id}")
response.raise_for_status()
return response.json()["title"]
Mocking with responses
pip install responses
import pytest
import responses
from client import BookNotFoundError, get_book_title
@responses.activate
def test_get_book_title_returns_the_title():
responses.add(
responses.GET,
"https://api.example.com/books/1",
json={"id": 1, "title": "Dune"},
status=200,
)
title = get_book_title("https://api.example.com", 1)
assert title == "Dune"
@responses.activate
def test_get_book_title_raises_on_404():
responses.add(
responses.GET,
"https://api.example.com/books/999",
json={"error": "not found"},
status=404,
)
with pytest.raises(BookNotFoundError, match="999"):
get_book_title("https://api.example.com", 999)
pytest test_mocking.py -v
test_mocking.py::test_get_book_title_returns_the_title PASSED [ 50%]
test_mocking.py::test_get_book_title_raises_on_404 PASSED [100%]
============================== 2 passed in 0.39s ==============================
No server, real or local, is running — @responses.activate intercepts every call requests
makes for the duration of the test and returns whatever was registered with responses.add().
https://api.example.com doesn’t need to resolve to anything.
The bug: forgetting to register the call
@responses.activate
def test_get_book_title_without_registering_mock():
title = get_book_title("https://api.example.com", 1)
assert title == "Dune"
@responses.activate
def test_get_book_title_without_registering_mock():
> title = get_book_title("https://api.example.com", 1)
client.py:9: in get_book_title
response = requests.get(f"{base_url}/books/{book_id}")
...
responses/__init__.py:1140: in _on_request
raise response
E requests.exceptions.ConnectionError: Connection refused by Responses - the call doesn't
match any registered mock.
E
E Request:
E - GET https://api.example.com/books/1
E
E Available matches:
responses doesn’t fall through to a real network call when nothing matches — it raises
immediately. That’s deliberate: without it, a forgotten mock would silently turn into a real,
slow, external HTTP request buried inside a test suite that’s supposed to run offline. “no
registered mock” is a clear, fast failure instead.
Simulating server errors is easy — because there’s no server
@responses.activate
def test_get_book_title_raises_on_server_error():
responses.add(responses.GET, "https://api.example.com/books/1", status=500)
with pytest.raises(Exception):
get_book_title("https://api.example.com", 1)
test_mocking.py::test_get_book_title_raises_on_server_error PASSED [100%]
Getting a real API to reliably return a 500 on demand is often hard or impossible from the
outside. responses.add(..., status=500) produces it in one line — this is the case mocking
is genuinely better for than a real or local server.
Practice
1. Mock a body that isn't valid JSON and confirm the client's parsing fails loudly.
import json
@responses.activate
def test_malformed_json_body_raises_json_decode_error():
responses.add(
responses.GET,
"https://api.example.com/books/1",
body="not valid json",
status=200,
content_type="application/json",
)
with pytest.raises(json.JSONDecodeError):
get_book_title("https://api.example.com", 1)
test_practice.py::test_malformed_json_body_raises_json_decode_error PASSED [ 33%]
body= sends raw text instead of json=’s auto-serialized dict — this is how a mock can
produce an API that claims to be JSON and isn’t, which is exactly the kind of malformed
response a client’s error handling should be tested against.
2. Assert on responses.calls to confirm what your client actually sent.
@responses.activate
def test_request_history_records_the_call_that_was_made():
responses.add(
responses.GET,
"https://api.example.com/books/1",
json={"id": 1, "title": "Dune"},
status=200,
)
get_book_title("https://api.example.com", 1)
assert len(responses.calls) == 1
assert responses.calls[0].request.url == "https://api.example.com/books/1"
test_practice.py::test_request_history_records_the_call_that_was_made PASSED [ 66%]
responses.calls is the mock’s own request log — the only way to check what your code sent
when there’s no real server on the other end to inspect it.
3. Register two responses for the same URL and confirm they're served in order.
@responses.activate
def test_sequential_responses_are_consumed_in_order():
responses.add(responses.GET, "https://api.example.com/books/1", status=500)
responses.add(responses.GET, "https://api.example.com/books/1", json={"id": 1, "title": "Dune"}, status=200)
with pytest.raises(requests.HTTPError):
get_book_title("https://api.example.com", 1)
assert get_book_title("https://api.example.com", 1) == "Dune"
test_practice.py::test_sequential_responses_are_consumed_in_order PASSED [100%]
Each matching call consumes the next registered response in order — the tool for testing retry-then-succeed logic without writing a stateful fake server just to get one flaky response.
Next: timeouts, retries, and testing an endpoint that’s flaky on purpose.