diff --git a/CHANGELOG.md b/CHANGELOG.md index 63139d2..cb13185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `assert_requested` parameter is now available on responses and callbacks registration. Allowing to add optional responses while keeping other responses as mandatory. Refer to documentation for more details. ## [0.33.0] - 2024-10-28 ### Added diff --git a/README.md b/README.md index 5bc2ca7..b5b2072 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

Send responses to HTTPX using pytest

+from wsgiref.validate import assert_

Send responses to HTTPX using pytest

pypi version @@ -716,9 +716,17 @@ def pytest_collection_modifyitems(session, config, items): By default, `pytest-httpx` will ensure that every response was requested during test execution. -You can use the `httpx_mock` marker `assert_all_responses_were_requested` option to allow fewer requests than what you registered responses for. +If you want to add an optional response, you can use the `assert_requested` parameter when [registering a response](#add-responses) or [a callback](#add-callbacks). -This option can be useful if you add responses using shared fixtures. +```python +def test_fewer_requests_than_expected(httpx_mock): + # Even if this response never received a corresponding request, the test will not fail at teardown + httpx_mock.add_response(assert_requested=False) +``` + +If you don't have control over the response registration process (shared fixtures), +and you want to allow fewer requests than what you registered responses for, +you can use the `httpx_mock` marker `assert_all_responses_were_requested` option. > [!CAUTION] > Use this option at your own risk of not spotting regression (requests not sent) in your code base! @@ -732,6 +740,18 @@ def test_fewer_requests_than_expected(httpx_mock): httpx_mock.add_response() ``` +Note that the `assert_requested` parameter will take precedence over the `assert_all_responses_were_requested` option. +Meaning you can still register a response that will be checked for execution at teardown even if `assert_all_responses_were_requested` was set to `False`. + +```python +import pytest + +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +def test_force_expected_request(httpx_mock): + # Even if the assert_all_responses_were_requested option is set, the test will fail at teardown if this is not matched + httpx_mock.add_response(assert_requested=True) +``` + #### Allow to not register responses for every request By default, `pytest-httpx` will ensure that every request that was issued was expected. diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index cadec10..8da9dc7 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -304,20 +304,19 @@ def reset(self) -> None: self._requests_not_matched.clear() def _assert_options(self) -> None: - if self._options.assert_all_responses_were_requested: - callbacks_not_executed = [ - matcher for matcher, _ in self._callbacks if not matcher.nb_calls - ] - matchers_description = "\n".join( - [f"- {matcher}" for matcher in callbacks_not_executed] - ) + callbacks_not_executed = [ + matcher for matcher, _ in self._callbacks if matcher.should_have_matched() + ] + matchers_description = "\n".join( + [f"- {matcher}" for matcher in callbacks_not_executed] + ) - assert not callbacks_not_executed, ( - "The following responses are mocked but not requested:\n" - f"{matchers_description}\n" - "\n" - "If this is on purpose, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-register-more-responses-than-what-will-be-requested" - ) + assert not callbacks_not_executed, ( + "The following responses are mocked but not requested:\n" + f"{matchers_description}\n" + "\n" + "If this is on purpose, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-register-more-responses-than-what-will-be-requested" + ) if self._options.assert_all_requests_were_expected: requests_description = "\n".join( diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py index 900ffa9..c526525 100644 --- a/pytest_httpx/_request_matcher.py +++ b/pytest_httpx/_request_matcher.py @@ -39,6 +39,7 @@ def __init__( match_data: Optional[dict[str, Any]] = None, match_files: Optional[Any] = None, match_extensions: Optional[dict[str, Any]] = None, + assert_requested: Optional[bool] = None, ): self._options = options self.nb_calls = 0 @@ -55,6 +56,7 @@ def __init__( else proxy_url ) self.extensions = match_extensions + self.assert_requested = options.assert_all_responses_were_requested if assert_requested is None else assert_requested if self._is_matching_body_more_than_one_way(): raise ValueError( "Only one way of matching against the body can be provided. " @@ -177,6 +179,10 @@ def _extensions_match(self, request: httpx.Request) -> bool: for extension_name, extension_value in self.extensions.items() ) + def should_have_matched(self) -> bool: + """Return True if the matcher did not serve its purpose.""" + return self.assert_requested and not self.nb_calls + def __str__(self) -> str: if self._options.can_send_already_matched_responses: matcher_description = f"Match {self.method or 'every'} request" diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index ee529c6..3b52703 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -2435,3 +2435,40 @@ async def test_extensions_not_matching(httpx_mock: HTTPXMock) -> None: == """No response can be found for GET request on https://test_url with {'test': 'value2'} extensions amongst: - Match any request with {'test': 'value'} extensions""" ) + + +@pytest.mark.asyncio +async def test_optional_response_not_matched(httpx_mock: HTTPXMock) -> None: + # This response is optional and the fact that it was never requested should not trigger anything + httpx_mock.add_response(url="https://test_url", assert_requested=False) + httpx_mock.add_response(url="https://test_url2") + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url2") + assert response.content == b"" + + +@pytest.mark.asyncio +async def test_optional_response_matched(httpx_mock: HTTPXMock) -> None: + # This response is optional and the fact that it was never requested should not trigger anything + httpx_mock.add_response(url="https://test_url", assert_requested=False) + httpx_mock.add_response(url="https://test_url2") + + async with httpx.AsyncClient() as client: + response1 = await client.get("https://test_url") + response2 = await client.get("https://test_url2") + assert response1.content == b"" + assert response2.content == b"" + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +async def test_mandatory_response_matched(httpx_mock: HTTPXMock) -> None: + # This response is optional and the fact that it was never requested should not trigger anything + httpx_mock.add_response(url="https://test_url") + # This response MUST be requested + httpx_mock.add_response(url="https://test_url2", assert_requested=True) + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url2") + assert response.content == b"" diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 802d2fa..49642ad 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -2129,3 +2129,37 @@ def test_extensions_not_matching(httpx_mock: HTTPXMock) -> None: == """No response can be found for GET request on https://test_url with {'test': 'value2'} extensions amongst: - Match any request with {'test': 'value'} extensions""" ) + + +def test_optional_response_not_matched(httpx_mock: HTTPXMock) -> None: + # This response is optional and the fact that it was never requested should not trigger anything + httpx_mock.add_response(url="https://test_url", assert_requested=False) + httpx_mock.add_response(url="https://test_url2") + + with httpx.Client() as client: + response = client.get("https://test_url2") + assert response.content == b"" + + +def test_optional_response_matched(httpx_mock: HTTPXMock) -> None: + # This response is optional and the fact that it was never requested should not trigger anything + httpx_mock.add_response(url="https://test_url", assert_requested=False) + httpx_mock.add_response(url="https://test_url2") + + with httpx.Client() as client: + response1 = client.get("https://test_url") + response2 = client.get("https://test_url2") + assert response1.content == b"" + assert response2.content == b"" + + +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) +def test_mandatory_response_matched(httpx_mock: HTTPXMock) -> None: + # This response is optional and the fact that it was never requested should not trigger anything + httpx_mock.add_response(url="https://test_url") + # This response MUST be requested + httpx_mock.add_response(url="https://test_url2", assert_requested=True) + + with httpx.Client() as client: + response = client.get("https://test_url2") + assert response.content == b"" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 59d1ca9..eab7899 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -685,3 +685,35 @@ def test_invalid_marker(httpx_mock): result = testdir.runpytest() result.assert_outcomes(errors=1) result.stdout.re_match_lines([r".*got an unexpected keyword argument 'foo'"]) + + +def test_mandatory_response_not_matched(testdir: Testdir) -> None: + """ + assert_requested MUST take precedence over assert_all_responses_were_requested. + """ + testdir.makepyfile( + """ + import httpx + import pytest + + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + def test_mandatory_response_not_matched(httpx_mock): + # This response is optional and the fact that it was never requested should not trigger anything + httpx_mock.add_response(url="https://test_url") + # This response MUST be requested + httpx_mock.add_response(url="https://test_url2", assert_requested=True) + + """ + ) + result = testdir.runpytest() + result.assert_outcomes(errors=1, passed=1) + # Assert the teardown assertion failure + result.stdout.fnmatch_lines( + [ + "*AssertionError: The following responses are mocked but not requested:", + "* - Match any request on https://test_url2", + "* ", + "* If this is on purpose, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-register-more-responses-than-what-will-be-requested", + ], + consecutive=True, + )