Intermediate

Your Python application talks to external APIs — fetching weather data, processing payments, sending notifications, pulling user profiles from third-party services. But when you write tests, you do not want those tests to actually hit the internet. Real API calls are slow, flaky, cost money, and make your test results depend on whether some server halfway around the world is having a good day. Mocking API calls lets you test your code’s logic in complete isolation, with predictable responses that run in milliseconds.

Python’s standard library includes everything you need through the unittest.mock module. You do not need to install anything extra to get started — patch, MagicMock, and Mock are all built in. For more advanced scenarios, the third-party responses library provides an elegant way to mock the requests library specifically. Both approaches work seamlessly with pytest.

In this article, we will cover everything you need to mock API calls in Python. We will start with a quick example, then explain how mocking works under the hood. From there, we will walk through patching with decorators and context managers, configuring mock return values and side effects, verifying that calls were made correctly, using the responses library for request-level mocking, and handling error scenarios. We will finish with a complete real-life project that tests a GitHub user profile fetcher end to end.

Mocking an API Call in Python: Quick Example

Here is the simplest possible example of mocking an API call. We have a function that fetches a user from an API, and a test that replaces the HTTP call with a fake response.

# quick_mock_example.py
from unittest.mock import patch, MagicMock

import requests

def get_user(user_id):
    """Fetch a user from the API."""
    response = requests.get(f"https://jsonplaceholder.typicode.com/users/{user_id}")
    response.raise_for_status()
    return response.json()

# Test it without hitting the real API
@patch("requests.get")
def test_get_user(mock_get):
    mock_response = MagicMock()
    mock_response.json.return_value = {"id": 1, "name": "Leanne Graham"}
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    user = get_user(1)
    assert user["name"] == "Leanne Graham"
    mock_get.assert_called_once_with("https://jsonplaceholder.typicode.com/users/1")

if __name__ == "__main__":
    test_get_user()
    print("Test passed!")

Output:

$ python quick_mock_example.py
Test passed!

The @patch decorator replaced requests.get with a MagicMock before the test ran, and restored the real function afterward. We configured the mock to return a fake JSON response, then verified that our function called the right URL. The entire test runs without any network access, making it fast and reliable.

Want to learn about context managers, side effects, error simulation, and the responses library? Keep reading — we cover all of that below.

What Is Mocking and Why Mock API Calls?

Mocking is a testing technique where you replace a real object with a fake one that behaves however you configure it. In the context of API calls, mocking means replacing the HTTP client (usually requests.get or requests.post) with a controlled substitute that returns predetermined responses without making any network requests.

Here is why mocking API calls matters for any serious Python project:

Problem with real API calls in testsHow mocking solves it
Tests are slow (network round-trips)Mocks return instantly from memory
Tests fail when API is down or rate-limitedMocks always respond predictably
Tests cost money (paid APIs charge per call)Mocks are free — no HTTP requests leave your machine
Tests depend on external data that changesMocks return the exact data you specify
Tests cannot simulate errors easilyMocks can raise any exception on demand
Tests require authentication tokensMocks bypass all authentication

The core principle is simple: your tests should verify that your code handles API responses correctly. They should not verify that the API itself is working — that is the API provider’s job. Mocking draws a clean boundary between your logic and the external world.

Patching With the Decorator Pattern

The most common way to mock an API call is the @patch decorator from unittest.mock. It temporarily replaces a specified object with a MagicMock for the duration of the test, then restores the original when the test finishes.

# github_client.py
import requests

def get_repo_stars(owner, repo):
    """Fetch the star count for a GitHub repository."""
    url = f"https://api.github.com/repos/{owner}/{repo}"
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    data = response.json()
    return data["stargazers_count"]

def is_popular(owner, repo, threshold=1000):
    """Check if a repository has more stars than the threshold."""
    stars = get_repo_stars(owner, repo)
    return stars >= threshold
# test_github_client.py
from unittest.mock import patch, MagicMock
from github_client import get_repo_stars, is_popular

@patch("github_client.requests.get")
def test_get_repo_stars(mock_get):
    mock_response = MagicMock()
    mock_response.json.return_value = {"stargazers_count": 54321}
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    stars = get_repo_stars("python", "cpython")
    assert stars == 54321

@patch("github_client.requests.get")
def test_is_popular_true(mock_get):
    mock_response = MagicMock()
    mock_response.json.return_value = {"stargazers_count": 5000}
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    assert is_popular("pallets", "flask") is True

@patch("github_client.requests.get")
def test_is_popular_false(mock_get):
    mock_response = MagicMock()
    mock_response.json.return_value = {"stargazers_count": 50}
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    assert is_popular("someone", "small-project") is False

Output:

$ pytest test_github_client.py -v
========================= test session starts =========================
collected 3 items

test_github_client.py::test_get_repo_stars PASSED
test_github_client.py::test_is_popular_true PASSED
test_github_client.py::test_is_popular_false PASSED

========================= 3 passed in 0.02s ==========================

Notice the patch target is "github_client.requests.get", not "requests.get". This is the single most common mistake with @patch: you must patch where the object is looked up, not where it is defined. Since github_client.py imports requests and calls requests.get, you patch it inside github_client‘s namespace.

Patching With Context Managers

Sometimes the decorator pattern is too broad — you only need the mock active for a few lines, not the entire test. The with statement gives you finer control over exactly when the mock is active.

# test_context_manager.py
from unittest.mock import patch, MagicMock
from github_client import get_repo_stars

def test_patch_as_context_manager():
    with patch("github_client.requests.get") as mock_get:
        mock_response = MagicMock()
        mock_response.json.return_value = {"stargazers_count": 999}
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response

        stars = get_repo_stars("test", "repo")
        assert stars == 999

    # After the with block, requests.get is real again

Output:

$ pytest test_context_manager.py -v
========================= test session starts =========================
collected 1 item

test_context_manager.py::test_patch_as_context_manager PASSED

========================= 1 passed in 0.01s ==========================

The context manager approach is especially useful when your test needs to verify behavior both with and without the mock in the same test function. Inside the with block, the mock is active. Outside it, the original object is restored. This gives you precise control that the decorator cannot match.

Side Effects: Simulating Errors and Dynamic Responses

Real APIs do not always return happy responses. They time out, return 500 errors, send malformed JSON, and rate-limit your requests. The side_effect parameter on a mock lets you simulate all of these scenarios so your code handles failures gracefully.

# test_error_handling.py
from unittest.mock import patch, MagicMock
import requests
from github_client import get_repo_stars

@patch("github_client.requests.get")
def test_api_timeout(mock_get):
    mock_get.side_effect = requests.exceptions.Timeout("Connection timed out")

    try:
        get_repo_stars("python", "cpython")
        assert False, "Should have raised Timeout"
    except requests.exceptions.Timeout:
        pass  # Expected behavior

@patch("github_client.requests.get")
def test_api_404(mock_get):
    mock_response = MagicMock()
    mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
        "404 Not Found"
    )
    mock_get.return_value = mock_response

    try:
        get_repo_stars("nonexistent", "repo")
        assert False, "Should have raised HTTPError"
    except requests.exceptions.HTTPError:
        pass  # Expected behavior

@patch("github_client.requests.get")
def test_api_returns_different_responses(mock_get):
    response_1 = MagicMock()
    response_1.json.return_value = {"stargazers_count": 100}
    response_1.raise_for_status.return_value = None

    response_2 = MagicMock()
    response_2.json.return_value = {"stargazers_count": 200}
    response_2.raise_for_status.return_value = None

    mock_get.side_effect = [response_1, response_2]

    assert get_repo_stars("owner", "repo1") == 100
    assert get_repo_stars("owner", "repo2") == 200

Output:

$ pytest test_error_handling.py -v
========================= test session starts =========================
collected 3 items

test_error_handling.py::test_api_timeout PASSED
test_error_handling.py::test_api_404 PASSED
test_error_handling.py::test_api_returns_different_responses PASSED

========================= 3 passed in 0.02s ==========================

When side_effect is an exception class or instance, the mock raises that exception when called. When it is a list, the mock returns each item in sequence on successive calls. This is incredibly powerful for testing retry logic, fallback behavior, and error recovery paths that would be nearly impossible to trigger with real API calls.

Verifying How Your Code Calls the API

Mocking is not just about controlling what comes back from the API — it also lets you verify exactly how your code called it. Did it use the right URL? Did it send the correct headers? Did it call the API the expected number of times? The mock object records every call for inspection.

# notification_service.py
import requests

def send_notification(user_email, message, urgent=False):
    """Send a notification via the company API."""
    payload = {
        "to": user_email,
        "body": message,
        "priority": "high" if urgent else "normal"
    }
    headers = {"Authorization": "Bearer fake-token-123"}
    response = requests.post(
        "https://api.notifications.internal/send",
        json=payload,
        headers=headers,
        timeout=5
    )
    response.raise_for_status()
    return response.json()
# test_notification_service.py
from unittest.mock import patch, MagicMock, call
from notification_service import send_notification

@patch("notification_service.requests.post")
def test_sends_correct_payload(mock_post):
    mock_response = MagicMock()
    mock_response.json.return_value = {"status": "sent", "id": "msg-123"}
    mock_response.raise_for_status.return_value = None
    mock_post.return_value = mock_response

    result = send_notification("alice@example.com", "Hello!", urgent=True)

    mock_post.assert_called_once_with(
        "https://api.notifications.internal/send",
        json={
            "to": "alice@example.com",
            "body": "Hello!",
            "priority": "high"
        },
        headers={"Authorization": "Bearer fake-token-123"},
        timeout=5
    )
    assert result["status"] == "sent"

@patch("notification_service.requests.post")
def test_normal_priority_by_default(mock_post):
    mock_response = MagicMock()
    mock_response.json.return_value = {"status": "sent"}
    mock_response.raise_for_status.return_value = None
    mock_post.return_value = mock_response

    send_notification("bob@example.com", "Update available")

    actual_call = mock_post.call_args
    assert actual_call.kwargs["json"]["priority"] == "normal"

Output:

$ pytest test_notification_service.py -v
========================= test session starts =========================
collected 2 items

test_notification_service.py::test_sends_correct_payload PASSED
test_notification_service.py::test_normal_priority_by_default PASSED

========================= 2 passed in 0.01s ==========================

The assert_called_once_with method checks both that the mock was called exactly once and that it received the exact arguments you specified. For more flexible inspection, call_args gives you the actual positional and keyword arguments from the most recent call. This is how you verify that your code is building the right request body, sending the correct headers, and using proper timeout values — all without any network traffic.

The responses Library: Mocking at the HTTP Level

While unittest.mock works at the Python object level (replacing requests.get itself), the responses library works at the HTTP level — it intercepts outgoing HTTP requests and returns configured responses. This is closer to how the real code works and requires less boilerplate for request-heavy tests.

# test_with_responses.py
import responses
import requests

def fetch_todos(user_id):
    """Fetch todos for a user from the API."""
    url = f"https://jsonplaceholder.typicode.com/todos?userId={user_id}"
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    todos = response.json()
    return [t for t in todos if not t["completed"]]

@responses.activate
def test_fetch_incomplete_todos():
    responses.add(
        responses.GET,
        "https://jsonplaceholder.typicode.com/todos",
        json=[
            {"id": 1, "userId": 1, "title": "Buy groceries", "completed": False},
            {"id": 2, "userId": 1, "title": "Walk the dog", "completed": True},
            {"id": 3, "userId": 1, "title": "Write tests", "completed": False},
        ],
        status=200
    )

    incomplete = fetch_todos(1)
    assert len(incomplete) == 2
    assert incomplete[0]["title"] == "Buy groceries"
    assert incomplete[1]["title"] == "Write tests"

@responses.activate
def test_fetch_todos_server_error():
    responses.add(
        responses.GET,
        "https://jsonplaceholder.typicode.com/todos",
        json={"error": "Internal Server Error"},
        status=500
    )

    try:
        fetch_todos(1)
        assert False, "Should have raised HTTPError"
    except requests.exceptions.HTTPError:
        pass

Output:

$ pytest test_with_responses.py -v
========================= test session starts =========================
collected 2 items

test_with_responses.py::test_fetch_incomplete_todos PASSED
test_with_responses.py::test_fetch_todos_server_error PASSED

========================= 2 passed in 0.03s ==========================

The @responses.activate decorator intercepts all HTTP requests made through the requests library during the test. You register expected responses with responses.add(), specifying the HTTP method, URL, response body, and status code. If your code tries to make a request to an unregistered URL, responses raises a ConnectionError, which catches accidental real API calls. Install it with pip install responses.

Combining Mocks With pytest Fixtures

When multiple tests share the same mock setup, pytest fixtures eliminate the repetition. You can create fixtures that set up mocks and inject them into any test that needs them.

# test_with_fixtures.py
import pytest
from unittest.mock import patch, MagicMock
from github_client import get_repo_stars, is_popular

@pytest.fixture
def mock_github_api():
    """Fixture that patches requests.get for GitHub API tests."""
    with patch("github_client.requests.get") as mock_get:
        mock_response = MagicMock()
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response
        yield {"mock_get": mock_get, "mock_response": mock_response}

def test_repo_with_many_stars(mock_github_api):
    mock_github_api["mock_response"].json.return_value = {"stargazers_count": 50000}
    assert get_repo_stars("big", "project") == 50000

def test_repo_with_few_stars(mock_github_api):
    mock_github_api["mock_response"].json.return_value = {"stargazers_count": 3}
    assert get_repo_stars("tiny", "project") == 3

def test_popular_repo(mock_github_api):
    mock_github_api["mock_response"].json.return_value = {"stargazers_count": 9999}
    assert is_popular("big", "project", threshold=5000) is True

def test_unpopular_repo(mock_github_api):
    mock_github_api["mock_response"].json.return_value = {"stargazers_count": 10}
    assert is_popular("tiny", "project", threshold=5000) is False

Output:

$ pytest test_with_fixtures.py -v
========================= test session starts =========================
collected 4 items

test_with_fixtures.py::test_repo_with_many_stars PASSED
test_with_fixtures.py::test_repo_with_few_stars PASSED
test_with_fixtures.py::test_popular_repo PASSED
test_with_fixtures.py::test_unpopular_repo PASSED

========================= 4 passed in 0.02s ==========================

The fixture uses yield inside the with patch() context manager, which means the mock is active while the test runs and automatically cleaned up afterward. Each test only needs to configure the specific return value it cares about — the common setup (patching, creating the mock response, wiring up raise_for_status) is handled once in the fixture. Put shared fixtures like this in conftest.py to make them available across multiple test files.

Real-Life Example: Testing a GitHub Profile Fetcher

Let us build a complete module that fetches GitHub user profiles and formats them for display, then write a comprehensive test suite covering happy paths, error handling, and edge cases.

# github_profile.py
import requests

class GitHubProfileError(Exception):
    """Custom exception for GitHub profile fetching errors."""
    pass

class GitHubProfile:
    API_BASE = "https://api.github.com"

    def __init__(self, username):
        self.username = username
        self._data = None

    def fetch(self):
        """Fetch the user profile from GitHub API."""
        try:
            response = requests.get(
                f"{self.API_BASE}/users/{self.username}",
                headers={"Accept": "application/vnd.github.v3+json"},
                timeout=10
            )
            response.raise_for_status()
            self._data = response.json()
        except requests.exceptions.Timeout:
            raise GitHubProfileError(f"Timeout fetching profile for {self.username}")
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 404:
                raise GitHubProfileError(f"User '{self.username}' not found")
            raise GitHubProfileError(f"API error: {e}")
        return self

    def summary(self):
        """Return a formatted summary string."""
        if not self._data:
            raise GitHubProfileError("Profile not fetched yet. Call fetch() first.")
        name = self._data.get("name", self.username)
        bio = self._data.get("bio", "No bio available")
        repos = self._data.get("public_repos", 0)
        followers = self._data.get("followers", 0)
        return f"{name} | {repos} repos | {followers} followers | {bio}"

    @property
    def is_prolific(self):
        """Check if the user has more than 50 public repos."""
        if not self._data:
            return False
        return self._data.get("public_repos", 0) > 50

Now the test suite that exercises the full class:

# test_github_profile.py
import pytest
from unittest.mock import patch, MagicMock
import requests
from github_profile import GitHubProfile, GitHubProfileError

@pytest.fixture
def mock_api():
    with patch("github_profile.requests.get") as mock_get:
        mock_response = MagicMock()
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response
        yield {"get": mock_get, "response": mock_response}

@pytest.fixture
def sample_profile_data():
    return {
        "login": "octocat",
        "name": "The Octocat",
        "bio": "GitHub mascot and occasional developer",
        "public_repos": 85,
        "followers": 12000
    }

# --- Fetch Tests ---

def test_fetch_sets_data(mock_api, sample_profile_data):
    mock_api["response"].json.return_value = sample_profile_data
    profile = GitHubProfile("octocat").fetch()
    assert profile._data == sample_profile_data

def test_fetch_uses_correct_url(mock_api, sample_profile_data):
    mock_api["response"].json.return_value = sample_profile_data
    GitHubProfile("torvalds").fetch()
    mock_api["get"].assert_called_once_with(
        "https://api.github.com/users/torvalds",
        headers={"Accept": "application/vnd.github.v3+json"},
        timeout=10
    )

def test_fetch_timeout(mock_api):
    mock_api["get"].side_effect = requests.exceptions.Timeout("timed out")
    with pytest.raises(GitHubProfileError, match="Timeout"):
        GitHubProfile("slowuser").fetch()

def test_fetch_user_not_found(mock_api):
    error_response = MagicMock()
    error_response.status_code = 404
    mock_api["response"].raise_for_status.side_effect = (
        requests.exceptions.HTTPError(response=error_response)
    )
    with pytest.raises(GitHubProfileError, match="not found"):
        GitHubProfile("ghost").fetch()

# --- Summary Tests ---

def test_summary_format(mock_api, sample_profile_data):
    mock_api["response"].json.return_value = sample_profile_data
    profile = GitHubProfile("octocat").fetch()
    result = profile.summary()
    assert "The Octocat" in result
    assert "85 repos" in result
    assert "12000 followers" in result

def test_summary_without_fetch():
    profile = GitHubProfile("someone")
    with pytest.raises(GitHubProfileError, match="not fetched"):
        profile.summary()

def test_summary_missing_bio(mock_api):
    mock_api["response"].json.return_value = {
        "login": "minimal", "name": "Min User",
        "public_repos": 1, "followers": 0
    }
    profile = GitHubProfile("minimal").fetch()
    assert "No bio available" in profile.summary()

# --- Property Tests ---

@pytest.mark.parametrize("repo_count, expected", [
    (100, True),
    (51, True),
    (50, False),
    (0, False),
])
def test_is_prolific(mock_api, repo_count, expected):
    mock_api["response"].json.return_value = {"public_repos": repo_count}
    profile = GitHubProfile("user").fetch()
    assert profile.is_prolific is expected

def test_is_prolific_without_fetch():
    profile = GitHubProfile("someone")
    assert profile.is_prolific is False

Output:

$ pytest test_github_profile.py -v
========================= test session starts =========================
collected 11 items

test_github_profile.py::test_fetch_sets_data PASSED
test_github_profile.py::test_fetch_uses_correct_url PASSED
test_github_profile.py::test_fetch_timeout PASSED
test_github_profile.py::test_fetch_user_not_found PASSED
test_github_profile.py::test_summary_format PASSED
test_github_profile.py::test_summary_without_fetch PASSED
test_github_profile.py::test_summary_missing_bio PASSED
test_github_profile.py::test_is_prolific[100-True] PASSED
test_github_profile.py::test_is_prolific[51-True] PASSED
test_github_profile.py::test_is_prolific[50-False] PASSED
test_github_profile.py::test_is_prolific_without_fetch PASSED

========================= 11 passed in 0.03s =========================

This test suite demonstrates all the mocking techniques from the article working together. The mock_api fixture handles common setup, side_effect simulates timeouts and HTTP errors, assert_called_once_with verifies the request details, and parametrize covers the boundary cases for is_prolific. Every test runs without internet access and completes in milliseconds.

Frequently Asked Questions

Should I use unittest.mock or the responses library?

Use unittest.mock when you need general-purpose mocking that works with any library or object, not just HTTP calls. Use responses when you are specifically testing code that uses the requests library and want a cleaner syntax for defining mock HTTP responses. For most projects, start with unittest.mock since it is built in and covers all use cases. Add responses when you have many request-heavy tests and the mock setup becomes repetitive.

Why does my patch not seem to work?

The most common reason is patching the wrong target. You must patch where the object is used, not where it is defined. If your module my_app.py does import requests and calls requests.get, you patch "my_app.requests.get", not "requests.get". If the module does from requests import get, you patch "my_app.get" instead. Check your import style and make sure the patch target matches it.

How do I mock async API calls with aiohttp or httpx?

For aiohttp, use the aioresponses library which works like responses but for async HTTP. For httpx, use respx. Both follow the same pattern: register expected URLs with mock responses, run your async code, and verify the calls. You can also use unittest.mock.AsyncMock (Python 3.8+) for general async mocking with patch.

Can I mock only some API calls and let others go through?

With unittest.mock, you can use side_effect with a function that conditionally returns a mock or calls the real implementation. With responses, add responses.passthrough_prefixes = ("https://allowed-api.com",) to let specific URLs through while mocking others. However, mixing real and mocked calls in tests is generally a sign that you should split the test into separate unit and integration tests.

How many things should I mock in a single test?

Mock only the external boundaries — the things that cross your application’s edge (HTTP calls, database queries, file I/O, system clocks). Do not mock internal functions or classes within your own codebase unless you have a specific reason. Over-mocking makes tests brittle because they break whenever you refactor internal code, even if the external behavior stays the same. A good rule of thumb: if you are mocking more than two things in one test, the function under test might be doing too much and should be refactored.

Conclusion

We covered the complete toolkit for mocking API calls in Python: patching with decorators and context managers, configuring return values and side effects with MagicMock, verifying call arguments with assert_called_once_with, using the responses library for HTTP-level mocking, combining mocks with pytest fixtures, and simulating errors like timeouts and 404s. The GitHub profile project showed how all these techniques work together in a realistic codebase.

Try extending the GitHub profile tests as practice: add a method that fetches the user’s repositories, handle pagination, or add caching with a TTL. Each new feature gives you more opportunities to practice mocking different response shapes and error conditions.

For the complete unittest.mock documentation, visit the official Python docs at docs.python.org/3/library/unittest.mock. For the responses library, see its GitHub page at github.com/getsentry/responses.