From acce2971883c2111d13531aa8656a3488111a075 Mon Sep 17 00:00:00 2001 From: Colin-b Date: Tue, 2 Dec 2025 12:58:59 +0100 Subject: [PATCH 1/5] Test multi values in same param --- tests/test_httpx_async.py | 54 +++++++++++++++++++++++++++++++++++++++ tests/test_httpx_sync.py | 49 ++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index 663ae52..c9e171c 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -89,6 +89,60 @@ async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" +@pytest.mark.asyncio +async def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True) + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url", params={"a": [1, 3]}) + assert response.content == b"" + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=2&a=3", is_optional=True) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.get("https://test_url", params={"a": [1, 3]}) + assert ( + str(exception_info.value) + == """No response can be found for GET request on https://test_url?a=1&a=3 amongst: +- Match any request on https://test_url?a=2&a=3""" + ) + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.get("https://test_url", params={"a": [1, 3, 4]}) + assert ( + str(exception_info.value) + == """No response can be found for GET request on https://test_url?a=1&a=3&a=4 amongst: +- Match any request on https://test_url?a=1&a=3""" + ) + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=1&a=3&a=4", is_optional=True) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.get("https://test_url", params={"a": [1, 3]}) + assert ( + str(exception_info.value) + == """No response can be found for GET request on https://test_url?a=1&a=3 amongst: +- Match any request on https://test_url?a=1&a=3&a=4""" + ) + + @pytest.mark.asyncio @pytest.mark.httpx_mock(assert_all_requests_were_expected=False) async def test_url_not_matching(httpx_mock: HTTPXMock) -> None: diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index e1f5610..92df78a 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -80,7 +80,54 @@ def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" -# TODO Test URL matching with more than one value on the same param (sucess and failure) +def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True) + + with httpx.Client() as client: + response = client.get("https://test_url", params={"a": [1, 3]}) + assert response.content == b"" + + +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=2&a=3", is_optional=True) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.get("https://test_url", params={"a": [1, 3]}) + assert ( + str(exception_info.value) + == """No response can be found for GET request on https://test_url?a=1&a=3 amongst: +- Match any request on https://test_url?a=2&a=3""" + ) + + +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.get("https://test_url", params={"a": [1, 3, 4]}) + assert ( + str(exception_info.value) + == """No response can be found for GET request on https://test_url?a=1&a=3&a=4 amongst: +- Match any request on https://test_url?a=1&a=3""" + ) + + +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url="https://test_url?a=1&a=3&a=4", is_optional=True) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.get("https://test_url", params={"a": [1, 3]}) + assert ( + str(exception_info.value) + == """No response can be found for GET request on https://test_url?a=1&a=3 amongst: +- Match any request on https://test_url?a=1&a=3&a=4""" + ) @pytest.mark.httpx_mock(assert_all_requests_were_expected=False) From 508927585c55303ba092a9b60d1ce6ba18f59c7d Mon Sep 17 00:00:00 2001 From: Colin-b Date: Tue, 2 Dec 2025 13:55:16 +0100 Subject: [PATCH 2/5] Test params not matching --- tests/test_httpx_async.py | 19 +++++++++++++++++++ tests/test_httpx_sync.py | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index c9e171c..b7a9b2d 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -89,6 +89,25 @@ async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": "1"}, + is_optional=True, + ) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.post("https://test_url?a=2") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=2 amongst: +- Match any request on https://test_url with {'a': '1'} query parameters""" + ) + + @pytest.mark.asyncio async def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True) diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 92df78a..935ecb8 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -80,6 +80,24 @@ def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": "1"}, + is_optional=True, + ) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.post("https://test_url?a=2") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=2 amongst: +- Match any request on https://test_url with {'a': '1'} query parameters""" + ) + + def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True) From 8295e1d532403748a8d0f3efa1122281a958980b Mon Sep 17 00:00:00 2001 From: Colin-b Date: Tue, 2 Dec 2025 14:17:37 +0100 Subject: [PATCH 3/5] Prevent invalid match_params setup --- pytest_httpx/_request_matcher.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py index 4c4b2f5..33d978b 100644 --- a/pytest_httpx/_request_matcher.py +++ b/pytest_httpx/_request_matcher.py @@ -67,6 +67,26 @@ def __init__( self.data = match_data self.files = match_files self.params = match_params + if self.params: + # TODO Prevent match_params and params in URL + for name, values in self.params.items(): + if not isinstance(name, str): + raise ValueError( + "match_params keys should only contain strings." + ) + if isinstance(values, list): + if len(values) < 2: + raise ValueError( + "match_params values should only contain list of at least 2 elements, use the string value otherwise." + ) + if not all(isinstance(value, str) for value in values): + raise ValueError( + "match_params values should only contain string or list of strings." + ) + elif not isinstance(values, str): + raise ValueError( + "match_params values should only contain string or list of strings." + ) self.proxy_url = ( httpx.URL(proxy_url) if proxy_url and isinstance(proxy_url, str) @@ -95,9 +115,6 @@ def __init__( "match_data is meant to be used for multipart matching (in conjunction with match_files)." "Use match_content to match url encoded data." ) - # TODO Prevent match_params and params in URL - # TODO Prevent match_params with non str values / keys - # TODO Prevent match_params with list values of size < 2 def expect_body(self) -> bool: matching_ways = [ From 34b20cae7473ad4e738a9f2958a99de741eb1066 Mon Sep 17 00:00:00 2001 From: Colin-b Date: Tue, 2 Dec 2025 16:09:04 +0100 Subject: [PATCH 4/5] Prevent invalid match_params setup --- pytest_httpx/_request_matcher.py | 21 +-------- tests/test_httpx_async.py | 76 ++++++++++++++++++++++++++++++++ tests/test_httpx_sync.py | 72 ++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 20 deletions(-) diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py index 33d978b..4530d3e 100644 --- a/pytest_httpx/_request_matcher.py +++ b/pytest_httpx/_request_matcher.py @@ -67,26 +67,7 @@ def __init__( self.data = match_data self.files = match_files self.params = match_params - if self.params: - # TODO Prevent match_params and params in URL - for name, values in self.params.items(): - if not isinstance(name, str): - raise ValueError( - "match_params keys should only contain strings." - ) - if isinstance(values, list): - if len(values) < 2: - raise ValueError( - "match_params values should only contain list of at least 2 elements, use the string value otherwise." - ) - if not all(isinstance(value, str) for value in values): - raise ValueError( - "match_params values should only contain string or list of strings." - ) - elif not isinstance(values, str): - raise ValueError( - "match_params values should only contain string or list of strings." - ) + # TODO Prevent match_params and params in URL self.proxy_url = ( httpx.URL(proxy_url) if proxy_url and isinstance(proxy_url, str) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index b7a9b2d..ac4b3fb 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -89,6 +89,82 @@ async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_query_params_with_single_value_list(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": ["1"]}, + is_optional=True, + ) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.post("https://test_url?a=1") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=1 amongst: +- Match any request on https://test_url with {'a': ['1']} query parameters""" + ) + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_query_params_with_non_str_value(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": 1}, + is_optional=True, + ) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.post("https://test_url?a=1") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=1 amongst: +- Match any request on https://test_url with {'a': 1} query parameters""" + ) + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_query_params_with_non_str_list_value(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": [1, "2"]}, + is_optional=True, + ) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.post("https://test_url?a=1&a=2") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=1&a=2 amongst: +- Match any request on https://test_url with {'a': [1, '2']} query parameters""" + ) + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +async def test_url_query_params_with_non_str_name(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={1: "1"}, + is_optional=True, + ) + + async with httpx.AsyncClient() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + await client.post("https://test_url?1=1") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?1=1 amongst: +- Match any request on https://test_url with {1: '1'} query parameters""" + ) + + @pytest.mark.asyncio @pytest.mark.httpx_mock(assert_all_requests_were_expected=False) async def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None: diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 935ecb8..784c8b8 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -80,6 +80,78 @@ def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_query_params_with_single_value_list(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": ["1"]}, + is_optional=True, + ) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.post("https://test_url?a=1") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=1 amongst: +- Match any request on https://test_url with {'a': ['1']} query parameters""" + ) + + +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_query_params_with_non_str_value(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": 1}, + is_optional=True, + ) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.post("https://test_url?a=1") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=1 amongst: +- Match any request on https://test_url with {'a': 1} query parameters""" + ) + + +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_query_params_with_non_str_list_value(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": [1, "2"]}, + is_optional=True, + ) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.post("https://test_url?a=1&a=2") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?a=1&a=2 amongst: +- Match any request on https://test_url with {'a': [1, '2']} query parameters""" + ) + + +@pytest.mark.httpx_mock(assert_all_requests_were_expected=False) +def test_url_query_params_with_non_str_name(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={1: "1"}, + is_optional=True, + ) + + with httpx.Client() as client: + with pytest.raises(httpx.TimeoutException) as exception_info: + client.post("https://test_url?1=1") + assert ( + str(exception_info.value) + == """No response can be found for POST request on https://test_url?1=1 amongst: +- Match any request on https://test_url with {1: '1'} query parameters""" + ) + + @pytest.mark.httpx_mock(assert_all_requests_were_expected=False) def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( From e5ec7b6e0c4215f227fc3e9d82342b618ae82a27 Mon Sep 17 00:00:00 2001 From: Colin-b Date: Tue, 2 Dec 2025 16:41:08 +0100 Subject: [PATCH 5/5] Prevent invalid match_params setup --- pytest_httpx/_request_matcher.py | 22 ++++++++++++++++++++-- tests/test_httpx_sync.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py index 4530d3e..c34033b 100644 --- a/pytest_httpx/_request_matcher.py +++ b/pytest_httpx/_request_matcher.py @@ -15,7 +15,6 @@ def _url_match( received: httpx.URL, params: Optional[dict[str, Union[str | list[str]]]], ) -> bool: - # TODO Allow to provide a regex in URL and params as a dict if isinstance(url_to_match, re.Pattern): return url_to_match.match(str(received)) is not None @@ -67,7 +66,6 @@ def __init__( self.data = match_data self.files = match_files self.params = match_params - # TODO Prevent match_params and params in URL self.proxy_url = ( httpx.URL(proxy_url) if proxy_url and isinstance(proxy_url, str) @@ -91,6 +89,18 @@ def __init__( "If you want to match against the multipart representation, use match_files (and match_data). " "Otherwise, use match_content." ) + if self.params and not self.url: + raise ValueError( + "URL must be provided when match_params is used." + ) + if self.params and isinstance(self.url, re.Pattern): + raise ValueError( + "match_params cannot be used in addition to regex URL. Request this feature via https://github.com/Colin-b/pytest_httpx/issues/new?title=Regex%20URL%20should%20allow%20match_params&body=Hi,%20I%20need%20a%20regex%20to%20match%20the%20non%20query%20part%20of%20the%20URL%20only" + ) + if self._is_matching_params_more_than_one_way(): + raise ValueError( + "Provided URL must not contain any query parameter when match_params is used." + ) if self.data and not self.files: raise ValueError( "match_data is meant to be used for multipart matching (in conjunction with match_files)." @@ -113,6 +123,14 @@ def _is_matching_body_more_than_one_way(self) -> bool: ] return sum(matching_ways) > 1 + def _is_matching_params_more_than_one_way(self) -> bool: + url_has_params = bool(self.url.params) if (self.url and isinstance(self.url, httpx.URL)) else False + matching_ways = [ + self.params is not None, + url_has_params, + ] + return sum(matching_ways) > 1 + def match( self, real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport], diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 784c8b8..60e1d64 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -152,6 +152,27 @@ def test_url_query_params_with_non_str_name(httpx_mock: HTTPXMock) -> None: ) +def test_match_params_without_url(httpx_mock: HTTPXMock) -> None: + with pytest.raises(ValueError) as exception_info: + httpx_mock.add_response(match_params={"a": "1"}) + + assert str(exception_info.value) == "URL must be provided when match_params is used." + + +def test_query_params_in_both_url_and_match_params(httpx_mock: HTTPXMock) -> None: + with pytest.raises(ValueError) as exception_info: + httpx_mock.add_response(url="https://test_url?a=1", match_params={"a": "1"}) + + assert str(exception_info.value) == "Provided URL must not contain any query parameter when match_params is used." + + +def test_regex_url_and_match_params(httpx_mock: HTTPXMock) -> None: + with pytest.raises(ValueError) as exception_info: + httpx_mock.add_response(url=re.compile(".*test.*"), match_params={"a": "1"}) + + assert str(exception_info.value) == "match_params cannot be used in addition to regex URL. Request this feature via https://github.com/Colin-b/pytest_httpx/issues/new?title=Regex%20URL%20should%20allow%20match_params&body=Hi,%20I%20need%20a%20regex%20to%20match%20the%20non%20query%20part%20of%20the%20URL%20only" + + @pytest.mark.httpx_mock(assert_all_requests_were_expected=False) def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(