Skip to content

Commit

Permalink
Merge pull request #160 from Colin-b/develop
Browse files Browse the repository at this point in the history
Release 0.32.0
  • Loading branch information
Colin-b authored Sep 27, 2024
2 parents 0cbba7c + 2da1848 commit dcc725f
Show file tree
Hide file tree
Showing 11 changed files with 782 additions and 186 deletions.
34 changes: 32 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.32.0] - 2024-09-27
### 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.
- `httpx.TimeoutException` message issued in case of unmatched request is now linking documentation on how to reuse responses (in case some responses are already matched).

### 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.
- `httpx.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.
- `httpx.TimeoutException` message issued in case of unmatched request is now listing unmatched responses (in registration order) before already matched one (still in registration order).
- The incentive behind this change is to help identify a potential mismatch faster as the first unmatched response is the most likely to be the one expected to match.
- Response description in failure messages (`httpx.TimeoutException` message issued in case of unmatched request or assertion failure message in case of unmatched responses at teardown) is now displaying if the response was already matched or not and less misleading in it's phrasing about what it can match (a single request by default).

### 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 @@ -17,7 +45,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 Expand Up @@ -345,7 +374,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.31.2...HEAD
[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.32.0...HEAD
[0.32.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.31.2...v0.32.0
[0.31.2]: https://github.com/Colin-b/pytest_httpx/compare/v0.31.1...v0.31.2
[0.31.1]: https://github.com/Colin-b/pytest_httpx/compare/v0.31.0...v0.31.1
[0.31.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.30.0...v0.31.0
Expand Down
135 changes: 78 additions & 57 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-229 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 All @@ -79,6 +81,7 @@ Order of parameters in the query string does not matter, however order of values

```python
import httpx
import re
from pytest_httpx import HTTPXMock


Expand All @@ -88,6 +91,20 @@ def test_url(httpx_mock: HTTPXMock):
with httpx.Client() as client:
response1 = client.delete("https://test_url?a=1&b=2")
response2 = client.get("https://test_url?b=2&a=1")


def test_url_as_pattern(httpx_mock: HTTPXMock):
httpx_mock.add_response(url=re.compile(".*test.*"))

with httpx.Client() as client:
response = client.get("https://test_url")


def test_url_as_httpx_url(httpx_mock: HTTPXMock):
httpx_mock.add_response(url=httpx.URL("https://test_url", params={"a": "1", "b": "2"}))

with httpx.Client() as client:
response = client.get("https://test_url?a=1&b=2")
```

#### Matching on HTTP method
Expand Down Expand Up @@ -161,7 +178,7 @@ def test_proxy_url(httpx_mock: HTTPXMock):

#### Matching on HTTP headers

Use `match_headers` parameter to specify the HTTP headers to reply to.
Use `match_headers` parameter to specify the HTTP headers (as a dict) to reply to.

Matching is performed on equality for each provided header.

Expand All @@ -179,7 +196,7 @@ def test_headers_matching(httpx_mock: HTTPXMock):

#### Matching on HTTP body

Use `match_content` parameter to specify the full HTTP body to reply to.
Use `match_content` parameter to specify the full HTTP body (as bytes) to reply to.

Matching is performed on equality.

Expand Down Expand Up @@ -290,7 +307,9 @@ def test_html_body(httpx_mock: HTTPXMock):

### Reply by streaming chunks

Use `stream` parameter to stream chunks that you specify.
Use `stream` parameter (as `httpx.SyncByteStream` or `httpx.AsyncByteStream`) to stream chunks that you specify.

Note that `pytest_httpx.IteratorStream` can be used to provide an iterable.

```python
import httpx
Expand Down Expand Up @@ -347,7 +366,7 @@ content of file 1\r

### Add non 200 response

Use `status_code` parameter to specify the HTTP status code of the response.
Use `status_code` parameter to specify the HTTP status code (as an int) of the response.

```python
import httpx
Expand All @@ -366,7 +385,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 @@ -426,7 +445,7 @@ def test_cookies(httpx_mock: HTTPXMock):

### Add HTTP/2.0 response

Use `http_version` parameter to specify the HTTP protocol version of the response.
Use `http_version` parameter to specify the HTTP protocol version (as a string) of the response.

```python
import httpx
Expand All @@ -450,10 +469,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 +547,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 +608,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 All @@ -639,7 +622,7 @@ Refer to [available options](#available-options) for an exhaustive list of optio
```python
import pytest

@pytest.mark.httpx_mock(assert_all_responses_were_requested=True)
@pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
def test_something(httpx_mock):
...
```
Expand All @@ -663,6 +646,15 @@ def pytest_collection_modifyitems(session, config, items):
item.add_marker(pytest.mark.httpx_mock(assert_all_responses_were_requested=False))
```

> [!IMPORTANT]
> Note that [there currently is a bug in pytest](https://github.com/pytest-dev/pytest/issues/10406) where `pytest_collection_modifyitems` will actually add the marker AFTER its `module` and `class` registration.
>
> Meaning the order is currently:
> module -> class -> test suite -> test
>
> instead of:
> test suite -> module -> class -> test
### Available options

#### Allow to register more responses than what will be requested
Expand All @@ -673,10 +665,13 @@ 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

@pytest.mark.httpx_mock(assert_all_responses_were_requested=True)
@pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
def test_fewer_requests_than_expected(httpx_mock):
# Even if this response never received a corresponding request, the test will not fail at teardown
httpx_mock.add_response()
Expand All @@ -687,7 +682,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 +698,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
11 changes: 6 additions & 5 deletions pytest_httpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import pytest
from pytest import Config, FixtureRequest, MonkeyPatch

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

__all__ = (
Expand All @@ -25,9 +26,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 +64,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.",
)
7 changes: 1 addition & 6 deletions pytest_httpx/_httpx_internals.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import base64
from typing import (
Union,
Dict,
Tuple,
Optional,
)
from typing import Union, Optional
from collections.abc import Sequence, Iterable, AsyncIterator, Iterator

import httpcore
Expand Down
Loading

0 comments on commit dcc725f

Please sign in to comment.