From 5b4540fca99790ec031a16ad3ac8bce4147a0594 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Fri, 26 Apr 2024 16:10:53 +0200 Subject: [PATCH 1/2] Setup httpx_mock via marker instead of fixtures We want to be able to configure how the httpx_mock fixture behaves at different scopes. A pytest marker can be used at module, class, or test scope and so is what is used here. Providing a non-default value from the fixtures will emit a DeprecationWarning for each test with the exact marker it can be replaced with: =========================== warnings summary =========================== tests/test_tmp.py::test_a tests/test_tmp.py::test_b /.../pytest_httpx/__init__.py:56: DeprecationWarning: The assert_all_r esponses_were_requested and non_mocked_hosts fixtures are deprecated. Use the following marker instead: pytest.mark.httpx_mock(assert_all_responses_were_requested=False) warnings.warn( tests/test_tmp.py::test_c /.../pytest_httpx/__init__.py:56: DeprecationWarning: The assert_all_r esponses_were_requested and non_mocked_hosts fixtures are deprecated. Use the following marker instead: pytest.mark.httpx_mock(non_mocked_hosts=['example.com']) warnings.warn( Resolves #137 --- CHANGELOG.md | 14 ++++ pytest_httpx/__init__.py | 51 ++++++++++--- pytest_httpx/_httpx_mock.py | 48 ++++++++++++ tests/test_plugin.py | 148 +++++++++++++++++++++++++++++++++++- 4 files changed, 247 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7855f..4853e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- The `httpx_mock` fixture is now configured using a marker. + ```python + # Apply marker to whole module + pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + + # Or to specific tests + @pytest.mark.httpx_mock(non_mocked_hosts=[...]) + def test_foo(httpx_mock): + ... + ``` +### Deprecated +- `assert_all_responses_were_requested` fixture +- `non_mocked_hosts` fixture ## [0.30.0] - 2024-02-21 ### Changed diff --git a/pytest_httpx/__init__.py b/pytest_httpx/__init__.py index 3fe8071..5cbf0c0 100644 --- a/pytest_httpx/__init__.py +++ b/pytest_httpx/__init__.py @@ -1,11 +1,12 @@ +import warnings from collections.abc import Generator from typing import List import httpx import pytest -from pytest import MonkeyPatch +from pytest import Config, FixtureRequest, MonkeyPatch -from pytest_httpx._httpx_mock import HTTPXMock +from pytest_httpx._httpx_mock import HTTPXMock, HTTPXMockOptions from pytest_httpx._httpx_internals import IteratorStream from pytest_httpx.version import __version__ @@ -16,6 +17,14 @@ ) +FIXTURE_DEPRECATION_MSG = """\ +The assert_all_responses_were_requested and non_mocked_hosts fixtures are deprecated. +Use the following marker instead: + +{options!r} +""" + + @pytest.fixture def assert_all_responses_were_requested() -> bool: return True @@ -31,12 +40,26 @@ def httpx_mock( monkeypatch: MonkeyPatch, assert_all_responses_were_requested: bool, non_mocked_hosts: List[str], + request: FixtureRequest, ) -> Generator[HTTPXMock, None, None]: - # Ensure redirections to www hosts are handled transparently. - missing_www = [ - f"www.{host}" for host in non_mocked_hosts if not host.startswith("www.") - ] - non_mocked_hosts += missing_www + marker = request.node.get_closest_marker("httpx_mock") + + if marker: + options = HTTPXMockOptions.from_marker(marker) + else: + deprecated_usage = not assert_all_responses_were_requested or non_mocked_hosts + options = HTTPXMockOptions( + assert_all_responses_were_requested=assert_all_responses_were_requested, + non_mocked_hosts=non_mocked_hosts, + ) + if deprecated_usage: + warnings.warn( + FIXTURE_DEPRECATION_MSG.format(options=options), DeprecationWarning + ) + + # Make sure we use options instead + del non_mocked_hosts + del assert_all_responses_were_requested mock = HTTPXMock() @@ -46,7 +69,7 @@ def httpx_mock( def mocked_handle_request( transport: httpx.HTTPTransport, request: httpx.Request ) -> httpx.Response: - if request.url.host in non_mocked_hosts: + if request.url.host in options.non_mocked_hosts: return real_handle_request(transport, request) return mock._handle_request(transport, request) @@ -62,7 +85,7 @@ def mocked_handle_request( async def mocked_handle_async_request( transport: httpx.AsyncHTTPTransport, request: httpx.Request ) -> httpx.Response: - if request.url.host in non_mocked_hosts: + if request.url.host in options.non_mocked_hosts: return await real_handle_async_request(transport, request) return await mock._handle_async_request(transport, request) @@ -73,4 +96,12 @@ async def mocked_handle_async_request( ) yield mock - mock.reset(assert_all_responses_were_requested) + mock.reset(options.assert_all_responses_were_requested) + + +def pytest_configure(config: Config) -> None: + config.addinivalue_line( + "markers", + "httpx_mock(*, assert_all_responses_were_requested=True, " + "non_mocked_hosts=[]): Configure httpx_mock fixture.", + ) diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index 941b050..2e88fd1 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -1,8 +1,11 @@ import copy import inspect +from functools import cached_property +from operator import methodcaller from typing import Union, Optional, Callable, Any, Awaitable import httpx +from pytest import Mark from pytest_httpx import _httpx_internals from pytest_httpx._pretty_print import RequestDescription @@ -266,6 +269,51 @@ def _reset_callbacks(self) -> list[_RequestMatcher]: return callbacks_not_executed +class HTTPXMockOptions: + def __init__( + self, + *, + assert_all_responses_were_requested: bool = True, + non_mocked_hosts: Optional[list[str]] = None, + ) -> None: + if non_mocked_hosts is None: + non_mocked_hosts = [] + + self.assert_all_responses_were_requested = assert_all_responses_were_requested + + # The original non_mocked_hosts list is shown in the __repr__, see the + # non_mocked_hosts property for more. + self._non_mocked_hosts = non_mocked_hosts + + @classmethod + def from_marker(cls, marker: Mark) -> "HTTPXMockOptions": + """Initialise from a marker so that the marker kwargs raise an error if + incorrect. + """ + __tracebackhide__ = methodcaller("errisinstance", TypeError) + return cls(**marker.kwargs) + + @cached_property + def non_mocked_hosts(self) -> list[str]: + # Ensure redirections to www hosts are handled transparently. + missing_www = [ + f"www.{host}" + for host in self._non_mocked_hosts + if not host.startswith("www.") + ] + return [*self._non_mocked_hosts, *missing_www] + + def __repr__(self) -> str: + kwargs = [] + if not self.assert_all_responses_were_requested: + kwargs.append( + f"assert_all_responses_were_requested={self.assert_all_responses_were_requested!r}" + ) + if self._non_mocked_hosts: + kwargs.append(f"non_mocked_hosts={self._non_mocked_hosts!r}") + return f"pytest.mark.httpx_mock(" + ", ".join(kwargs) + ")" + + def _unread(response: httpx.Response) -> httpx.Response: # Allow to read the response on client side response.is_stream_consumed = False diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f821beb..2b29949 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -41,7 +41,9 @@ def test_httpx_mock_unused_response(httpx_mock): ) -def test_httpx_mock_unused_response_without_assertion(testdir: Testdir) -> None: +def test_httpx_mock_unused_response_without_assertion_via_fixture( + testdir: Testdir, +) -> None: """ Unused responses should not fail test case if assert_all_responses_were_requested fixture is set to False. """ @@ -59,6 +61,28 @@ def test_httpx_mock_unused_response_without_assertion(httpx_mock): ) result = testdir.runpytest() result.assert_outcomes(passed=1) + # Our deprecation warning should show how to configure an equivalent marker + result.stdout.re_match_lines( + [r".*pytest\.mark\.httpx_mock\(assert_all_responses_were_requested=False\)"] + ) + + +def test_httpx_mock_unused_response_without_assertion(testdir: Testdir) -> None: + """ + Unused responses should not fail test case if + assert_all_responses_were_requested option is set to False. + """ + testdir.makepyfile( + """ + import pytest + + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + def test_httpx_mock_unused_response_without_assertion(httpx_mock): + httpx_mock.add_response() + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) def test_httpx_mock_unused_callback(testdir: Testdir) -> None: @@ -85,7 +109,9 @@ def unused(*args, **kwargs): ) -def test_httpx_mock_unused_callback_without_assertion(testdir: Testdir) -> None: +def test_httpx_mock_unused_callback_without_assertion_via_fixture( + testdir: Testdir, +) -> None: """ Unused callbacks should not fail test case if assert_all_responses_were_requested fixture is set to False. """ @@ -107,9 +133,35 @@ def unused(*args, **kwargs): ) result = testdir.runpytest() result.assert_outcomes(passed=1) + # Our deprecation warning should show how to configure an equivalent marker + result.stdout.re_match_lines( + [r".*pytest\.mark\.httpx_mock\(assert_all_responses_were_requested=False\)"] + ) -def test_httpx_mock_non_mocked_hosts_sync(testdir: Testdir) -> None: +def test_httpx_mock_unused_callback_without_assertion(testdir: Testdir) -> None: + """ + Unused callbacks should not fail test case if + assert_all_responses_were_requested option is set to False. + """ + testdir.makepyfile( + """ + import pytest + + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + def test_httpx_mock_unused_callback_without_assertion(httpx_mock): + def unused(*args, **kwargs): + pass + + httpx_mock.add_callback(unused) + + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_httpx_mock_non_mocked_hosts_sync_via_fixture(testdir: Testdir) -> None: """ Non mocked hosts should go through while other requests should be mocked. """ @@ -140,9 +192,43 @@ def test_httpx_mock_non_mocked_hosts_sync(httpx_mock): ) result = testdir.runpytest() result.assert_outcomes(passed=1) + # Our deprecation warning should show how to configure an equivalent marker + result.stdout.re_match_lines( + [r".*pytest\.mark\.httpx_mock\(non_mocked_hosts=\['localhost'\]\)"] + ) -def test_httpx_mock_non_mocked_hosts_async(testdir: Testdir) -> None: +def test_httpx_mock_non_mocked_hosts_sync(testdir: Testdir) -> None: + """ + Non mocked hosts should go through while other requests should be mocked. + """ + testdir.makepyfile( + """ + import httpx + import pytest + + @pytest.mark.httpx_mock(non_mocked_hosts=["localhost"]) + def test_httpx_mock_non_mocked_hosts_sync(httpx_mock): + httpx_mock.add_response() + + with httpx.Client() as client: + # Mocked request + client.get("https://foo.tld") + + # Non mocked request + with pytest.raises(httpx.ConnectError): + client.get("https://localhost:5005") + + # Assert that a single request was mocked + assert len(httpx_mock.get_requests()) == 1 + + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_httpx_mock_non_mocked_hosts_async_via_fixture(testdir: Testdir) -> None: """ Non mocked hosts should go through while other requests should be mocked. """ @@ -174,3 +260,57 @@ async def test_httpx_mock_non_mocked_hosts_async(httpx_mock): ) result = testdir.runpytest() result.assert_outcomes(passed=1) + # Our deprecation warning should show how to configure an equivalent marker + result.stdout.re_match_lines( + [r".*pytest\.mark\.httpx_mock\(non_mocked_hosts=\['localhost'\]\)"] + ) + + +def test_httpx_mock_non_mocked_hosts_async(testdir: Testdir) -> None: + """ + Non mocked hosts should go through while other requests should be mocked. + """ + testdir.makepyfile( + """ + import httpx + import pytest + + @pytest.mark.asyncio + @pytest.mark.httpx_mock(non_mocked_hosts=["localhost"]) + async def test_httpx_mock_non_mocked_hosts_async(httpx_mock): + httpx_mock.add_response() + + async with httpx.AsyncClient() as client: + # Mocked request + await client.get("https://foo.tld") + + # Non mocked request + with pytest.raises(httpx.ConnectError): + await client.get("https://localhost:5005") + + # Assert that a single request was mocked + assert len(httpx_mock.get_requests()) == 1 + + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_invalid_marker(testdir: Testdir) -> None: + """ + Unknown marker keyword arguments should raise a TypeError. + """ + testdir.makepyfile( + """ + import pytest + + @pytest.mark.httpx_mock(foo=123) + def test_httpx_mock_non_mocked_hosts_async(httpx_mock): + pass + + """ + ) + result = testdir.runpytest() + result.assert_outcomes(errors=1) + result.stdout.re_match_lines([r".*unexpected keyword argument 'foo'"]) From ee369770fb635c44e9f7f49583edc35872ff907c Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Fri, 26 Apr 2024 16:31:31 +0200 Subject: [PATCH 2/2] Update README for httpx_mock marker --- README.md | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f463cf2..10930e8 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,18 @@ 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. -This behavior can be disabled thanks to the `assert_all_responses_were_requested` fixture: +This behavior can be disabled thanks to the `httpx_mock` marker: ```python import pytest -@pytest.fixture -def assert_all_responses_were_requested() -> bool: - return False +# For whole module +pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + +# For specific test +@pytest.mark.httpx_mock(assert_all_responses_were_requested=True) +def test_something(httpx_mock): + ... ``` Default response is a HTTP/1.1 200 (OK) without any body. @@ -456,14 +460,18 @@ 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. -This behavior can be disabled thanks to the `assert_all_responses_were_requested` fixture: +This behavior can be disabled thanks to the `httpx_mock` marker: ```python import pytest -@pytest.fixture -def assert_all_responses_were_requested() -> bool: - return False +# For whole module +pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + +# For specific test +@pytest.mark.httpx_mock(assert_all_responses_were_requested=True) +def test_something(httpx_mock): + ... ``` Note that callbacks are considered as responses, and thus are [selected the same way](#how-response-is-selected). @@ -650,14 +658,18 @@ By default, `pytest-httpx` will mock every request. But, for instance, in case you want to write integration tests with other servers, you might want to let some requests go through. -To do so, you can use the `non_mocked_hosts` fixture: +To do so, you can use the `httpx_mock` marker: ```python import pytest -@pytest.fixture -def non_mocked_hosts() -> list: - return ["my_local_test_host", "my_other_test_host"] +# For whole module +pytestmark = pytest.mark.httpx_mock(non_mocked_hosts=["my_local_test_host", "my_other_test_host"]) + +# For specific test +@pytest.mark.httpx_mock(non_mocked_hosts=["my_local_test_host"]) +def test_something(httpx_mock): + ... ``` Every other requested hosts will be mocked as in the following example @@ -666,11 +678,7 @@ Every other requested hosts will be mocked as in the following example import pytest import httpx -@pytest.fixture -def non_mocked_hosts() -> list: - return ["my_local_test_host"] - - +@pytest.mark.httpx_mock(non_mocked_hosts=["my_local_test_host"]) def test_partial_mock(httpx_mock): httpx_mock.add_response()