Skip to content
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- The `httpx.HTTPError` message issued in case no mock could be found is now a `httpx.TimeoutException` containing all information required to fix the test case (if needed).

## [0.6.0] - 2020-08-07
### Changed
Expand Down
126 changes: 125 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<a href="https://travis-ci.com/Colin-b/pytest_httpx"><img alt="Build status" src="https://api.travis-ci.com/Colin-b/pytest_httpx.svg?branch=master"></a>
<a href="https://travis-ci.com/Colin-b/pytest_httpx"><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://travis-ci.com/Colin-b/pytest_httpx"><img alt="Number of tests" src="https://img.shields.io/badge/tests-79 passed-blue"></a>
<a href="https://travis-ci.com/Colin-b/pytest_httpx"><img alt="Number of tests" src="https://img.shields.io/badge/tests-121 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>

Expand All @@ -25,6 +25,9 @@ Once installed, `httpx_mock` [`pytest`](https://docs.pytest.org/en/latest/) fixt
- [Add dynamic responses](#dynamic-responses)
- [Raising exceptions](#raising-exceptions)
- [Check requests](#check-sent-requests)
- [Migrating](#migrating-to-pytest-httpx)
- [responses](#from-responses)
- [aioresponses](#from-aioresponses)

## Add responses

Expand Down Expand Up @@ -393,6 +396,21 @@ 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:

```python
import httpx
import pytest
from pytest_httpx import HTTPXMock


def test_timeout(httpx_mock: HTTPXMock):
with httpx.Client() as client:
with pytest.raises(httpx.TimeoutException):
client.get("http://test_url")

```

### How callback is selected

In case more than one callback match request, the first one not yet executed (according to the registration order) will be executed.
Expand Down Expand Up @@ -482,3 +500,109 @@ Matching is performed on equality for each provided header.
Use `match_content` parameter to specify the full HTTP body executing the callback.

Matching is performed on equality.

## Migrating to pytest-httpx

Here is how to migrate from well-known testing libraries to `pytest-httpx`.

### From responses

| Feature | responses | pytest-httpx |
|:--------|:----------|:-------------|
| Add a response | `responses.add()` | `httpx_mock.add_response()` |
| Add a callback | `responses.add_callback()` | `httpx_mock.add_callback()` |
| Retrieve requests | `responses.calls` | `httpx_mock.get_requests()` |

#### Add a response or a callback

Undocumented parameters means that they are unchanged between `responses` and `pytest-httpx`.
Below is a list of parameters that will require a change in your code.

| Parameter | responses | pytest-httpx |
|:--------|:----------|:-------------|
| method | `method=responses.GET` | `method="GET"` |
| body (as bytes) | `body=b"sample"` | `data=b"sample"` |
| body (as str) | `body="sample"` | `data="sample"` |
| status code | `status=201` | `status_code=201` |
| headers | `adding_headers={"name": "value"}` | `headers={"name": "value"}` |
| content-type header | `content_type="application/custom"` | `headers={"content-type": "application/custom"}` |
| Match the full query | `match_querystring=True` | The full query is always matched when providing the `url` parameter. |

Sample adding a response with `responses`:
```python
from responses import RequestsMock

def test_response(responses: RequestsMock):
responses.add(
method=responses.GET,
url="http://test_url",
body=b"This is the response content",
status=400,
)

```

Sample adding the same response with `pytest-httpx`:
```python
from pytest_httpx import HTTPXMock

def test_response(httpx_mock: HTTPXMock):
httpx_mock.add_response(
method="GET",
url="http://test_url",
data=b"This is the response content",
status_code=400,
)

```

### From aioresponses

| Feature | aioresponses | pytest-httpx |
|:--------|:----------|:-------------|
| Add a response | `aioresponses.method()` | `httpx_mock.add_response(method="METHOD")` |
| Add a callback | `aioresponses.method()` | `httpx_mock.add_callback(method="METHOD")` |

#### Add a response or a callback

Undocumented parameters means that they are unchanged between `responses` and `pytest-httpx`.
Below is a list of parameters that will require a change in your code.

| Parameter | responses | pytest-httpx |
|:--------|:----------|:-------------|
| body (as bytes) | `body=b"sample"` | `data=b"sample"` |
| body (as str) | `body="sample"` | `data="sample"` |
| body (as JSON) | `payload=["sample"]` | `json=["sample"]` |
| status code | `status=201` | `status_code=201` |

Sample adding a response with `aioresponses`:
```python
from aioresponses import aioresponses


@pytest.fixture
def mock_aioresponse():
with aioresponses() as m:
yield m


def test_response(mock_aioresponse):
mock_aioresponse.get(
url="http://test_url",
body=b"This is the response content",
status=400,
)

```

Sample adding the same response with `pytest-httpx`:
```python
def test_response(httpx_mock):
httpx_mock.add_response(
method="GET",
url="http://test_url",
data=b"This is the response content",
status_code=400,
)

```
57 changes: 51 additions & 6 deletions pytest_httpx/_httpx_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def __init__(
):
self.nb_calls = 0
self.url = url
self.method = method
self.method = method.upper() if method else method
self.headers = match_headers
self.content = match_content

Expand Down Expand Up @@ -77,7 +77,7 @@ def _method_match(self, request: httpx.Request) -> bool:
if not self.method:
return True

return request.method == self.method.upper()
return request.method == self.method

def _headers_match(self, request: httpx.Request) -> bool:
if not self.headers:
Expand All @@ -94,6 +94,18 @@ def _content_match(self, request: httpx.Request) -> bool:

return request.read() == self.content

def __str__(self) -> str:
matcher_description = f"Match {self.method or 'all'} requests"
if self.url:
matcher_description += f" on {self.url}"
if self.headers:
matcher_description += f" with {self.headers} headers"
if self.content is not None:
matcher_description += f" and {self.content} body"
elif self.content is not None:
matcher_description += f" with {self.content} body"
return matcher_description


class HTTPXMock:
def __init__(self):
Expand Down Expand Up @@ -170,10 +182,43 @@ def _handle_request(
if callback:
return callback(request=request, timeout=timeout)

raise httpx.HTTPError(
f"No mock can be found for {request.method} request on {request.url}.",
request=request,
raise httpx.TimeoutException(
self._explain_that_no_response_was_found(request), request=request
)

def _explain_that_no_response_was_found(self, request: httpx.Request) -> str:
expect_headers = set(
[
header
for matcher, _ in self._responses + self._callbacks
if matcher.headers
for header in matcher.headers
]
)
expect_body = any(
[
matcher.content is not None
for matcher, _ in self._responses + self._callbacks
]
)

request_description = f"{request.method} request on {request.url}"
if expect_headers:
request_description += f" with {dict({name: value for name, value in request.headers.items() if name in expect_headers})} headers"
if expect_body:
request_description += f" and {request.read()} body"
elif expect_body:
request_description += f" with {request.read()} body"

matchers_description = "\n".join(
[str(matcher) for matcher, _ in self._responses + self._callbacks]
)

message = f"No response can be found for {request_description}"
if matchers_description:
message += f" amongst:\n{matchers_description}"

return message

def _get_response(self, request: httpx.Request) -> Optional[Response]:
responses = [
Expand Down Expand Up @@ -323,4 +368,4 @@ def to_response(
else []
)
body = stream(data=data, files=files, json=json, boundary=boundary)
return (http_version.encode(), status_code, b"", headers, body)
return http_version.encode(), status_code, b"", headers, body
Loading