Skip to content

Commit

Permalink
Merge pull request #168 from Colin-b/allow_to_register_optional_respo…
Browse files Browse the repository at this point in the history
…nses

Allow to register optional responses
  • Loading branch information
Colin-b authored Nov 18, 2024
2 parents d737f01 + 49ff0e8 commit 066b4d2
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 15 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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.
Expand Down
25 changes: 12 additions & 13 deletions pytest_httpx/_httpx_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions pytest_httpx/_request_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. "
Expand Down Expand Up @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions tests/test_httpx_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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""
34 changes: 34 additions & 0 deletions tests/test_httpx_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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""
32 changes: 32 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

0 comments on commit 066b4d2

Please sign in to comment.