Skip to content

Commit

Permalink
Merge pull request #159 from Colin-b/feature/toggle_repeat
Browse files Browse the repository at this point in the history
Already matched responses are not sent by default anymore
  • Loading branch information
Colin-b authored Sep 26, 2024
2 parents 5b4e1bf + e5e4d05 commit 790efd5
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 66 deletions.
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,28 @@ 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.
- `__init__` now expects one parameter, the newly introduced (since [0.31.0]) options.
- `HTTPXMockOptions` class was never intended to be exposed and is now marked as private.

## [0.31.2] - 2024-09-23
### Fixed
- `httpx_mock` marker can now be defined at different levels for a single test.
Expand All @@ -26,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
Expand Down
95 changes: 45 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Build status" src="https://github.com/Colin-b/pytest_httpx/workflows/Release/badge.svg"></a>
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Coverage" src="https://img.shields.io/badge/coverage-100%25-brightgreen"></a>
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Number of tests" src="https://img.shields.io/badge/tests-216 passed-blue"></a>
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Number of tests" src="https://img.shields.io/badge/tests-222 passed-blue"></a>
<a href="https://pypi.org/project/pytest-httpx/"><img alt="Number of downloads" src="https://img.shields.io/pypi/dm/pytest_httpx"></a>
</p>

> [!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.
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions pytest_httpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest
from pytest import Config, FixtureRequest, MonkeyPatch

from pytest_httpx._httpx_mock import HTTPXMock, HTTPXMockOptions
from pytest_httpx._httpx_mock import HTTPXMock, _HTTPXMockOptions
from pytest_httpx._httpx_internals import IteratorStream
from pytest_httpx.version import __version__

Expand All @@ -25,9 +25,9 @@ def httpx_mock(
for marker in request.node.iter_markers("httpx_mock"):
options = marker.kwargs | options
__tracebackhide__ = methodcaller("errisinstance", TypeError)
options = HTTPXMockOptions(**options)
options = _HTTPXMockOptions(**options)

mock = HTTPXMock()
mock = HTTPXMock(options)

# Mock synchronous requests
real_handle_request = httpx.HTTPTransport.handle_request
Expand Down Expand Up @@ -63,13 +63,13 @@ async def mocked_handle_async_request(

yield mock
try:
mock._assert_options(options)
mock._assert_options()
finally:
mock.reset()


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.",
)
28 changes: 20 additions & 8 deletions pytest_httpx/_httpx_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@
from pytest_httpx._request_matcher import _RequestMatcher


class HTTPXMockOptions:
class _HTTPXMockOptions:
def __init__(
self,
*,
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 = []
Expand All @@ -32,7 +34,13 @@ def __init__(


class HTTPXMock:
def __init__(self) -> None:
"""
This class is only exposed for `httpx_mock` fixture type hinting purpose.
"""

def __init__(self, options: _HTTPXMockOptions) -> None:
"""Private and subject to breaking changes without notice."""
self._options = options
self._requests: list[
tuple[Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport], httpx.Request]
] = []
Expand Down Expand Up @@ -235,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]:
"""
Expand Down Expand Up @@ -284,8 +296,8 @@ def reset(self) -> None:
self._callbacks.clear()
self._requests_not_matched.clear()

def _assert_options(self, options: HTTPXMockOptions) -> None:
if options.assert_all_responses_were_requested:
def _assert_options(self) -> None:
if self._options.assert_all_responses_were_requested:
callbacks_not_executed = [
matcher for matcher, _ in self._callbacks if not matcher.nb_calls
]
Expand All @@ -300,7 +312,7 @@ def _assert_options(self, options: HTTPXMockOptions) -> None:
"If this is on purpose, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-register-more-responses-than-what-will-be-requested"
)

if options.assert_all_requests_were_expected:
if self._options.assert_all_requests_were_expected:
requests_description = "\n".join(
[
f"- {request.method} request on {request.url}"
Expand Down
Loading

0 comments on commit 790efd5

Please sign in to comment.