Skip to main content
API Testing · lesson 7 of 11
API Testing intermediate 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

api-testing python mocking responses pytest

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.

Frequently Asked Questions

What's the difference between mocking with responses and the local test server from an earlier lesson?
The local server is a real HTTP round trip — your code's request encoding, the server's response encoding, and the network layer in between all actually run. responses intercepts at the requests library level and never touches a socket, which is faster and makes states like a raw 500 or malformed JSON trivial to produce, but it only tests your client code's handling of a response, not whether a real server would actually send that response.
Why did the unregistered-mock test raise ConnectionError instead of actually hitting the network?
@responses.activate patches requests globally for the duration of the test and refuses to let any real network call through, matched or not. If a call doesn't match a registered mock, responses raises immediately rather than silently falling through to the internet — which is a safety feature: it's what stops a forgotten mock from turning into a real, slow, external HTTP call in your test suite.
When should I mock instead of using a real (or local) server?
Mock when you're testing your own code's reaction to a response — error handling, retry logic, parsing — especially for states that are hard to trigger for real, like a malformed body or a specific 5xx. Use a real or local server when you're testing the actual contract between two systems, since a mock can't tell you whether the real API would ever send what you told it to.
What does responses.calls give me that a plain mock doesn't?
A recorded history of every request that was actually made while the mock was active — method, URL, headers, and body — so a test can assert on what your code sent, not just what it received back. That's the only way to verify request-building logic (like an auth header or a serialized payload) without a real server to inspect it on the other end.
Can responses simulate an API that returns different things on retry?
Yes — call responses.add() multiple times for the same URL and it serves them in the order they were registered, one per matching call. That's exactly what testing retry-then-succeed logic needs, without writing a stateful fake server for it.