Intermediate

Any code that calls an external API creates a testing headache. Running real HTTP requests in tests means your tests depend on network availability, API rate limits, third-party uptime, and credentials — factors entirely outside your control. Tests that pass on your laptop fail in CI. Tests that run in 200ms suddenly take 30 seconds waiting for a slow endpoint. Error handling code is nearly impossible to test because you cannot force a real API to return a 500 error on demand. The result: developers skip testing HTTP-calling code, and bugs hide there until production finds them.

The responses library intercepts calls to the requests library before they hit the network and returns pre-configured fake responses instead. Your production code calls requests.get() normally; during tests, responses catches that call and returns whatever JSON, status code, or error you specify. The real network is never touched. Install it with pip install responses. It requires only requests as a dependency, which you already have if you are making HTTP calls.

This tutorial covers the full responses workflow: registering mock responses as a decorator and context manager, testing error conditions and network failures, using callback functions for dynamic responses, verifying that your code makes the correct API calls, and integrating with pytest. By the end, you will be able to test every HTTP code path — success, error, timeout, redirect — with fast, deterministic tests.

responses Quick Example

The pattern is simple: activate responses, register a URL with a fake response, then run your code. Any call to that URL via requests returns your fake data instead of hitting the network.

# test_responses_quick.py
import responses
import requests

def get_user(user_id: int) -> dict:
    """Fetch a user from a REST API."""
    url = f"https://jsonplaceholder.typicode.com/users/{user_id}"
    resp = requests.get(url, timeout=10)
    resp.raise_for_status()
    return resp.json()

@responses.activate
def test_get_user_success():
    # Register the fake response BEFORE calling your code
    responses.add(
        responses.GET,
        "https://jsonplaceholder.typicode.com/users/1",
        json={"id": 1, "name": "Alice Smith", "email": "alice@example.com"},
        status=200
    )
    user = get_user(1)
    assert user["name"] == "Alice Smith"
    assert user["email"] == "alice@example.com"
    print(f"Fetched user: {user}")
    print(f"API calls made: {len(responses.calls)}")

test_get_user_success()

Output:

Fetched user: {'id': 1, 'name': 'Alice Smith', 'email': 'alice@example.com'}
API calls made: 1

The @responses.activate decorator intercepts all requests calls within that function. Without a registered response for a URL, responses raises a ConnectionError — this is intentional. It prevents tests from accidentally hitting real APIs if a URL is unregistered, which would cause flaky tests and unexpected API usage.

responses.add(): your CI pipeline does not care that the API is down at 2am.
responses.add(): your CI pipeline does not care that the API is down at 2am.

What Is the responses Library?

The responses library works by patching the underlying transport layer of the requests library. When activated, it replaces the real HTTP adapter with a mock adapter that checks whether an incoming request URL matches any registered mock. If it matches, the mock response is returned. If not, a ConnectionError is raised (by default).

MethodUse case
responses.add()Register a static mock response for a URL
responses.add_callback()Dynamic response based on request content
responses.add_passthrough()Allow specific URLs to hit the real network
responses.callsInspect what requests were made and with what args
responses.assert_call_count()Assert a URL was called exactly N times
responses.reset()Clear all registered mocks

The library supports all HTTP methods (GET, POST, PUT, PATCH, DELETE), regex URL matching, streaming responses, and even simulating connection errors and timeouts. It is compatible with the requests.Session object and all requests-based libraries like httpx equivalents built on requests.

Registering GET and POST Mocks

The most common pattern: register responses for GET and POST endpoints, then test the code paths that call them. Here is a realistic example testing a service that creates and fetches resources:

# test_responses_crud.py
import responses
import requests
import json

BASE_URL = "https://jsonplaceholder.typicode.com"

def create_post(title: str, body: str, user_id: int) -> dict:
    resp = requests.post(
        f"{BASE_URL}/posts",
        json={"title": title, "body": body, "userId": user_id},
        headers={"Content-Type": "application/json"},
        timeout=10
    )
    resp.raise_for_status()
    return resp.json()

def get_posts_by_user(user_id: int) -> list:
    resp = requests.get(f"{BASE_URL}/posts?userId={user_id}", timeout=10)
    resp.raise_for_status()
    return resp.json()

@responses.activate
def test_create_and_fetch():
    # Mock the POST endpoint
    responses.add(
        responses.POST,
        f"{BASE_URL}/posts",
        json={"id": 101, "title": "Python Tips", "body": "Use f-strings!", "userId": 5},
        status=201
    )
    # Mock the GET endpoint (with query params as part of URL)
    responses.add(
        responses.GET,
        f"{BASE_URL}/posts",
        json=[
            {"id": 101, "title": "Python Tips", "userId": 5},
            {"id": 102, "title": "Async Python", "userId": 5}
        ],
        status=200
    )

    new_post = create_post("Python Tips", "Use f-strings!", user_id=5)
    assert new_post["id"] == 101
    assert new_post["title"] == "Python Tips"

    user_posts = get_posts_by_user(5)
    assert len(user_posts) == 2
    assert user_posts[0]["userId"] == 5

    # Verify request bodies were sent correctly
    post_call = responses.calls[0]
    sent_body = json.loads(post_call.request.body)
    assert sent_body["userId"] == 5
    print(f"POST body sent: {sent_body}")
    print(f"GET returned {len(user_posts)} posts")
    print(f"Total API calls made: {len(responses.calls)}")

test_create_and_fetch()

Output:

POST body sent: {'title': 'Python Tips', 'body': 'Use f-strings!', 'userId': 5}
GET returned 2 posts
Total API calls made: 2

The responses.calls list is a powerful testing tool — it records every request made during the test, including URL, method, headers, and body. You can assert on these to verify your code sends the right data to the API, not just that it handles the response correctly.

Testing Error Conditions

Error handling is the most undertested part of any HTTP-calling codebase. Without responses, you cannot force a real API to return a 500 error or refuse to connect. With responses, every error scenario becomes straightforward to test.

# test_responses_errors.py
import responses
import requests
from requests.exceptions import ConnectionError, Timeout

def safe_fetch(url: str) -> dict:
    """Fetch with error handling -- returns None on failure."""
    try:
        resp = requests.get(url, timeout=5)
        resp.raise_for_status()
        return resp.json()
    except requests.exceptions.HTTPError as e:
        print(f"HTTP error: {e.response.status_code}")
        return None
    except (ConnectionError, Timeout) as e:
        print(f"Connection error: {type(e).__name__}")
        return None

BASE = "https://api.example-test.com"

@responses.activate
def test_404_returns_none():
    responses.add(responses.GET, f"{BASE}/resource/999", status=404)
    result = safe_fetch(f"{BASE}/resource/999")
    assert result is None
    print("404 test: returned None correctly")

@responses.activate
def test_500_returns_none():
    responses.add(responses.GET, f"{BASE}/resource/1", status=500,
                  json={"error": "Internal Server Error"})
    result = safe_fetch(f"{BASE}/resource/1")
    assert result is None
    print("500 test: returned None correctly")

@responses.activate
def test_connection_error():
    responses.add(responses.GET, f"{BASE}/resource/2",
                  body=ConnectionError("Network unreachable"))
    result = safe_fetch(f"{BASE}/resource/2")
    assert result is None
    print("ConnectionError test: handled gracefully")

@responses.activate
def test_timeout():
    responses.add(responses.GET, f"{BASE}/resource/3",
                  body=Timeout("Request timed out"))
    result = safe_fetch(f"{BASE}/resource/3")
    assert result is None
    print("Timeout test: handled gracefully")

test_404_returns_none()
test_500_returns_none()
test_connection_error()
test_timeout()

Output:

HTTP error: 404
404 test: returned None correctly
HTTP error: 500
500 test: returned None correctly
Connection error: ConnectionError
ConnectionError test: handled gracefully
Connection error: Timeout
Timeout test: handled gracefully

Passing an exception as body= to responses.add() causes the mock to raise that exception instead of returning a response. This directly tests your except blocks — the code paths that are most often skipped in testing because they are hard to trigger against a real API.

Can your error handler handle a 429 at 3am? responses.add() knows.
Can your error handler handle a 429 at 3am? responses.add() knows.

Dynamic Responses with add_callback

Sometimes a static mock response is not enough — you need the response to depend on what the request contains. add_callback lets you provide a function that receives the request object and returns the response dynamically.

# test_responses_callback.py
import responses
import requests
import json

def handle_user_request(request):
    """Return different users based on the URL path."""
    user_id = int(request.url.split("/")[-1])
    if user_id > 100:
        return (404, {}, json.dumps({"error": "User not found"}))
    return (200, {"Content-Type": "application/json"},
            json.dumps({"id": user_id, "name": f"User {user_id}", "active": True}))

def handle_post_request(request):
    """Echo back the posted data with a generated ID."""
    body = json.loads(request.body)
    body["id"] = 42
    body["created"] = "2025-05-04"
    return (201, {"Content-Type": "application/json"}, json.dumps(body))

@responses.activate
def test_dynamic_responses():
    responses.add_callback(
        responses.GET,
        "https://api.example-test.com/users/",
        callback=handle_user_request,
        match_querystring=False
    )
    responses.add_callback(
        responses.POST,
        "https://api.example-test.com/posts",
        callback=handle_post_request
    )

    # Valid user
    r = requests.get("https://api.example-test.com/users/7")
    assert r.status_code == 200
    assert r.json()["name"] == "User 7"
    print(f"User 7: {r.json()}")

    # Invalid user
    r = requests.get("https://api.example-test.com/users/999")
    assert r.status_code == 404
    print(f"User 999: {r.json()}")

    # Post with echoed body
    r = requests.post("https://api.example-test.com/posts",
                      json={"title": "Test", "author": "Alice"})
    assert r.status_code == 201
    assert r.json()["id"] == 42
    print(f"Created post: {r.json()}")

test_dynamic_responses()

Output:

User 7: {'id': 7, 'name': 'User 7', 'active': True}
User 999: {'error': 'User not found'}
Created post: {'title': 'Test', 'author': 'Alice', 'id': 42, 'created': '2025-05-04'}

Callbacks unlock complex scenarios: responses that change after N calls, responses that validate request authentication headers and return 401 if the token is wrong, or responses that simulate paginated APIs where each call returns the next page. The callback receives the full PreparedRequest object, so you have access to headers, body, URL, and query parameters.

Real-Life Example: Testing a GitHub API Client

50 tests, 0 API calls, 0 rate limit errors.
50 tests, 0 API calls, 0 rate limit errors.
# test_github_client.py
import responses
import requests

class GitHubClient:
    BASE_URL = "https://api.github.com"

    def __init__(self, token: str):
        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"token {token}",
            "Accept": "application/vnd.github.v3+json"
        })

    def get_repo(self, owner: str, repo: str) -> dict:
        resp = self.session.get(f"{self.BASE_URL}/repos/{owner}/{repo}")
        resp.raise_for_status()
        return resp.json()

    def list_issues(self, owner: str, repo: str, state: str = "open") -> list:
        resp = self.session.get(
            f"{self.BASE_URL}/repos/{owner}/{repo}/issues",
            params={"state": state}
        )
        resp.raise_for_status()
        return resp.json()

    def create_issue(self, owner: str, repo: str, title: str, body: str = "") -> dict:
        resp = self.session.post(
            f"{self.BASE_URL}/repos/{owner}/{repo}/issues",
            json={"title": title, "body": body}
        )
        resp.raise_for_status()
        return resp.json()

@responses.activate
def test_github_client_full():
    BASE = "https://api.github.com"
    client = GitHubClient(token="test-token-abc")

    # Mock: get repo
    responses.add(responses.GET, f"{BASE}/repos/alice/my-project",
                  json={"id": 999, "name": "my-project", "stargazers_count": 42, "open_issues": 3},
                  status=200)

    # Mock: list open issues
    responses.add(responses.GET, f"{BASE}/repos/alice/my-project/issues",
                  json=[
                      {"id": 1, "title": "Fix login bug", "state": "open"},
                      {"id": 2, "title": "Add dark mode", "state": "open"},
                  ], status=200)

    # Mock: create issue
    responses.add(responses.POST, f"{BASE}/repos/alice/my-project/issues",
                  json={"id": 101, "title": "Performance regression in v2.1", "number": 101},
                  status=201)

    # Run the client methods
    repo = client.get_repo("alice", "my-project")
    assert repo["stargazers_count"] == 42
    print(f"Repo: {repo['name']} ({repo['stargazers_count']} stars)")

    issues = client.list_issues("alice", "my-project")
    assert len(issues) == 2
    print(f"Open issues: {[i['title'] for i in issues]}")

    new_issue = client.create_issue("alice", "my-project",
                                    "Performance regression in v2.1",
                                    "Noticed in benchmarks...")
    assert new_issue["number"] == 101
    print(f"Created issue #{new_issue['number']}: {new_issue['title']}")

    # Verify Authorization header was sent with every request
    for call in responses.calls:
        assert "Authorization" in call.request.headers
        assert call.request.headers["Authorization"] == "token test-token-abc"
    print(f"All {len(responses.calls)} calls included auth header: OK")

test_github_client_full()

Output:

Repo: my-project (42 stars)
Open issues: ['Fix login bug', 'Add dark mode']
Created issue #101: Performance regression in v2.1
All 3 calls included auth header: OK

The final assertion — checking that every call included the Authorization header — is a pattern worth using in every API client test. It catches a common bug: authentication headers being dropped when the session is configured incorrectly or when a redirect is followed. responses.calls makes this verification trivial.

Frequently Asked Questions

How does responses compare to httpretty and VCR.py?

responses is requests-specific and lightweight — it only patches requests, which is exactly what you want when your codebase uses requests. httpretty works at the socket level and intercepts any HTTP library (including urllib and http.client), making it more powerful but also more invasive and harder to debug. VCR.py records real API responses to YAML cassette files and replays them, which is useful for complex integration tests but adds file management overhead. For most requests-based testing, responses is the right balance of simplicity and control.

What happens if I call requests without activating responses?

Without @responses.activate or the context manager, responses is inactive and all requests calls go to the real network as normal. This is by design — responses never interferes with production code or non-test code. Inside an activated context, any requests call to an unregistered URL raises a ConnectionError with a clear message indicating no matching mock was found.

Does responses work with requests.Session?

Yes — responses patches the underlying transport adapter used by all requests objects, including Session instances. Any Session().get(), Session().post(), or other session-based call is intercepted exactly like a bare requests.get() call. This means you can test code that uses sessions for cookie handling, connection pooling, or persistent headers without any special configuration.

How do I assert a URL was called exactly N times?

Use responses.assert_call_count(url, count) after your test code runs. For example, responses.assert_call_count("https://api.example.com/users", 3) raises AssertionError if the URL was not called exactly 3 times. Alternatively, filter responses.calls manually: len([c for c in responses.calls if c.request.url == url]). This is useful for testing retry logic — confirm your code retries exactly 3 times before giving up.

Can I match URLs with regex patterns?

Yes — pass a compiled regex pattern to responses.add() instead of a string URL. For example: responses.add(responses.GET, re.compile(r"https://api.example.com/users/\d+"), json={...}) matches any numeric user ID. This is useful when the exact URL varies but follows a pattern, like paginated endpoints with varying page numbers or resource IDs generated at runtime.

Conclusion

The responses library makes HTTP-dependent code fully testable by intercepting requests calls before they reach the network. You have covered registering static mocks with responses.add(), simulating errors and network failures, using add_callback for dynamic responses that depend on request content, and inspecting responses.calls to verify request correctness. The GitHub client example showed a complete test suite for a real-world API client that tests success paths, error handling, and authentication — all without a single real network call.

Extend the GitHub client tests by adding retry logic — use a callback that returns 429 on the first two calls and 200 on the third, then verify your retry decorator handles it correctly. For the complete API reference, see the responses documentation on GitHub.