Back to Blog
Python

Mocking API Requests in pytest with Python

python pytest mock api requests: Learn how to mock API requests in pytest using unittest.mock and monkeypatch, simulate responses and errors, and keep tests determinis...

pytestmockingrequestsunit testingAPI testing
A stylized diagram showing a pytest test intercepting an HTTP request with a mock response, with a network cable cut symbolizing no real API call.

python pytest mock api requests requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you write pytest tests for code that calls external APIs, you need to mock API requests to keep tests fast and deterministic. This article shows how to mock API requests in pytest using unittest.mock and the monkeypatch fixture, and when a dedicated library like responses makes sense.

Why Mocking API Requests Matters in pytest

A test that makes a real HTTP request is slow, flaky, and dependent on network availability. It also risks hitting rate limits or modifying remote state. Mocking the request layer lets you test your code's logic—how it constructs URLs, handles status codes, parses JSON, and reacts to errors—without touching the network.

The core idea is to replace the function that performs the HTTP call with a stand-in that returns a controlled response. In Python, the most common target is requests.get, but the same technique applies to requests.post, requests.put, and other methods.

Patching requests.get with unittest.mock

The unittest.mock library provides the patch context manager, which can replace an attribute in a module or class for the duration of a test. When your code calls requests.get, you typically patch it at the module where it is used, not where requests is defined.

Consider this function that fetches a user profile:

# myapp/api.py import requests def get_user(user_id): response = requests.get(f"https://api.example.com/users/{user_id}") response.raise_for_status() return response.json()

To test it without a network call, patch requests.get and provide a fake response object:

# tests/test_api.py from unittest.mock import patch, Mock from myapp.api import get_user def test_get_user_returns_parsed_json(): fake_response = Mock() fake_response.raise_for_status.return_value = None fake_response.json.return_value = {"id": 42, "name": "Alice"} with patch("myapp.api.requests.get", return_value=fake_response) as mock_get: result = get_user(42) assert result == {"id": 42, "name": "Alice"} mock_get.assert_called_once_with("https://api.example.com/users/42")

The Mock object for the response has raise_for_status and json methods that return controlled values. The patch replaces requests.get inside myapp.api so the function never makes a real request.

Using pytest's monkeypatch Fixture

pytest provides the monkeypatch fixture, which is a more pytest-idiomatic way to replace attributes. It automatically undoes changes after each test, avoiding nested with blocks.

# tests/test_api.py from unittest.mock import Mock from myapp.api import get_user def test_get_user_with_monkeypatch(monkeypatch): fake_response = Mock() fake_response.raise_for_status.return_value = None fake_response.json.return_value = {"id": 7, "name": "Bob"} monkeypatch.setattr("myapp.api.requests.get", Mock(return_value=fake_response)) result = get_user(7) assert result == {"id": 7, "name": "Bob"}

monkeypatch.setattr accepts a string path to the attribute. It also works with the actual module object if you prefer. The fixture restores the original requests.get after the test, even if the test raises an exception.

Simulating Different API Responses

Real APIs return different status codes, headers, and payloads. Your mock should be able to represent those variations. The key is to make the fake response object behave like a real requests.Response.

For example, to test a function that handles a 404 error:

# myapp/api.py def get_user_or_none(user_id): response = requests.get(f"https://api.example.com/users/{user_id}") if response.status_code == 404: return None response.raise_for_status() return response.json()
# tests/test_api.py def test_get_user_or_none_returns_none_on_404(monkeypatch): fake_response = Mock() fake_response.status_code = 404 fake_response.raise_for_status.side_effect = requests.HTTPError("404 Client Error") monkeypatch.setattr("myapp.api.requests.get", Mock(return_value=fake_response)) assert get_user_or_none(999) is None

You can also simulate a successful response with custom headers by setting the headers attribute on the mock. For more complex scenarios, you can use a MagicMock that auto-creates attributes, but explicit Mock configuration is usually clearer.

Verifying Request Arguments and Call Counts

Mocking is not just about returning data; it also lets you assert that your code made the correct request. The assert_called_once_with method checks both the call count and the exact arguments. For more granular checks, use mock_get.call_args or mock_get.call_args_list.

def test_get_user_sends_expected_headers(monkeypatch): fake_response = Mock() fake_response.raise_for_status.return_value = None fake_response.json.return_value = {} mock_get = Mock(return_value=fake_response) monkeypatch.setattr("myapp.api.requests.get", mock_get) get_user(1) args, kwargs = mock_get.call_args assert args[0] == "https://api.example.com/users/1" assert kwargs.get("timeout") == 5

If your code calls requests.get multiple times, use mock_get.call_count or iterate over call_args_list to verify each call. This is especially useful when testing retry logic or pagination.

Handling Exceptions and Timeouts

External APIs can fail with network errors, timeouts, or invalid HTTP responses. Your mock should be able to raise these exceptions so you can test error-handling paths.

Use the side_effect parameter to make the mock raise an exception when called:

# myapp/api.py def fetch_with_retry(url, retries=2): for attempt in range(retries): try: response = requests.get(url, timeout=5) response.raise_for_status() return response.json() except requests.Timeout: continue raise requests.Timeout(f"Failed after {retries} attempts")
# tests/test_api.py from requests import Timeout def test_fetch_with_retry_raises_after_timeouts(monkeypatch): mock_get = Mock(side_effect=Timeout("timed out")) monkeypatch.setattr("myapp.api.requests.get", mock_get) with pytest.raises(Timeout): fetch_with_retry("https://api.example.com/data") assert mock_get.call_count == 2

You can also use side_effect with a list to return different values on successive calls, which is useful for testing retry success scenarios.

Choosing Between unittest.mock and responses Library

While unittest.mock gives you full control, it can become verbose when you need to mock many endpoints or complex request matching. The responses library provides a higher-level interface that registers mock responses and automatically intercepts requests made through the requests library.

Here is the same test using responses:

# tests/test_api.py import responses from myapp.api import get_user @responses.activate def test_get_user_with_responses(): responses.add( responses.GET, "https://api.example.com/users/42", json={"id": 42, "name": "Alice"}, status=200, ) result = get_user(42) assert result == {"id": 42, "name": "Alice"}

responses matches the URL and method, and you can specify json, body, status, and headers. It also records the requests it intercepted, so you can assert on them. This approach reduces boilerplate when you have many endpoints.

The tradeoff is that responses only works with the requests library, not with httpx or aiohttp. If your code uses a different HTTP client, unittest.mock is the more portable choice. For a project that already uses requests exclusively, responses often leads to cleaner tests.

Keeping Mocks Maintainable in a Growing Test Suite

As your test suite grows, duplicated mock setup becomes a maintenance burden. A common pattern is to create a factory function that builds a fake response with the fields you need.

# tests/factories.py def fake_response(status_code=200, json_data=None, headers=None): resp = Mock() resp.status_code = status_code resp.headers = headers or {} resp.json.return_value = json_data if json_data is not None else {} if status_code >= 400: resp.raise_for_status.side_effect = requests.HTTPError(f"{status_code} error") else: resp.raise_for_status.return_value = None return resp

Then in tests you can write:

monkeypatch.setattr("myapp.api.requests.get", Mock(return_value=fake_response(json_data={"id": 1})))

This keeps the mock behavior consistent and reduces the chance of forgetting to set raise_for_status or status_code. When the API contract changes, you update the factory in one place.

Another maintainability concern is patching the wrong module. Always patch where the name is looked up, not where it is defined. If your code does from requests import get, you must patch myapp.api.get instead of myapp.api.requests.get. A quick way to avoid this confusion is to always use import requests and call requests.get, which makes the patch target obvious.

Finally, consider whether your mock should be a Mock or a MagicMock. MagicMock auto-creates attributes, which can hide typos in your code. A plain Mock requires you to define every attribute you access, which often catches mistakes earlier. Use Mock unless you have a specific reason to need MagicMock's magic methods.

python pytest mock api requests: Practical Usage and Code Ex | RYUSLOG DEV