Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setup httpx_mock via marker instead of fixtures #145

Merged
merged 3 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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

### Removed
- `pytest` `7` is not supported anymore (`pytest` `8` has been out for 9 months already).

Expand Down
42 changes: 25 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand Down
51 changes: 41 additions & 10 deletions pytest_httpx/__init__.py
Original file line number Diff line number Diff line change
@@ -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__

Expand All @@ -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
Expand All @@ -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()

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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.",
)
48 changes: 48 additions & 0 deletions pytest_httpx/_httpx_mock.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import copy
import inspect
from functools import cached_property
from operator import methodcaller
from typing import Union, Optional, Callable, Any
from collections.abc import Awaitable

import httpx
from pytest import Mark

from pytest_httpx import _httpx_internals
from pytest_httpx._pretty_print import RequestDescription
Expand Down Expand Up @@ -267,6 +270,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
Expand Down
Loading