mrkeyoor.com_
Thu 06 Aug 10:54 UTC
PyPITestingupdated 06 Aug 2026

responses

responses fakes HTTP for code that calls the requests library. It works by patching one function, requests.adapters.HTTPAdapter.send, so anything your code does through requests gets served from a registry of canned Response objects instead of the network. You register with responses.get(url, json=..., status=...) or the generic responses.add(), turn it on with the @responses.activate decorator or a with responses.RequestsMock() block, and any request that does not match a registered response raises requests.exceptions.ConnectionError rather than quietly reaching the internet. On top of that there is a matchers module for asserting on the request body, query string, headers and multipart payload, a registries module for making the order of responses meaningful, callbacks for computing a reply from the incoming request, and responses.calls for inspecting exactly what your code sent.

Verdict

If your code speaks requests, this is the mocking library to reach for: one decorator, no fixture server, and matchers good enough to assert what you actually sent. Just remember it only patches requests, so keep an eye on anything in your dependency tree that talks HTTP some other way.

API stability4/5Still pre-1.0 after more than a decade, but the core add/activate/RequestsMock surface has been steady for years and the README keeps a deprecation table with migration paths for everything removed since 0.14; the caveat is that recording and replay sit in responses._recorder and are marked BETA, so that part can change under you
Docs4/5The README is long and worked-example driven, covering matchers, registries, retries, redirects and passthrough with runnable code rather than prose; what it lacks is a page on debugging a failed match, which is where most time goes, and the two entry points having different assert_all_requests_are_fired defaults is never stated outright
Maintenance4/5Pushed 2026-07-24 with 0.26.0, 0.26.1 and 0.26.2 shipping across 2026, backed by Sentry rather than one person, and typed with a py.typed marker; 27 genuinely open issues out of 38 open issues and PRs is healthy, though a few releases a year means a fix can wait months
Ecosystem5/5About 17.0M downloads a week, a companion pytest-responses fixture package, and near-universal presence in the test suites of libraries built on requests, which also means most Python developers can already read a responses test without looking anything up

Use it if

  • Your production code uses requests and you want tests that never touch the network: the patch is at the adapter layer, so it covers Session objects, third-party SDKs built on requests, and code you cannot edit
  • You want to assert on what was sent, not just stub what comes back: matchers.json_params_matcher, query_param_matcher, header_matcher and multipart_matcher fail the request with a ConnectionError when the payload is wrong, which turns a stub into a contract test
  • You need to stage a sequence: registering several responses for one URL pops them in order, and registries.OrderedRegistry makes that strict so you can exercise urllib3 Retry logic against three 500s and then a 200
  • You want to test error paths cheaply: passing body=Exception('boom') makes the call raise instead of returning, so timeout and connection-failure branches get covered without sleeping
  • You are already in pytest or unittest and do not want a fixture server: the decorator, the context manager, and the start/stop pair all work without changing how tests are collected or run
Skip it if

Setup reality

pip install responses and the decorator works on the first try, which is why the surprises come later. First, the patch is applied to one attribute path, requests.adapters.HTTPAdapter.send, so any code that built its own transport adapter or that reaches urllib3 directly slips past unmocked; the target argument on RequestsMock exists precisely because people need to point it somewhere else. Second, the defaults disagree between the two entry points: @responses.activate leaves assert_all_requests_are_fired at False, while with responses.RequestsMock() sets it to True, so moving a test from one form to the other can change whether an unused stub is an error. Third, matching is stricter than it looks. Query strings are matched when the registered URL has one and ignored when it does not, header_matcher with strict_match=True fails against the headers requests adds on its own, and json_params_matcher compares the parsed body exactly, so an extra key that your API happily ignores still turns into a ConnectionError with a message about a mismatch rather than an obvious assertion failure. Budget an afternoon learning to read those error strings. Finally, a failed match surfaces as requests.exceptions.ConnectionError, which is the same exception your production code probably catches and retries, so a badly written test can look like a hang rather than a failure.

Patterns

Register a response and hit itstub-a-get

import responses
import requests
import pytest


@responses.activate
def test_fetches_user():
    responses.get(
        "https://api.example.com/users/1",
        json={"id": 1, "name": "Ada"},
        status=200,
    )

    resp = requests.get("https://api.example.com/users/1")
    assert resp.json()["name"] == "Ada"


@responses.activate
def test_unregistered_url_is_blocked():
    with pytest.raises(requests.exceptions.ConnectionError):
        requests.get("https://api.example.com/anything-else")

The json= keyword sets the Content-Type for you; body= does not, so pair body with content_type when the caller checks it. An unmatched URL raises ConnectionError, which is the same exception a real outage produces, so if your code under test has a broad except around requests you will see a retry loop instead of a failing assertion.

Catch stubs that were never calledcontext-manager-and-unused-stubs

import responses
import requests


def test_context_manager():
    with responses.RequestsMock() as rsps:          # asserts on exit by default
        rsps.get("https://api.example.com/a", json={})
        rsps.get("https://api.example.com/b", json={})
        requests.get("https://api.example.com/a")
    # AssertionError: Not all requests have been executed
    #   [('GET', 'https://api.example.com/b')]


@responses.activate(assert_all_requests_are_fired=True)
def test_decorator_opt_in():
    responses.get("https://api.example.com/b", json={})
    # same assertion, but only because it was asked for explicitly

This is the one default worth memorising: RequestsMock() sets assert_all_requests_are_fired to True, while @responses.activate sets it to False. A test that quietly stops calling an endpoint keeps passing under the decorator and starts failing the moment someone converts it to a with block.

Assert on the JSON or form body that was sentmatch-request-body

import responses
import requests
from responses import matchers


@responses.activate
def test_sends_right_payload():
    responses.post(
        "https://api.example.com/orders",
        json={"id": "ord_1"},
        match=[matchers.json_params_matcher({"sku": "abc", "qty": 2})],
    )

    requests.post("https://api.example.com/orders", json={"sku": "abc", "qty": 2})


@responses.activate
def test_form_encoded():
    responses.post(
        "https://calc.example.com/sum",
        body="4",
        match=[matchers.urlencoded_params_matcher({"left": "1", "right": "3"})],
    )
    requests.post("https://calc.example.com/sum", data={"left": 1, "right": 3})

Matching is exact by default, so one extra key your API would happily ignore turns into a ConnectionError whose message contains the diff. urlencoded_params_matcher compares strings, which is why the expected values are quoted even though the code passes integers. Reach for matchers.body_matcher when the payload is neither JSON nor form data.

Match on query parameters and headersmatch-query-and-headers

import responses
import requests
from responses import matchers


@responses.activate
def test_query_and_headers():
    responses.get(
        "https://api.example.com/search",          # no query string in the URL
        json={"hits": []},
        match=[
            matchers.query_param_matcher({"q": "ada", "page": "2"}),
            matchers.header_matcher({"Accept": "application/json"}),
        ],
    )

    requests.get(
        "https://api.example.com/search",
        params={"q": "ada", "page": 2},
        headers={"Accept": "application/json"},
    )

# only check the params you care about
matchers.query_param_matcher({"q": "ada"}, strict_match=False)

Do not put the query string in the URL when you also use query_param_matcher; the URL's own query is matched separately and the two rules fight. header_matcher ignores the extra headers requests adds by default, and turning on strict_match=True makes it fail against User-Agent and Accept-Encoding unless you send a hand-built PreparedRequest.

Return different answers to the same URLsequence-of-responses

import responses
import requests
from responses import registries
from urllib3.util import Retry


@responses.activate
def test_first_fails_then_succeeds():
    responses.get("https://api.example.com/x", status=500)
    responses.get("https://api.example.com/x", json={"ok": True})

    assert requests.get("https://api.example.com/x").status_code == 500
    assert requests.get("https://api.example.com/x").json() == {"ok": True}


@responses.activate(registry=registries.OrderedRegistry)
def test_retry_budget():
    url = "https://api.example.com/x"
    for _ in range(3):
        responses.get(url, body="Error", status=500)
    responses.get(url, body="OK", status=200)

    session = requests.Session()
    session.mount(
        "https://",
        requests.adapters.HTTPAdapter(
            max_retries=Retry(total=4, backoff_factor=0, status_forcelist=[500])
        ),
    )
    assert session.get(url).status_code == 200

With the default registry, a response is consumed only when more than one matches; register a single response for a URL and it answers every call. OrderedRegistry makes insertion order binding, which is the only reliable way to test a retry budget. Set backoff_factor to 0 or your test sleeps for real.

Compute the reply from the incoming requestdynamic-callback

import json
import re
import responses
import requests


@responses.activate
def test_echo_sum():
    def callback(request):
        payload = json.loads(request.body)
        body = json.dumps({"value": sum(payload["numbers"])})
        return (200, {"request-id": "abc123"}, body)

    responses.add_callback(
        responses.POST,
        re.compile(r"https://calc\.example\.com/(sum|prod)"),
        callback=callback,
        content_type="application/json",
    )

    resp = requests.post("https://calc.example.com/sum", json={"numbers": [1, 2, 3]})
    assert resp.json() == {"value": 6}

The callback must return a three-tuple of status, headers and a body string; returning a dict or forgetting the headers raises inside responses rather than in your test. request.body is bytes or str depending on how the caller sent it, so parse defensively. Use functools.partial to reuse one callback with different canned identifiers.

Assert how many times and with whatinspect-calls

import json
import responses
import requests


@responses.activate
def test_call_inspection():
    rsp = responses.patch("https://api.example.com/users/1", status=200)

    requests.patch("https://api.example.com/users/1", json={"active": False})

    assert len(responses.calls) == 1              # every call in this test
    assert rsp.call_count == 1                    # calls matched by this stub
    assert json.loads(rsp.calls[0].request.body) == {"active": False}
    assert rsp.calls[0].response.status_code == 200

    responses.assert_call_count("https://api.example.com/users/1", 1)

Prefer rsp.calls over indexing responses.calls when requests can arrive in any order, for example from a thread pool, because the per-response list is what stays stable. assert_call_count matches on the exact URL including its query string, so a call with extra params counts as a different URL and the assertion fails in a confusing way.

Make the call blow up instead of returningsimulate-network-errors

import pytest
import requests
import responses


@responses.activate
def test_timeout_path():
    responses.get(
        "https://api.example.com/slow",
        body=requests.exceptions.ConnectTimeout("too slow"),
    )

    with pytest.raises(requests.exceptions.ConnectTimeout):
        requests.get("https://api.example.com/slow", timeout=1)


@responses.activate
def test_body_can_be_any_exception():
    responses.get("https://api.example.com/boom", body=Exception("kaboom"))
    with pytest.raises(Exception, match="kaboom"):
        requests.get("https://api.example.com/boom")

Passing an exception instance as body is how you cover retry and fallback branches without waiting on a real timeout. Raise the specific exception your code catches: a bare Exception will slip past an except requests.exceptions.RequestException and fail the test for the wrong reason.

Let some hosts through to the networkallow-real-requests

import re
import responses


@responses.activate
def test_mixed():
    responses.add_passthru("https://s3.amazonaws.com")          # prefix
    responses.add_passthru(re.compile(r"https://cdn\.\w+\.com/"))  # pattern

    responses.get("https://api.example.com/x", json={"ok": True})
    # calls to api.example.com are stubbed; the two passthru targets are real


# or mark one registered response as a passthrough
responses.add(
    responses.Response(responses.GET, "https://api.example.com/health", passthrough=True)
)

Passthru is the escape hatch for a service you genuinely cannot fake, such as a signed upload, but it also puts the network back into your test suite: CI now fails when that host is down. Keep the prefixes narrow and never pass a bare scheme, since a prefix of 'https://' disables the whole point of the library.

Start and stop around a test class or fixtureshare-across-tests

import pytest
import responses


@pytest.fixture
def mocked_api():
    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
        rsps.get("https://api.example.com/health", json={"status": "ok"})
        yield rsps


def test_uses_fixture(mocked_api):
    mocked_api.get("https://api.example.com/users", json=[])
    ...


class TestManualLifecycle:
    def setup_method(self):
        self.r_mock = responses.RequestsMock(assert_all_requests_are_fired=True)
        self.r_mock.start()
        self.r_mock.get("https://api.example.com/health", status=200)

    def teardown_method(self):
        self.r_mock.stop()
        self.r_mock.reset()

A yielding fixture is the safest shared setup because the context manager unpatches even when the test raises. If you use start/stop by hand, put stop() and reset() in teardown unconditionally: a missed stop leaves HTTPAdapter.send patched for the rest of the session and the failures land in unrelated tests.

Replace, upsert and remove stubs mid-testedit-registered-responses

import responses
import requests


@responses.activate
def test_swap_response():
    responses.get("https://api.example.com/cfg", json={"flag": False})

    assert requests.get("https://api.example.com/cfg").json() == {"flag": False}

    responses.replace(responses.GET, "https://api.example.com/cfg", json={"flag": True})
    assert requests.get("https://api.example.com/cfg").json() == {"flag": True}

    print(responses.registered())   # inspect the stack while debugging
    responses.remove(responses.GET, "https://api.example.com/cfg")
    responses.reset()

replace() only touches the first stub matching that method and URL and raises if none exists; upsert() adds one instead when it is missing. remove() deletes every match, not just the first, which is the asymmetry that catches people. responses.registered() is the fastest way to see why a match failed.

Capture real traffic to a YAML filerecord-and-replay

import requests
import responses
from responses import _recorder


@_recorder.record(file_path="tests/fixtures/api.yaml")
def capture():
    requests.get("https://api.example.com/users/1")
    requests.get("https://api.example.com/users/2")


@responses.activate
def test_replays_recording():
    responses._add_from_file(file_path="tests/fixtures/api.yaml")
    responses.post("https://api.example.com/users", status=201)   # add extras after

    assert requests.get("https://api.example.com/users/1").status_code == 200

Both entry points start with an underscore and the README labels this BETA, so pin your version if you build a workflow on it. The recorder writes status, body, headers and URL verbatim, which means any API token or personal data in a captured response lands in a file you are about to commit. Scrub before you push, or use vcrpy, which has filtering built in.

Alternatives

PackageRegistryPick it when
respxPyPIYour client is httpx, sync or async, where responses patches nothing and every call escapes to the network
requests-mockPyPIYou prefer adapter-level mounting and a fixture-first API, or you want to mock a single Session rather than all of requests
vcrpyPyPIYou want recorded cassettes as a first-class, stable feature instead of the BETA recorder hidden behind responses._recorder
pytest-httpserverPyPIYou want a real local HTTP server so the socket, TLS and client library behaviour are exercised rather than patched away