From 66ce1cbf9bfa3fd34ace8e1a11c5a9aeec7b2577 Mon Sep 17 00:00:00 2001 From: Rob Percival Date: Sun, 2 Nov 2025 23:02:12 +0000 Subject: [PATCH 1/4] Add assert_on_exception parameter to RequestsMock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When set to True, assertions about unfired requests will be raised even when an exception occurs in the context manager. This provides valuable debugging context about which mocked requests were or weren't called when debugging test failures. By default (assert_on_exception=False), the assertion is suppressed to avoid masking the original exception. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.rst | 28 +++++++++++++++++ responses/__init__.py | 4 ++- responses/tests/test_responses.py | 51 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a9c73e7d..5fef0204 100644 --- a/README.rst +++ b/README.rst @@ -917,6 +917,34 @@ the ``assert_all_requests_are_fired`` value: content_type="application/json", ) +By default, when an exception occurs within the context manager, the assertion +about unfired requests is suppressed to avoid masking the original exception. +However, this can hide valuable context about which mocked requests were or weren't +called. You can use ``assert_on_exception=True`` to always see this information when +debugging test failures. + +.. code-block:: python + + import responses + import requests + + + def test_with_assert_on_exception(): + with responses.RequestsMock( + assert_all_requests_are_fired=True, assert_on_exception=True + ) as rsps: + rsps.add(responses.GET, "http://example.com/users", body="test") + rsps.add(responses.GET, "http://example.com/profile", body="test") + requests.get("http://example.com/users") + raise ValueError("Something went wrong") + + # Output: + # ValueError: Something went wrong + # + # During handling of the above exception, another exception occurred: + # + # AssertionError: Not all requests have been executed [('GET', 'http://example.com/profile')] + Assert Request Call Count ------------------------- diff --git a/responses/__init__.py b/responses/__init__.py index 89cc9a5c..9b493524 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -730,11 +730,13 @@ def __init__( registry: Type[FirstMatchRegistry] = FirstMatchRegistry, *, real_adapter_send: "_HTTPAdapterSend" = _real_send, + assert_on_exception: bool = False, ) -> None: self._calls: CallList = CallList() self.reset() self._registry: FirstMatchRegistry = registry() # call only after reset self.assert_all_requests_are_fired: bool = assert_all_requests_are_fired + self.assert_on_exception: bool = assert_on_exception self.response_callback: Optional[Callable[[Any], Response]] = response_callback self.passthru_prefixes: Tuple[_URLPatternType, ...] = tuple(passthru_prefixes) self.target: str = target @@ -993,7 +995,7 @@ def __enter__(self) -> "RequestsMock": def __exit__(self, type: Any, value: Any, traceback: Any) -> None: success = type is None try: - self.stop(allow_assert=success) + self.stop(allow_assert=success or self.assert_on_exception) finally: self.reset() diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py index 66711a51..9708309b 100644 --- a/responses/tests/test_responses.py +++ b/responses/tests/test_responses.py @@ -1217,6 +1217,57 @@ def test_some_second_function(): assert_reset() +def test_assert_on_exception(): + """Test that assert_on_exception controls assertion behavior during exceptions.""" + + def run(): + # Default behavior (assert_on_exception=False): + # assertion should NOT be raised when an exception occurs + with pytest.raises(ValueError) as value_exc_info: + with responses.RequestsMock( + assert_all_requests_are_fired=True, assert_on_exception=False + ) as m: + m.add(responses.GET, "http://example.com", body=b"test") + m.add(responses.GET, "http://not-called.com", body=b"test") + requests.get("http://example.com") + raise ValueError("Main error") + + # Should only see the ValueError, not the AssertionError about unfired requests + assert "Main error" in str(value_exc_info.value) + assert "not-called.com" not in str(value_exc_info.value) + + # With assert_on_exception=True: assertion WILL be raised even with an exception + # The AssertionError will be the primary exception, with the ValueError as context + with pytest.raises(AssertionError) as assert_exc_info: + with responses.RequestsMock( + assert_all_requests_are_fired=True, assert_on_exception=True + ) as m: + m.add(responses.GET, "http://example.com", body=b"test") + m.add(responses.GET, "http://not-called.com", body=b"test") + requests.get("http://example.com") + raise ValueError("Main error") + + # The AssertionError should mention the unfired request + assert "not-called.com" in str(assert_exc_info.value) + # Python automatically chains exceptions, so we should see both in the traceback + assert isinstance(assert_exc_info.value.__context__, ValueError) + assert "Main error" in str(assert_exc_info.value.__context__) + + # Test that both work normally when no other exception occurs + with pytest.raises(AssertionError) as assert_exc_info2: + with responses.RequestsMock( + assert_all_requests_are_fired=True, assert_on_exception=True + ) as m: + m.add(responses.GET, "http://example.com", body=b"test") + m.add(responses.GET, "http://not-called.com", body=b"test") + requests.get("http://example.com") + + assert "not-called.com" in str(assert_exc_info2.value) + + run() + assert_reset() + + def test_allow_redirects_samehost(): redirecting_url = "http://example.com" final_url_path = "/1" From 2094c12eda404916c178d8ce5a5224a9a85e786a Mon Sep 17 00:00:00 2001 From: Rob Percival Date: Sun, 2 Nov 2025 23:23:00 +0000 Subject: [PATCH 2/4] Add decorator support for assert_on_exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @responses.activate decorator now accepts an assert_on_exception parameter, providing a convenient way to enable assertion checking even when exceptions occur: @responses.activate( assert_all_requests_are_fired=True, assert_on_exception=True ) def test_my_api(): ... This is consistent with the existing decorator support for assert_all_requests_are_fired and registry parameters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- responses/__init__.py | 23 ++++++++++++----- responses/tests/test_responses.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/responses/__init__.py b/responses/__init__.py index 9b493524..06f677cb 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -179,6 +179,7 @@ def get_wrapped( *, registry: Optional[Any] = None, assert_all_requests_are_fired: Optional[bool] = None, + assert_on_exception: Optional[bool] = None, ) -> Callable[..., Any]: """Wrap provided function inside ``responses`` context manager. @@ -195,6 +196,8 @@ def get_wrapped( Custom registry that should be applied. See ``responses.registries`` assert_all_requests_are_fired : bool Raise an error if not all registered responses were executed. + assert_on_exception : bool + Raise assertion errors even when an exception occurs in the context manager. Returns ------- @@ -208,6 +211,12 @@ def get_wrapped( new=assert_all_requests_are_fired, ) + assert_on_exception_mock = std_mock.patch.object( + target=responses, + attribute="assert_on_exception", + new=assert_on_exception, + ) + if inspect.iscoroutinefunction(func): # set asynchronous wrapper if requestor function is asynchronous @wraps(func) @@ -215,7 +224,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] if registry is not None: responses._set_registry(registry) - with assert_mock, responses: + with assert_mock, assert_on_exception_mock, responses: return await func(*args, **kwargs) else: @@ -225,9 +234,9 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] if registry is not None: responses._set_registry(registry) - with assert_mock, responses: - # set 'assert_all_requests_are_fired' temporarily for a single run. - # Mock automatically unsets to avoid leakage to another decorated + with assert_mock, assert_on_exception_mock, responses: + # set 'assert_all_requests_are_fired' and 'assert_on_exception' temporarily for a + # single run. Mock automatically unsets to avoid leakage to another decorated # function since we still apply the value on 'responses.mock' object return func(*args, **kwargs) @@ -1009,9 +1018,9 @@ def activate( *, registry: Type[Any] = ..., assert_all_requests_are_fired: bool = ..., + assert_on_exception: bool = ..., ) -> Callable[["_F"], "_F"]: - """Overload for scenario when - 'responses.activate(registry=, assert_all_requests_are_fired=True)' is used. + """Overload for scenario when 'responses.activate(...)' is used. See https://github.com/getsentry/responses/pull/469 for more details """ @@ -1021,6 +1030,7 @@ def activate( *, registry: Optional[Type[Any]] = None, assert_all_requests_are_fired: bool = False, + assert_on_exception: bool = False, ) -> Union[Callable[["_F"], "_F"], "_F"]: if func is not None: return get_wrapped(func, self) @@ -1031,6 +1041,7 @@ def deco_activate(function: "_F") -> Callable[..., Any]: self, registry=registry, assert_all_requests_are_fired=assert_all_requests_are_fired, + assert_on_exception=assert_on_exception, ) return deco_activate diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py index 9708309b..46cb82de 100644 --- a/responses/tests/test_responses.py +++ b/responses/tests/test_responses.py @@ -1268,6 +1268,48 @@ def run(): assert_reset() +def test_assert_on_exception_with_decorator(): + """Test that assert_on_exception works with the @responses.activate decorator.""" + + # Default behavior with decorator: assertion should NOT be raised when an exception occurs + with pytest.raises(ValueError) as value_exc_info: + + @responses.activate(assert_all_requests_are_fired=True) + def test_default(): + responses.add(responses.GET, "http://example.com", body=b"test") + responses.add(responses.GET, "http://not-called.com", body=b"test") + requests.get("http://example.com") + raise ValueError("Main error") + + test_default() + + # Should only see the ValueError, not the AssertionError about unfired requests + assert "Main error" in str(value_exc_info.value) + assert "not-called.com" not in str(value_exc_info.value) + + # With assert_on_exception=True in decorator: assertion WILL be raised even with an exception + with pytest.raises(AssertionError) as assert_exc_info: + + @responses.activate( + assert_all_requests_are_fired=True, assert_on_exception=True + ) + def test_with_assert_on_exception(): + responses.add(responses.GET, "http://example.com", body=b"test") + responses.add(responses.GET, "http://not-called.com", body=b"test") + requests.get("http://example.com") + raise ValueError("Main error") + + test_with_assert_on_exception() + + # The AssertionError should mention the unfired request + assert "not-called.com" in str(assert_exc_info.value) + # Python automatically chains exceptions, so we should see both in the traceback + assert isinstance(assert_exc_info.value.__context__, ValueError) + assert "Main error" in str(assert_exc_info.value.__context__) + + assert_reset() + + def test_allow_redirects_samehost(): redirecting_url = "http://example.com" final_url_path = "/1" From c27fb47ec15933012b01aff9039469b8fc179c09 Mon Sep 17 00:00:00 2001 From: Rob Percival Date: Mon, 3 Nov 2025 00:30:44 +0000 Subject: [PATCH 3/4] Update CHANGES for assert_on_exception feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the new assert_on_exception parameter in version 0.26.0. This is a minor version bump (not patch) because we're adding new functionality to the public API, even though it's fully backward compatible with existing code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGES | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES b/CHANGES index c1c617d7..11d6cea1 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,11 @@ +0.26.0 +------ + +* Added `assert_on_exception` parameter to `RequestsMock` and `@responses.activate` decorator. + When set to True, assertions about unfired requests will be raised even when an exception + occurs in the wrapped code, providing valuable debugging context about which mocked + requests were or weren't called. + 0.25.8 ------ From 119be7327e66c7b003c5d7d5f8f704a7f1a6fb22 Mon Sep 17 00:00:00 2001 From: Rob Percival Date: Wed, 12 Nov 2025 23:59:51 +0000 Subject: [PATCH 4/4] Always raise assertions on exception when assert_all_requests_are_fired=True MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a breaking change that modifies the behavior of assert_all_requests_are_fired to always raise assertions about unfired requests even when an exception occurs in the context manager or decorated function. Previously, when an exception occurred, assertions about unfired requests were suppressed to avoid masking the original exception. However, this behavior hid valuable debugging context about which mocked requests were or weren't called. The new behavior always raises assertions (when assert_all_requests_are_fired=True), with the original exception chained as context. This provides developers with complete information about both the original failure and the state of mocked requests. Changes: - Updated __exit__ to always pass allow_assert=True to stop() - Removed conditional logic that suppressed assertions on exception - Updated tests to verify assertions are raised during exceptions - Updated documentation to reflect new behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGES | 9 ++-- README.rst | 15 +++---- responses/__init__.py | 23 ++--------- responses/tests/test_responses.py | 68 ++++++++----------------------- 4 files changed, 33 insertions(+), 82 deletions(-) diff --git a/CHANGES b/CHANGES index 11d6cea1..526f8253 100644 --- a/CHANGES +++ b/CHANGES @@ -1,10 +1,11 @@ 0.26.0 ------ -* Added `assert_on_exception` parameter to `RequestsMock` and `@responses.activate` decorator. - When set to True, assertions about unfired requests will be raised even when an exception - occurs in the wrapped code, providing valuable debugging context about which mocked - requests were or weren't called. +* **Breaking change**: When using `assert_all_requests_are_fired=True`, assertions about + unfired requests are now raised even when an exception occurs in the context manager or + decorated function. Previously, these assertions were suppressed when exceptions occurred. + This new behavior provides valuable debugging context about which mocked requests were + or weren't called. 0.25.8 ------ diff --git a/README.rst b/README.rst index 5fef0204..a7919906 100644 --- a/README.rst +++ b/README.rst @@ -917,11 +917,10 @@ the ``assert_all_requests_are_fired`` value: content_type="application/json", ) -By default, when an exception occurs within the context manager, the assertion -about unfired requests is suppressed to avoid masking the original exception. -However, this can hide valuable context about which mocked requests were or weren't -called. You can use ``assert_on_exception=True`` to always see this information when -debugging test failures. +When ``assert_all_requests_are_fired=True`` and an exception occurs within the +context manager, assertions about unfired requests will still be raised. This +provides valuable context about which mocked requests were or weren't called +when debugging test failures. .. code-block:: python @@ -929,10 +928,8 @@ debugging test failures. import requests - def test_with_assert_on_exception(): - with responses.RequestsMock( - assert_all_requests_are_fired=True, assert_on_exception=True - ) as rsps: + def test_with_exception(): + with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps: rsps.add(responses.GET, "http://example.com/users", body="test") rsps.add(responses.GET, "http://example.com/profile", body="test") requests.get("http://example.com/users") diff --git a/responses/__init__.py b/responses/__init__.py index 06f677cb..ca2d17f8 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -179,7 +179,6 @@ def get_wrapped( *, registry: Optional[Any] = None, assert_all_requests_are_fired: Optional[bool] = None, - assert_on_exception: Optional[bool] = None, ) -> Callable[..., Any]: """Wrap provided function inside ``responses`` context manager. @@ -196,8 +195,6 @@ def get_wrapped( Custom registry that should be applied. See ``responses.registries`` assert_all_requests_are_fired : bool Raise an error if not all registered responses were executed. - assert_on_exception : bool - Raise assertion errors even when an exception occurs in the context manager. Returns ------- @@ -211,12 +208,6 @@ def get_wrapped( new=assert_all_requests_are_fired, ) - assert_on_exception_mock = std_mock.patch.object( - target=responses, - attribute="assert_on_exception", - new=assert_on_exception, - ) - if inspect.iscoroutinefunction(func): # set asynchronous wrapper if requestor function is asynchronous @wraps(func) @@ -224,7 +215,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] if registry is not None: responses._set_registry(registry) - with assert_mock, assert_on_exception_mock, responses: + with assert_mock, responses: return await func(*args, **kwargs) else: @@ -234,8 +225,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] if registry is not None: responses._set_registry(registry) - with assert_mock, assert_on_exception_mock, responses: - # set 'assert_all_requests_are_fired' and 'assert_on_exception' temporarily for a + with assert_mock, responses: + # set 'assert_all_requests_are_fired' temporarily for a # single run. Mock automatically unsets to avoid leakage to another decorated # function since we still apply the value on 'responses.mock' object return func(*args, **kwargs) @@ -739,13 +730,11 @@ def __init__( registry: Type[FirstMatchRegistry] = FirstMatchRegistry, *, real_adapter_send: "_HTTPAdapterSend" = _real_send, - assert_on_exception: bool = False, ) -> None: self._calls: CallList = CallList() self.reset() self._registry: FirstMatchRegistry = registry() # call only after reset self.assert_all_requests_are_fired: bool = assert_all_requests_are_fired - self.assert_on_exception: bool = assert_on_exception self.response_callback: Optional[Callable[[Any], Response]] = response_callback self.passthru_prefixes: Tuple[_URLPatternType, ...] = tuple(passthru_prefixes) self.target: str = target @@ -1002,9 +991,8 @@ def __enter__(self) -> "RequestsMock": return self def __exit__(self, type: Any, value: Any, traceback: Any) -> None: - success = type is None try: - self.stop(allow_assert=success or self.assert_on_exception) + self.stop(allow_assert=True) finally: self.reset() @@ -1018,7 +1006,6 @@ def activate( *, registry: Type[Any] = ..., assert_all_requests_are_fired: bool = ..., - assert_on_exception: bool = ..., ) -> Callable[["_F"], "_F"]: """Overload for scenario when 'responses.activate(...)' is used. See https://github.com/getsentry/responses/pull/469 for more details @@ -1030,7 +1017,6 @@ def activate( *, registry: Optional[Type[Any]] = None, assert_all_requests_are_fired: bool = False, - assert_on_exception: bool = False, ) -> Union[Callable[["_F"], "_F"], "_F"]: if func is not None: return get_wrapped(func, self) @@ -1041,7 +1027,6 @@ def deco_activate(function: "_F") -> Callable[..., Any]: self, registry=registry, assert_all_requests_are_fired=assert_all_requests_are_fired, - assert_on_exception=assert_on_exception, ) return deco_activate diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py index 46cb82de..0f877f43 100644 --- a/responses/tests/test_responses.py +++ b/responses/tests/test_responses.py @@ -1163,11 +1163,13 @@ def run(): with responses.RequestsMock() as m: m.add(responses.GET, "http://example.com", body=b"test") - # check that assert_all_requests_are_fired doesn't swallow exceptions - with pytest.raises(ValueError): + # check that assert_all_requests_are_fired raises assertions even with exceptions + with pytest.raises(AssertionError) as exc_info: with responses.RequestsMock() as m: m.add(responses.GET, "http://example.com", body=b"test") raise ValueError() + # The ValueError should be chained as the context + assert isinstance(exc_info.value.__context__, ValueError) # check that assert_all_requests_are_fired=True doesn't remove urls with responses.RequestsMock(assert_all_requests_are_fired=True) as m: @@ -1217,31 +1219,14 @@ def test_some_second_function(): assert_reset() -def test_assert_on_exception(): - """Test that assert_on_exception controls assertion behavior during exceptions.""" +def test_assert_all_requests_are_fired_during_exception(): + """Test that assertions are raised even when an exception occurs.""" def run(): - # Default behavior (assert_on_exception=False): - # assertion should NOT be raised when an exception occurs - with pytest.raises(ValueError) as value_exc_info: - with responses.RequestsMock( - assert_all_requests_are_fired=True, assert_on_exception=False - ) as m: - m.add(responses.GET, "http://example.com", body=b"test") - m.add(responses.GET, "http://not-called.com", body=b"test") - requests.get("http://example.com") - raise ValueError("Main error") - - # Should only see the ValueError, not the AssertionError about unfired requests - assert "Main error" in str(value_exc_info.value) - assert "not-called.com" not in str(value_exc_info.value) - - # With assert_on_exception=True: assertion WILL be raised even with an exception + # Assertions WILL be raised even with an exception # The AssertionError will be the primary exception, with the ValueError as context with pytest.raises(AssertionError) as assert_exc_info: - with responses.RequestsMock( - assert_all_requests_are_fired=True, assert_on_exception=True - ) as m: + with responses.RequestsMock(assert_all_requests_are_fired=True) as m: m.add(responses.GET, "http://example.com", body=b"test") m.add(responses.GET, "http://not-called.com", body=b"test") requests.get("http://example.com") @@ -1253,11 +1238,9 @@ def run(): assert isinstance(assert_exc_info.value.__context__, ValueError) assert "Main error" in str(assert_exc_info.value.__context__) - # Test that both work normally when no other exception occurs + # Test that it also works normally when no other exception occurs with pytest.raises(AssertionError) as assert_exc_info2: - with responses.RequestsMock( - assert_all_requests_are_fired=True, assert_on_exception=True - ) as m: + with responses.RequestsMock(assert_all_requests_are_fired=True) as m: m.add(responses.GET, "http://example.com", body=b"test") m.add(responses.GET, "http://not-called.com", body=b"test") requests.get("http://example.com") @@ -1268,38 +1251,23 @@ def run(): assert_reset() -def test_assert_on_exception_with_decorator(): - """Test that assert_on_exception works with the @responses.activate decorator.""" +def test_assert_all_requests_are_fired_during_exception_with_decorator(): + """Test that assertions are raised even when an exception occurs. - # Default behavior with decorator: assertion should NOT be raised when an exception occurs - with pytest.raises(ValueError) as value_exc_info: - - @responses.activate(assert_all_requests_are_fired=True) - def test_default(): - responses.add(responses.GET, "http://example.com", body=b"test") - responses.add(responses.GET, "http://not-called.com", body=b"test") - requests.get("http://example.com") - raise ValueError("Main error") - - test_default() - - # Should only see the ValueError, not the AssertionError about unfired requests - assert "Main error" in str(value_exc_info.value) - assert "not-called.com" not in str(value_exc_info.value) + This tests the behavior with the @responses.activate decorator. + """ - # With assert_on_exception=True in decorator: assertion WILL be raised even with an exception + # Assertions WILL be raised even with an exception when using the decorator with pytest.raises(AssertionError) as assert_exc_info: - @responses.activate( - assert_all_requests_are_fired=True, assert_on_exception=True - ) - def test_with_assert_on_exception(): + @responses.activate(assert_all_requests_are_fired=True) + def test_with_exception(): responses.add(responses.GET, "http://example.com", body=b"test") responses.add(responses.GET, "http://not-called.com", body=b"test") requests.get("http://example.com") raise ValueError("Main error") - test_with_assert_on_exception() + test_with_exception() # The AssertionError should mention the unfired request assert "not-called.com" in str(assert_exc_info.value)