From f663eee4657c849fc3fadbfce5e3ade9caca8ffc Mon Sep 17 00:00:00 2001 From: Colin-b Date: Fri, 27 Sep 2024 00:43:07 +0200 Subject: [PATCH] Already matched responses are not sent by default anymore --- CHANGELOG.md | 9 +++- README.md | 93 ++++++++++++++++++------------------- pytest_httpx/__init__.py | 2 +- pytest_httpx/_httpx_mock.py | 12 +++-- tests/test_httpx_async.py | 57 ++++++++++++++++++++++- tests/test_httpx_sync.py | 51 +++++++++++++++++++- tests/test_plugin.py | 55 ++++++++++++++++++++++ 7 files changed, 223 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da5e939..55cdc0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- The following option is now available: + - `can_send_already_matched_responses` (boolean), defaulting to `False`. - Assertion failure message in case of unmatched responses is now linking documentation on how to deactivate the check. - Assertion failure message in case of unmatched requests is now linking documentation on how to deactivate the check. ### Fixed +- Documentation now clearly state the risks associated with changing the default options. - Assertion failure message in case of unmatched requests at teardown is now describing requests in a more user-friendly way. - Assertion failure message in case of unmatched requests at teardown is now prefixing requests with `- ` to highlight the fact that this is a list, preventing misapprehension in case only one element exists. - Assertion failure message in case of unmatched responses at teardown is now prefixing responses with `- ` to highlight the fact that this is a list, preventing misapprehension in case only one element exists. - TimeoutException message issued in case of unmatched request is now prefixing available responses with `- ` to highlight the fact that this is a list, preventing misapprehension in case only one element exists. ### Changed +- Last registered matching response will not be reused by default anymore in case all matching responses have already been sent. + - This behavior can be changed thanks to the new `pytest.mark.httpx_mock(can_send_already_matched_responses=True)` option. + - The incentive behind this change is to spot regression if a request was issued more than the expected number of times. - `HTTPXMock` class was only exposed for type hinting purpose. This is now explained in the class docstring. - As a result this is the last time a change to `__init__` signature will be documented and considered a breaking change. - Future changes will not be documented and will be considered as internal refactoring not worth a version bump. @@ -33,7 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.31.0] - 2024-09-20 ### Changed - Tests will now fail at teardown by default if some requests were issued but were not matched. - - This behavior can be changed thanks to the new ``pytest.mark.httpx_mock(assert_all_requests_were_expected=False)`` option. + - This behavior can be changed thanks to the new `pytest.mark.httpx_mock(assert_all_requests_were_expected=False)` option. + - The incentive behind this change is to spot unexpected requests in case code is swallowing `httpx.TimeoutException`. - The `httpx_mock` fixture is now configured using a marker (many thanks to [`Frazer McLean`](https://github.com/RazerM)). ```python # Apply marker to whole module diff --git a/README.md b/README.md index 6b37a36..f8d1745 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Number of downloads

+> [!NOTE] > Version 1.0.0 will be released once httpx is considered as stable (release of 1.0.0). > > However, current state can be considered as stable. @@ -28,6 +29,7 @@ Once installed, `httpx_mock` [`pytest`](https://docs.pytest.org/en/latest/) fixt - [Configuration](#configuring-httpx_mock) - [Register more responses than requested](#allow-to-register-more-responses-than-what-will-be-requested) - [Register less responses than requested](#allow-to-not-register-responses-for-every-request) + - [Allow to register a response for more than one request](#allow-to-register-a-response-for-more-than-one-request) - [Do not mock some requests](#do-not-mock-some-requests) - [Migrating](#migrating-to-pytest-httpx) - [responses](#from-responses) @@ -59,13 +61,13 @@ async def test_something_async(httpx_mock): If all registered responses are not sent back during test execution, the test case will fail at teardown [(unless you turned `assert_all_responses_were_requested` option off)](#allow-to-register-more-responses-than-what-will-be-requested). -Default response is a HTTP/1.1 200 (OK) without any body. +Default response is a `HTTP/1.1` `200 (OK)` without any body. ### How response is selected In case more than one response match request, the first one not yet sent (according to the registration order) will be sent. -In case all matching responses have been sent, the last one (according to the registration order) will be sent. +In case all matching responses have been sent once, the request will [not be considered as matched](#in-case-no-response-can-be-found) [(unless you turned `can_send_already_matched_responses` option on)](#allow-to-register-a-response-for-more-than-one-request). You can add criteria so that response will be sent only in case of a more specific matching. @@ -366,7 +368,7 @@ def test_status_code(httpx_mock: HTTPXMock): Use `headers` parameter to specify the extra headers of the response. -Any valid httpx headers type is supported, you can submit headers as a dict (str or bytes), a list of 2-tuples (str or bytes) or a `httpx.Header` instance. +Any valid httpx headers type is supported, you can submit headers as a dict (str or bytes), a list of 2-tuples (str or bytes) or a [`httpx.Header`](https://www.python-httpx.org/api/#headers) instance. ```python import httpx @@ -450,10 +452,11 @@ Callback should expect one parameter, the received [`httpx.Request`](https://www If all callbacks are not executed during test execution, the test case will fail at teardown [(unless you turned `assert_all_responses_were_requested` option off)](#allow-to-register-more-responses-than-what-will-be-requested). Note that callbacks are considered as responses, and thus are [selected the same way](#how-response-is-selected). +Meaning that you can transpose `httpx_mock.add_response` calls in the related examples into `httpx_mock.add_callback`. ### Dynamic responses -Callback should return a `httpx.Response`. +Callback should return a [`httpx.Response`](https://www.python-httpx.org/api/#response) instance. ```python import httpx @@ -527,7 +530,11 @@ def test_exception_raising(httpx_mock: HTTPXMock): ``` -Note that default behavior is to send an `httpx.TimeoutException` in case no response can be found. You can then test this kind of exception this way: +#### In case no response can be found + +The default behavior is to instantly raise a [`httpx.TimeoutException`](https://www.python-httpx.org/advanced/timeouts/) in case no matching response can be found. + +The exception message will display the request and every registered responses to help you identify any possible mismatch. ```python import httpx @@ -584,49 +591,8 @@ def test_no_request(httpx_mock: HTTPXMock): You can add criteria so that requests will be returned only in case of a more specific matching. -#### Matching on URL - -`url` parameter can either be a string, a python [re.Pattern](https://docs.python.org/3/library/re.html) instance or a [httpx.URL](https://www.python-httpx.org/api/#url) instance. - -Matching is performed on the full URL, query parameters included. - -Order of parameters in the query string does not matter, however order of values do matter if the same parameter is provided more than once. - -#### Matching on HTTP method - -Use `method` parameter to specify the HTTP method (POST, PUT, DELETE, PATCH, HEAD) of the requests to retrieve. - -`method` parameter must be a string. It will be upper-cased, so it can be provided lower cased. - -Matching is performed on equality. - -#### Matching on proxy URL - -`proxy_url` parameter can either be a string, a python [re.Pattern](https://docs.python.org/3/library/re.html) instance or a [httpx.URL](https://www.python-httpx.org/api/#url) instance. - -Matching is performed on the full proxy URL, query parameters included. - -Order of parameters in the query string does not matter, however order of values do matter if the same parameter is provided more than once. - -#### Matching on HTTP headers - -Use `match_headers` parameter to specify the HTTP headers executing the callback. - -Matching is performed on equality for each provided header. - -#### Matching on HTTP body - -Use `match_content` parameter to specify the full HTTP body executing the callback. - -Matching is performed on equality. - -##### Matching on HTTP JSON body - -Use `match_json` parameter to specify the JSON decoded HTTP body executing the callback. - -Matching is performed on equality. You can however use `unittest.mock.ANY` to do partial matching. - -Note that `match_content` cannot be provided if `match_json` is also provided. +Note that requests are [selected the same way as responses](#how-response-is-selected). +Meaning that you can transpose `httpx_mock.add_response` calls in the related examples into `httpx_mock.get_requests` or `httpx_mock.get_request`. ## Configuring httpx_mock @@ -673,6 +639,9 @@ You can use the `httpx_mock` marker `assert_all_responses_were_requested` option This option can be useful if you add responses using shared fixtures. +> [!CAUTION] +> Use this option at your own risk of not spotting regression (requests not sent) in your code base! + ```python import pytest @@ -687,7 +656,9 @@ def test_fewer_requests_than_expected(httpx_mock): By default, `pytest-httpx` will ensure that every request that was issued was expected. You can use the `httpx_mock` marker `assert_all_requests_were_expected` option to allow more requests than what you registered responses for. -Use this option at your own risk of not spotting regression in your code base! + +> [!CAUTION] +> Use this option at your own risk of not spotting regression (unexpected requests) in your code base! ```python import pytest @@ -701,6 +672,30 @@ def test_more_requests_than_expected(httpx_mock): client.get("https://test_url") ``` +#### Allow to register a response for more than one request + +By default, `pytest-httpx` will ensure that every request that was issued was expected. + +You can use the `httpx_mock` marker `can_send_already_matched_responses` option to allow multiple requests to match the same registered response. + +With this option, in case all matching responses have been sent at least once, the last one (according to the registration order) will be sent. + +> [!CAUTION] +> Use this option at your own risk of not spotting regression (requests issued more than the expected number of times) in your code base! + +```python +import pytest +import httpx + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_more_requests_than_responses(httpx_mock): + httpx_mock.add_response() + with httpx.Client() as client: + client.get("https://test_url") + # Even if only one response was registered, the test will not fail at teardown as this request will also be matched + client.get("https://test_url") +``` + #### Do not mock some requests By default, `pytest-httpx` will mock every request. diff --git a/pytest_httpx/__init__.py b/pytest_httpx/__init__.py index fe1f9de..66b2469 100644 --- a/pytest_httpx/__init__.py +++ b/pytest_httpx/__init__.py @@ -71,5 +71,5 @@ async def mocked_handle_async_request( def pytest_configure(config: Config) -> None: config.addinivalue_line( "markers", - "httpx_mock(*, assert_all_responses_were_requested=True, assert_all_requests_were_expected=True, non_mocked_hosts=[]): Configure httpx_mock fixture.", + "httpx_mock(*, assert_all_responses_were_requested=True, assert_all_requests_were_expected=True, can_send_already_matched_responses=False, non_mocked_hosts=[]): Configure httpx_mock fixture.", ) diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index 96c3520..55657d7 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -16,10 +16,12 @@ def __init__( *, assert_all_responses_were_requested: bool = True, assert_all_requests_were_expected: bool = True, + can_send_already_matched_responses: bool = False, non_mocked_hosts: Optional[list[str]] = None, ) -> None: self.assert_all_responses_were_requested = assert_all_responses_were_requested self.assert_all_requests_were_expected = assert_all_requests_were_expected + self.can_send_already_matched_responses = can_send_already_matched_responses if non_mocked_hosts is None: non_mocked_hosts = [] @@ -241,9 +243,13 @@ def _get_callback( matcher.nb_calls += 1 return callback - # Or the last registered - matcher.nb_calls += 1 - return callback + # Or the last registered (if it can be reused) + if self._options.can_send_already_matched_responses: + matcher.nb_calls += 1 + return callback + + # All callbacks have already been matched and last registered cannot be reused + return None def get_requests(self, **matchers: Any) -> list[httpx.Request]: """ diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index b05576a..ab5890c 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -45,11 +45,22 @@ async def test_url_matching(httpx_mock: HTTPXMock) -> None: response = await client.get("https://test_url") assert response.content == b"" + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +async def test_url_matching_reusing_response(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url") + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url") + assert response.content == b"" + response = await client.post("https://test_url") assert response.content == b"" @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url?a=1&b=2") @@ -98,6 +109,7 @@ async def test_url_query_string_not_matching(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_method_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(method="get") @@ -127,7 +139,8 @@ async def test_method_not_matching(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio -async def test_with_one_response(httpx_mock: HTTPXMock) -> None: +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +async def test_reusing_one_response(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url", content=b"test content") async with httpx.AsyncClient() as client: @@ -157,6 +170,7 @@ async def test_response_with_html_string_body(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_stream_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://test_url", @@ -186,6 +200,7 @@ async def test_stream_response_streaming(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_content_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://test_url", @@ -213,6 +228,7 @@ async def test_content_response_streaming(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_text_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://test_url", @@ -240,6 +256,7 @@ async def test_text_response_streaming(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_default_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -263,6 +280,24 @@ async def test_default_response_streaming(httpx_mock: HTTPXMock) -> None: async def test_with_many_responses(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url", content=b"test content 1") httpx_mock.add_response(url="https://test_url", content=b"test content 2") + httpx_mock.add_response(url="https://test_url", content=b"test content 2") + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url") + assert response.content == b"test content 1" + + response = await client.get("https://test_url") + assert response.content == b"test content 2" + + response = await client.get("https://test_url") + assert response.content == b"test content 2" + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +async def test_with_many_reused_responses(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url", content=b"test content 1") + httpx_mock.add_response(url="https://test_url", content=b"test content 2") async with httpx.AsyncClient() as client: response = await client.get("https://test_url") @@ -694,6 +729,7 @@ async def test_requests_retrieval(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_requests_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url") @@ -708,6 +744,7 @@ async def test_requests_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_request_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -720,6 +757,7 @@ async def test_request_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_requests_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -734,6 +772,7 @@ async def test_requests_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_request_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -746,6 +785,7 @@ async def test_request_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_requests_retrieval_on_same_url_and_method(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -762,6 +802,7 @@ async def test_requests_retrieval_on_same_url_and_method(httpx_mock: HTTPXMock) @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_default_requests_retrieval(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -896,6 +937,7 @@ async def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_callback_executed_twice(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) @@ -913,6 +955,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_async_callback_executed_twice(httpx_mock: HTTPXMock) -> None: async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) @@ -930,6 +973,7 @@ async def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_callback_registered_after_response(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content2"]) @@ -953,6 +997,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_async_callback_registered_after_response(httpx_mock: HTTPXMock) -> None: async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content2"]) @@ -976,6 +1021,7 @@ async def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_response_registered_after_callback(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content1"]) @@ -999,6 +1045,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_response_registered_after_async_callback(httpx_mock: HTTPXMock) -> None: async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content1"]) @@ -1022,6 +1069,7 @@ async def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_callback_matching_method(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) @@ -1039,6 +1087,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_async_callback_matching_method(httpx_mock: HTTPXMock) -> None: async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) @@ -1066,6 +1115,7 @@ def test_request_retrieval_with_more_than_one(testdir: Testdir) -> None: @pytest.mark.asyncio + @pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_request_retrieval_with_more_than_one(httpx_mock): httpx_mock.add_response() @@ -1275,6 +1325,7 @@ async def test_proxy_not_existing(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_requests_retrieval_content_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -1287,6 +1338,7 @@ async def test_requests_retrieval_content_matching(httpx_mock: HTTPXMock) -> Non @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_requests_retrieval_json_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -1299,6 +1351,7 @@ async def test_requests_retrieval_json_matching(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_requests_retrieval_proxy_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -1320,6 +1373,7 @@ async def test_requests_retrieval_proxy_matching(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_request_retrieval_proxy_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -2003,6 +2057,7 @@ async def test_mutating_json(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_streams_are_not_cascading_resulting_in_maximum_recursion( httpx_mock: HTTPXMock, ) -> None: diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index f37bdb8..e1a9be7 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -39,10 +39,20 @@ def test_url_matching(httpx_mock: HTTPXMock) -> None: response = client.get("https://test_url") assert response.content == b"" + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_url_matching_reusing_response(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url") + + with httpx.Client() as client: + response = client.get("https://test_url") + assert response.content == b"" + response = client.post("https://test_url") assert response.content == b"" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url?a=1&b=2") @@ -88,6 +98,7 @@ def test_url_query_string_not_matching(httpx_mock: HTTPXMock) -> None: ) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_method_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(method="get") @@ -115,7 +126,8 @@ def test_method_not_matching(httpx_mock: HTTPXMock) -> None: ) -def test_with_one_response(httpx_mock: HTTPXMock) -> None: +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_reusing_one_response(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url", content=b"test content") with httpx.Client() as client: @@ -161,6 +173,7 @@ def test_url_not_matching_upper_case_headers_matching(httpx_mock: HTTPXMock) -> ) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_stream_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://test_url", @@ -182,6 +195,7 @@ def test_stream_response_streaming(httpx_mock: HTTPXMock) -> None: list(response.iter_raw()) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_content_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://test_url", @@ -203,6 +217,7 @@ def test_content_response_streaming(httpx_mock: HTTPXMock) -> None: list(response.iter_raw()) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_text_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://test_url", @@ -224,6 +239,7 @@ def test_text_response_streaming(httpx_mock: HTTPXMock) -> None: list(response.iter_raw()) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_default_response_streaming(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -245,6 +261,23 @@ def test_default_response_streaming(httpx_mock: HTTPXMock) -> None: def test_with_many_responses(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url", content=b"test content 1") httpx_mock.add_response(url="https://test_url", content=b"test content 2") + httpx_mock.add_response(url="https://test_url", content=b"test content 2") + + with httpx.Client() as client: + response = client.get("https://test_url") + assert response.content == b"test content 1" + + response = client.get("https://test_url") + assert response.content == b"test content 2" + + response = client.get("https://test_url") + assert response.content == b"test content 2" + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_with_many_reused_responses(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url", content=b"test content 1") + httpx_mock.add_response(url="https://test_url", content=b"test content 2") with httpx.Client() as client: response = client.get("https://test_url") @@ -596,6 +629,7 @@ def test_requests_retrieval(httpx_mock: HTTPXMock) -> None: ) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_requests_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url") @@ -609,6 +643,7 @@ def test_requests_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: assert requests[1].headers["x-test"] == "test header 2" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_request_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -620,6 +655,7 @@ def test_request_retrieval_on_same_url(httpx_mock: HTTPXMock) -> None: assert request.headers["x-test"] == "test header 1" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_requests_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -633,6 +669,7 @@ def test_requests_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: assert requests[1].headers["x-test"] == "test header 2" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_request_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -644,6 +681,7 @@ def test_request_retrieval_on_same_method(httpx_mock: HTTPXMock) -> None: assert request.headers["x-test"] == "test header 1" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_requests_retrieval_on_same_url_and_method(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -659,6 +697,7 @@ def test_requests_retrieval_on_same_url_and_method(httpx_mock: HTTPXMock) -> Non assert requests[1].headers["x-test"] == "test header 2" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_default_requests_retrieval(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -757,6 +796,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: assert response.headers["content-type"] == "application/json" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_callback_executed_twice(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) @@ -773,6 +813,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: assert response.headers["content-type"] == "application/json" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_callback_registered_after_response(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content2"]) @@ -795,6 +836,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: assert response.headers["content-type"] == "application/json" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_response_registered_after_callback(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content1"]) @@ -817,6 +859,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: assert response.headers["content-type"] == "application/json" +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_callback_matching_method(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) @@ -840,8 +883,10 @@ def test_request_retrieval_with_more_than_one(testdir: Testdir) -> None: testdir.makepyfile( """ import httpx + import pytest + @pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_request_retrieval_with_more_than_one(httpx_mock): httpx_mock.add_response() @@ -1018,6 +1063,7 @@ def test_proxy_not_existing(httpx_mock: HTTPXMock) -> None: ) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_requests_retrieval_content_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -1029,6 +1075,7 @@ def test_requests_retrieval_content_matching(httpx_mock: HTTPXMock) -> None: assert len(httpx_mock.get_requests(match_content=b"This is the body")) == 2 +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_requests_retrieval_json_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -1040,6 +1087,7 @@ def test_requests_retrieval_json_matching(httpx_mock: HTTPXMock) -> None: assert len(httpx_mock.get_requests(match_json=["my_str"])) == 2 +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_requests_retrieval_proxy_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() @@ -1058,6 +1106,7 @@ def test_requests_retrieval_proxy_matching(httpx_mock: HTTPXMock) -> None: ) +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_request_retrieval_proxy_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index d0b06e9..30e6fe6 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -163,6 +163,61 @@ def test_httpx_mock_unexpected_request(httpx_mock): result.assert_outcomes(passed=1) +def test_httpx_mock_already_matched_response(testdir: Testdir) -> None: + """ + Already matched response should fail test case if + can_send_already_matched_responses option is set to False (default). + """ + testdir.makepyfile( + """ + import httpx + import pytest + + def test_httpx_mock_already_matched_response(httpx_mock): + httpx_mock.add_response() + with httpx.Client() as client: + client.get("https://foo.tld") + # Non mocked (already matched) request + with pytest.raises(httpx.TimeoutException): + client.get("https://foo.tld") + """ + ) + result = testdir.runpytest() + result.assert_outcomes(errors=1, passed=1) + result.stdout.fnmatch_lines( + [ + "*AssertionError: The following requests were not expected:", + "* - GET request on https://foo.tld", + "* ", + "* If this is on purpose, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-not-register-responses-for-every-request", + ], + consecutive=True, + ) + + +def test_httpx_mock_reusing_matched_response(testdir: Testdir) -> None: + """ + Already matched response should not fail test case if + can_send_already_matched_responses option is set to True. + """ + testdir.makepyfile( + """ + import httpx + import pytest + + @pytest.mark.httpx_mock(can_send_already_matched_responses=True) + def test_httpx_mock_reusing_matched_response(httpx_mock): + httpx_mock.add_response() + with httpx.Client() as client: + client.get("https://foo.tld") + # Reusing response + client.get("https://foo.tld") + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + def test_httpx_mock_non_mocked_hosts_sync(testdir: Testdir) -> None: """ Non mocked hosts should go through while other requests should be mocked.