Unit Testing Python HTTP Clients with unittest.mock

Python HTTP client request intercepted by a mock layer before reaching an external API

What You’ll Learn

In this lesson, you’ll learn how to use Python’s unittest.mock tools to test code that calls external HTTP APIs without making real network requests.

  • Replace an HTTP request with a mock object.
  • Configure simulated response data and exceptions.
  • Verify the URL, parameters, and timeout passed to an API client.
  • Use side_effect to simulate failures or multiple responses.
  • Patch an object where your application looks it up.
Ad

The Concept

Unit tests should usually test one unit of application code in isolation. If that code calls an external API, making a real request during every test creates several problems:

  • Tests become slower.
  • They depend on network access.
  • An external service can be unavailable or return different data.
  • Tests might accidentally modify real data or consume API quota.

The unittest.mock module in Python’s standard library lets you replace a real object with a configurable mock. A mock can return a prepared response, raise an exception, record how it was called, and provide different results for successive calls.

The most common tools are:

  • Mock and MagicMock for creating fake objects.
  • patch for temporarily replacing an object during a test.
  • call and assertion methods such as assert_called_once_with for checking interactions.
  • side_effect for raising an exception or returning different values across calls.

A particularly important rule is to patch the name where the code under test looks it up. If api_client.py imports requests and calls requests.get, patch api_client.requests.get, not an unrelated name in your test file.

Basic Example

Suppose an application retrieves a user profile from an external service. The production code uses the third-party requests package, but the test replaces requests.get with a mock.

api_client.py

import requests


class ApiError(Exception):
    pass


def fetch_profile(user_id):
    url = f"https://profiles.example.test/users/{user_id}"

    try:
        response = requests.get(url, timeout=5)
        response.raise_for_status()
    except requests.RequestException as exc:
        raise ApiError("Profile service request failed") from exc

    profile = response.json()
    return {
        "id": profile["id"],
        "name": profile["name"],
    }

test_api_client.py

import unittest
from unittest.mock import Mock, patch

from api_client import ApiError, fetch_profile


class FetchProfileTests(unittest.TestCase):
    @patch("api_client.requests.get")
    def test_returns_selected_profile_fields(self, mock_get):
        mock_response = Mock()
        mock_response.json.return_value = {
            "id": 42,
            "name": "Amina Patel",
            "internal_note": "not needed by this function",
        }
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response

        result = fetch_profile(42)

        self.assertEqual(result, {"id": 42, "name": "Amina Patel"})
        mock_get.assert_called_once_with(
            "https://profiles.example.test/users/42",
            timeout=5,
        )
        mock_response.raise_for_status.assert_called_once_with()
        mock_response.json.assert_called_once_with()

    @patch("api_client.requests.get")
    def test_translates_request_failure(self, mock_get):
        import requests

        mock_get.side_effect = requests.Timeout("service timed out")

        with self.assertRaises(ApiError):
            fetch_profile(42)


if __name__ == "__main__":
    unittest.main()

Run the test module from the directory containing both files:

python -m unittest test_api_client.py

Expected Output

..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

How the Code Works

A unit test patches the HTTP lookup used by the API client, configures a mock response, invokes the client without contacting the network, and verifies the result and request details. A failure path uses side_effect to simulate an exception that becomes an application error.
The test replaces the API client's HTTP request, supplies controlled responses or failures, then verifies both returned data and important request interactions.

@patch("api_client.requests.get") temporarily replaces the function used by fetch_profile. The real network function is restored automatically after the test finishes, even if the test fails.

mock_response = Mock() creates a fake response object. Its attributes can be configured as needed:

  • mock_response.json.return_value controls what response.json() returns.
  • mock_response.raise_for_status.return_value controls the behavior of response.raise_for_status().
  • mock_get.return_value makes requests.get() return that fake response.

The first test checks both the returned data and the interaction with the API. The assertion on mock_get confirms that the code used the expected URL and a five-second timeout. This is useful because a test should verify important request behavior, not only the final result.

In the second test, mock_get.side_effect is set to a requests.Timeout exception. Calling fetch_profile then raises that exception as if the external service had timed out. The production function catches the library-specific exception and translates it into the application’s ApiError.

Notice that the patch target starts with api_client. That is where the production module looks up requests.get. Patching only a name in test_api_client.py would not replace the function used by fetch_profile.

Another Example

Mocks can also provide a sequence of responses. The following client follows a pagination link until the incident service has no more pages. The test uses side_effect with a list, so each call receives a different fake response.

incident_client.py

import requests


def list_incidents():
    next_url = "https://status.example.test/api/incidents"
    incidents = []

    while next_url is not None:
        response = requests.get(next_url, timeout=3)
        response.raise_for_status()
        page = response.json()

        incidents.extend(page["incidents"])
        next_url = page["next"]

    return incidents

test_incident_client.py

import unittest
from unittest.mock import Mock, call, patch

from incident_client import list_incidents


class ListIncidentsTests(unittest.TestCase):
    @patch("incident_client.requests.get")
    def test_combines_all_pages(self, mock_get):
        first_response = Mock()
        first_response.json.return_value = {
            "incidents": [
                {"id": "inc-101", "summary": "Database latency"}
            ],
            "next": "https://status.example.test/api/incidents?page=2",
        }

        second_response = Mock()
        second_response.json.return_value = {
            "incidents": [
                {"id": "inc-102", "summary": "Queue backlog"}
            ],
            "next": None,
        }

        mock_get.side_effect = [first_response, second_response]

        result = list_incidents()

        self.assertEqual(
            result,
            [
                {"id": "inc-101", "summary": "Database latency"},
                {"id": "inc-102", "summary": "Queue backlog"},
            ],
        )
        self.assertEqual(
            mock_get.call_args_list,
            [
                call(
                    "https://status.example.test/api/incidents",
                    timeout=3,
                ),
                call(
                    "https://status.example.test/api/incidents?page=2",
                    timeout=3,
                ),
            ],
        )


if __name__ == "__main__":
    unittest.main()

This test is different from the profile example because it verifies a sequence of HTTP calls rather than a single call. It also checks that the client follows the URL returned by the first response instead of constructing the second URL incorrectly.

Common Mistakes

Patching the wrong location

Patch the name used by the module under test. For example, if incident_client.py calls requests.get, use @patch("incident_client.requests.get"). The module prefix is part of the target.

Forgetting to configure the mock response

A bare mock returns more mocks when its methods are called. If your code expects JSON data, configure mock_response.json.return_value. If it calls raise_for_status(), configure or verify that method too.

Testing only the returned value

A function could return the expected value while using the wrong URL, omitting authentication, or failing to set a timeout. Use assert_called_once_with or inspect call_args_list when request details are part of the function’s contract.

Over-specifying implementation details

Mock assertions should focus on behavior that matters. If the order of two internal helper calls is not important, avoid asserting every internal operation. Excessive assertions can make tests difficult to maintain during harmless refactoring.

Confusing a mock with an integration test

A mocked test verifies your code’s behavior around an API. It does not prove that the real API is available, that its authentication works, or that its response format has not changed. Those concerns belong in a small number of separately managed integration or contract tests.

Try It Yourself

Add a test for fetch_profile that verifies an HTTP error is translated into ApiError. Configure the fake response so that raise_for_status raises requests.HTTPError, then assert that fetch_profile(42) raises ApiError.

As an extra check, verify that the mock request still received the expected profile URL and timeout=5.

Challenge

Create a function named get_order_summary(order_id) in a module named order_client.py. It should:

  • Request https://orders.example.test/api/orders/{order_id} with timeout=4.
  • Call raise_for_status().
  • Read the JSON response.
  • Return a dictionary containing only id, status, and total.
  • Translate any requests.RequestException into an OrderServiceError.

Then write a unittest that mocks the request, checks the returned summary, and verifies the URL and timeout. Write a second test that simulates a connection error and checks the custom exception.

Solution

order_client.py

import requests


class OrderServiceError(Exception):
    pass


def get_order_summary(order_id):
    url = f"https://orders.example.test/api/orders/{order_id}"

    try:
        response = requests.get(url, timeout=4)
        response.raise_for_status()
    except requests.RequestException as exc:
        raise OrderServiceError("Order service request failed") from exc

    order = response.json()
    return {
        "id": order["id"],
        "status": order["status"],
        "total": order["total"],
    }

test_order_client.py

import unittest
from unittest.mock import Mock, patch

import requests

from order_client import OrderServiceError, get_order_summary


class GetOrderSummaryTests(unittest.TestCase):
    @patch("order_client.requests.get")
    def test_returns_order_summary(self, mock_get):
        mock_response = Mock()
        mock_response.json.return_value = {
            "id": "order-7001",
            "status": "paid",
            "total": 149.95,
            "customer_email": "customer@example.test",
        }
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response

        result = get_order_summary("order-7001")

        self.assertEqual(
            result,
            {
                "id": "order-7001",
                "status": "paid",
                "total": 149.95,
            },
        )
        mock_get.assert_called_once_with(
            "https://orders.example.test/api/orders/order-7001",
            timeout=4,
        )
        mock_response.raise_for_status.assert_called_once_with()
        mock_response.json.assert_called_once_with()

    @patch("order_client.requests.get")
    def test_translates_connection_error(self, mock_get):
        mock_get.side_effect = requests.ConnectionError("connection refused")

        with self.assertRaises(OrderServiceError):
            get_order_summary("order-7001")


if __name__ == "__main__":
    unittest.main()

The solution patches order_client.requests.get, which is the lookup location used by get_order_summary. The successful test supplies extra response data to prove that the function returns only its documented summary fields. The failure test uses side_effect to simulate a connection error without contacting a real server.

Key Takeaways

  • Use patch to replace external HTTP calls during a unit test.
  • Patch the object where the module under test looks it up.
  • Configure fake response methods with return_value.
  • Use side_effect to simulate exceptions or a sequence of API responses.
  • Verify important request details such as the URL, parameters, and timeout.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top
Ad
Ad
Ad