diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 712c63d..451f9f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' - name: Create packages run: | python -m pip install build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20b1f19..e5513fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c8e89d..30bae24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ repos: - repo: https://github.com/psf/black - rev: 24.10.0 + rev: 25.11.0 hooks: - id: black \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c99098b..ccad4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.36.0] - 2025-12-02 +### Changed +- `pytest` required version is now `9`. + +### Added +- Explicit support for python `3.14`. +- `match_params` parameter is now available on responses and callbacks registration, as well as request(s) retrieval. Allowing to provide query parameters as a dict instead of being part of the matched URL. + - This parameter allows to perform partial query params matching ([refer to documentation](README.md#matching-on-query-parameters) for more information). + +### Fixed +- URL with more than one value for the same parameter were not matched properly (matching was performed on the first value). +- `httpx_mock.add_exception` is now properly documented (accepts `BaseException` instead of `Exception`). + +### Removed +- `pytest` `8` is not supported anymore. +- python `3.9` is not supported anymore. + ## [0.35.0] - 2024-11-28 ### Changed - Requires [`httpx`](https://www.python-httpx.org)==0.28.\* @@ -408,7 +425,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - First release, should be considered as unstable for now as design might change. -[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.35.0...HEAD +[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.36.0...HEAD +[0.36.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.35.0...v0.36.0 [0.35.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.34.0...v0.35.0 [0.34.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.33.0...v0.34.0 [0.33.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.32.0...v0.33.0 diff --git a/LICENSE b/LICENSE index cab2d30..28edb58 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Colin Bounouar +Copyright (c) 2025 Colin Bounouar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 007852c..6d97041 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

@@ -107,6 +107,32 @@ def test_url_as_httpx_url(httpx_mock: HTTPXMock): response = client.get("https://test_url?a=1&b=2") ``` +#### Matching on query parameters + +Use `match_params` to partially match query parameters without having to provide a regular expression as `url`. + +If this parameter is provided, `url` parameter must not contain any query parameter. + +All query parameters have to be provided (as `str`). You can however use `unittest.mock.ANY` to do partial matching. + +```python +import httpx +from pytest_httpx import HTTPXMock +from unittest.mock import ANY + +def test_partial_params_matching(httpx_mock: HTTPXMock): + httpx_mock.add_response(url="https://test_url", match_params={"a": "1", "b": ANY}) + + with httpx.Client() as client: + response = client.get("https://test_url?a=1&b=2") + +def test_partial_multi_params_matching(httpx_mock: HTTPXMock): + httpx_mock.add_response(url="https://test_url", match_params={"a": ["1", "3"], "b": ["2", ANY]}) + + with httpx.Client() as client: + response = client.get("https://test_url?a=1&b=2&a=3&b=4") +``` + #### Matching on HTTP method Use `method` parameter to specify the HTTP method (POST, PUT, DELETE, PATCH, HEAD) to reply to. @@ -473,7 +499,7 @@ def test_headers_as_httpx_headers(httpx_mock: HTTPXMock): Cookies are sent in the `set-cookie` HTTP header. -You can then send cookies in the response by setting the `set-cookie` header with [the value following key=value format]((https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie)). +You can then send cookies in the response by setting the `set-cookie` header with [the value following key=value format](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie). ```python import httpx diff --git a/pyproject.toml b/pyproject.toml index 999ca59..0569403 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "pytest-httpx" description = "Send responses to httpx." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE"} authors = [ { name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" }, @@ -27,18 +27,18 @@ classifiers = [ "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Build Tools", "Typing :: Typed", ] dependencies = [ "httpx==0.28.*", - "pytest==8.*", + "pytest==9.*", ] dynamic = ["version"] @@ -51,9 +51,9 @@ issues = "https://github.com/Colin-b/pytest_httpx/issues" [project.optional-dependencies] testing = [ # Used to check coverage - "pytest-cov==6.*", + "pytest-cov==7.*", # Used to run async tests - "pytest-asyncio==0.24.*", + "pytest-asyncio==1.*", ] [project.entry-points.pytest11] @@ -62,6 +62,9 @@ pytest_httpx = "pytest_httpx" [tool.setuptools.dynamic] version = {attr = "pytest_httpx.version.__version__"} -[tool.pytest.ini_options] +[tool.pytest] # Silence deprecation warnings about option "asyncio_default_fixture_loop_scope" asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.run] +patch = ["subprocess"] diff --git a/pytest_httpx/_httpx_internals.py b/pytest_httpx/_httpx_internals.py index 477d2bc..d761f17 100644 --- a/pytest_httpx/_httpx_internals.py +++ b/pytest_httpx/_httpx_internals.py @@ -52,7 +52,7 @@ def _to_httpx_url(url: httpcore.URL, headers: list[tuple[bytes, bytes]]) -> http def _proxy_url( - real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport] + real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport], ) -> Optional[httpx.URL]: if isinstance( real_pool := real_transport._pool, (httpcore.HTTPProxy, httpcore.AsyncHTTPProxy) diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index e99646e..6667f21 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -58,7 +58,7 @@ def add_response( :param html: HTTP body of the response (as HTML string content). :param stream: HTTP body of the response (as httpx.SyncByteStream or httpx.AsyncByteStream) as stream content. :param json: HTTP body of the response (if JSON should be used as content type) if data is not provided. - :param url: Full URL identifying the request(s) to match. + :param url: Full URL identifying the request(s) to match. Use in addition to match_params if you do not want to provide query parameters as part of the URL. Can be a str, a re.Pattern instance or a httpx.URL instance. :param method: HTTP method identifying the request(s) to match. :param proxy_url: Full proxy URL identifying the request(s) to match. @@ -68,6 +68,8 @@ def add_response( :param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable. :param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary. :param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding + :param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary. + :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once). :param is_optional: True will mark this response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False). :param is_reusable: True will allow re-using this response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False). """ @@ -101,7 +103,7 @@ def add_callback( :param callback: The callable that will be called upon reception of the matched request. It must expect one parameter, the received httpx.Request and should return a httpx.Response. - :param url: Full URL identifying the request(s) to match. + :param url: Full URL identifying the request(s) to match. Use in addition to match_params if you do not want to provide query parameters as part of the URL. Can be a str, a re.Pattern instance or a httpx.URL instance. :param method: HTTP method identifying the request(s) to match. :param proxy_url: Full proxy URL identifying the request(s) to match. @@ -112,17 +114,18 @@ def add_callback( :param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary. :param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding :param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary. + :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once). :param is_optional: True will mark this callback as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False). :param is_reusable: True will allow re-using this callback even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False). """ self._callbacks.append((_RequestMatcher(self._options, **matchers), callback)) - def add_exception(self, exception: Exception, **matchers: Any) -> None: + def add_exception(self, exception: BaseException, **matchers: Any) -> None: """ Raise an exception if a request match. :param exception: The exception that will be raised upon reception of the matched request. - :param url: Full URL identifying the request(s) to match. + :param url: Full URL identifying the request(s) to match. Use in addition to match_params if you do not want to provide query parameters as part of the URL. Can be a str, a re.Pattern instance or a httpx.URL instance. :param method: HTTP method identifying the request(s) to match. :param proxy_url: Full proxy URL identifying the request(s) to match. @@ -133,6 +136,7 @@ def add_exception(self, exception: Exception, **matchers: Any) -> None: :param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary. :param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding :param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary. + :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once). :param is_optional: True will mark this exception response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False). :param is_reusable: True will allow re-using this exception response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False). """ @@ -261,7 +265,7 @@ def get_requests(self, **matchers: Any) -> list[httpx.Request]: """ Return all requests sent that match (empty list if no requests were matched). - :param url: Full URL identifying the requests to retrieve. + :param url: Full URL identifying the requests to retrieve. Use in addition to match_params if you do not want to provide query parameters as part of the URL. Can be a str, a re.Pattern instance or a httpx.URL instance. :param method: HTTP method identifying the requests to retrieve. Must be an upper-cased string value. :param proxy_url: Full proxy URL identifying the requests to retrieve. @@ -272,6 +276,7 @@ def get_requests(self, **matchers: Any) -> list[httpx.Request]: :param match_data: Multipart data (excluding files) identifying the requests to retrieve. Must be a dictionary. :param match_files: Multipart files identifying the requests to retrieve. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding :param match_extensions: Extensions identifying the requests to retrieve. Must be a dictionary. + :param match_params: Query string parameters identifying the requests to retrieve (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once). """ matcher = _RequestMatcher(self._options, **matchers) return [ @@ -284,7 +289,7 @@ def get_request(self, **matchers: Any) -> Optional[httpx.Request]: """ Return the single request that match (or None). - :param url: Full URL identifying the request to retrieve. + :param url: Full URL identifying the request to retrieve. Use in addition to match_params if you do not want to provide query parameters as part of the URL. Can be a str, a re.Pattern instance or a httpx.URL instance. :param method: HTTP method identifying the request to retrieve. Must be an upper-cased string value. :param proxy_url: Full proxy URL identifying the request to retrieve. @@ -295,6 +300,7 @@ def get_request(self, **matchers: Any) -> Optional[httpx.Request]: :param match_data: Multipart data (excluding files) identifying the request to retrieve. Must be a dictionary. :param match_files: Multipart files identifying the request to retrieve. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding :param match_extensions: Extensions identifying the request to retrieve. Must be a dictionary. + :param match_params: Query string parameters identifying the request to retrieve (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once). :raises AssertionError: in case more than one request match. """ requests = self.get_requests(**matchers) diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py index a2aaa13..504f47d 100644 --- a/pytest_httpx/_request_matcher.py +++ b/pytest_httpx/_request_matcher.py @@ -4,20 +4,24 @@ from re import Pattern import httpx +from httpx import QueryParams from pytest_httpx._httpx_internals import _proxy_url from pytest_httpx._options import _HTTPXMockOptions def _url_match( - url_to_match: Union[Pattern[str], httpx.URL], received: httpx.URL + url_to_match: Union[Pattern[str], httpx.URL], + received: httpx.URL, + params: Optional[dict[str, Union[str | list[str]]]], ) -> bool: if isinstance(url_to_match, re.Pattern): return url_to_match.match(str(received)) is not None # Compare query parameters apart as order of parameters should not matter - received_params = dict(received.params) - params = dict(url_to_match.params) + received_params = to_params_dict(received.params) + if params is None: + params = to_params_dict(url_to_match.params) # Remove the query parameters from the original URL to compare everything besides query parameters received_url = received.copy_with(query=None) @@ -26,6 +30,15 @@ def _url_match( return (received_params == params) and (url == received_url) +def to_params_dict(params: QueryParams) -> dict[str, Union[str | list[str]]]: + """Convert query parameters to a dict where the value is a string if the parameter has a single value and a list of string otherwise.""" + d = {} + for key in params: + values = params.get_list(key) + d[key] = values if len(values) > 1 else values[0] + return d + + class _RequestMatcher: def __init__( self, @@ -39,6 +52,7 @@ def __init__( match_data: Optional[dict[str, Any]] = None, match_files: Optional[Any] = None, match_extensions: Optional[dict[str, Any]] = None, + match_params: Optional[dict[str, Union[str | list[str]]]] = None, is_optional: Optional[bool] = None, is_reusable: Optional[bool] = None, ): @@ -51,14 +65,23 @@ def __init__( self.json = match_json self.data = match_data self.files = match_files + self.params = match_params self.proxy_url = ( httpx.URL(proxy_url) if proxy_url and isinstance(proxy_url, str) else proxy_url ) self.extensions = match_extensions - self.is_optional = not options.assert_all_responses_were_requested if is_optional is None else is_optional - self.is_reusable = options.can_send_already_matched_responses if is_reusable is None else is_reusable + self.is_optional = ( + not options.assert_all_responses_were_requested + if is_optional is None + else is_optional + ) + self.is_reusable = ( + options.can_send_already_matched_responses + if is_reusable is None + else is_reusable + ) if self._is_matching_body_more_than_one_way(): raise ValueError( "Only one way of matching against the body can be provided. " @@ -66,6 +89,16 @@ 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)." @@ -88,6 +121,18 @@ 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], @@ -106,7 +151,7 @@ def _url_match(self, request: httpx.Request) -> bool: if not self.url: return True - return _url_match(self.url, request.url) + return _url_match(self.url, request.url, self.params) def _method_match(self, request: httpx.Request) -> bool: if not self.method: @@ -168,7 +213,7 @@ def _proxy_match( return True if real_proxy_url := _proxy_url(real_transport): - return _url_match(self.proxy_url, real_proxy_url) + return _url_match(self.proxy_url, real_proxy_url, params=None) return False @@ -200,6 +245,8 @@ def __str__(self) -> str: def _extra_description(self) -> str: extra_description = [] + if self.params: + extra_description.append(f"{self.params} query parameters") if self.headers: extra_description.append(f"{self.headers} headers") if self.content is not None: diff --git a/pytest_httpx/version.py b/pytest_httpx/version.py index 10a3596..11c1d94 100644 --- a/pytest_httpx/version.py +++ b/pytest_httpx/version.py @@ -3,4 +3,4 @@ # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) -__version__ = "0.35.0" +__version__ = "0.36.0" diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index cf8e142..b5ad27a 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -72,6 +72,180 @@ async def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" +@pytest.mark.asyncio +async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url=httpx.URL("https://test_url"), + match_params={"a": ["1", "3"], "b": ANY, "c": "4", "d": ["5", ANY]}, + is_reusable=True, + ) + + async with httpx.AsyncClient() as client: + response = await client.post("https://test_url?a=1&b=2&a=3&c=4&d=5&d=6") + assert response.content == b"" + + # Parameters order should not matter + response = await client.get("https://test_url?b=9&a=1&a=3&c=4&d=5&d=7") + 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: + 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) + + 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: @@ -886,15 +1060,30 @@ async def test_request_exception_raising(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio -async def test_non_request_exception_raising(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_exception( - httpx.HTTPError("Unable to read within 5.0"), url="https://test_url" - ) +@pytest.mark.parametrize( + ("exception_type", "message"), + [ + # httpx exception without request context + pytest.param( + httpx.HTTPError, "Unable to read within 5.0", id="non_request_exception" + ), + # BaseException derived exception + pytest.param( + asyncio.CancelledError, + "Request was cancelled", + id="cancelled_exception", + ), + ], +) +async def test_non_request_exception_raising( + httpx_mock: HTTPXMock, exception_type: type, message: str +) -> None: + httpx_mock.add_exception(exception_type(message), url="https://test_url") async with httpx.AsyncClient() as client: - with pytest.raises(httpx.HTTPError) as exception_info: + with pytest.raises(exception_type) as exception_info: await client.get("https://test_url") - assert str(exception_info.value) == "Unable to read within 5.0" + assert str(exception_info.value) == message @pytest.mark.asyncio diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 0f6f6a9..3ad9043 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -64,6 +64,199 @@ def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None: assert response.content == b"" +def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://test_url", + match_params={"a": ["1", "3"], "b": ANY, "c": "4", "d": ["5", ANY]}, + is_reusable=True, + ) + + with httpx.Client() as client: + response = client.post("https://test_url?a=1&b=2&a=3&c=4&d=5&d=6") + assert response.content == b"" + + # Parameters order should not matter + response = client.get("https://test_url?b=9&a=1&a=3&c=4&d=5&d=7") + 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""" + ) + + +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( + 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) + + 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) def test_url_not_matching(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url="https://test_url", is_optional=True)