Skip to content

Commit 5cd3ec2

Browse files
authored
Merge pull request Colin-b#25 from Colin-b/feature/timeout_with_details
Simulate timeout with enough details in case no mock could be found
2 parents 133fc01 + a92fa20 commit 5cd3ec2

File tree

5 files changed

+1123
-21
lines changed

5 files changed

+1123
-21
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Changed
9+
- 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).
810

911
## [0.6.0] - 2020-08-07
1012
### Changed

README.md

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<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>
66
<a href="https://travis-ci.com/Colin-b/pytest_httpx"><img alt="Coverage" src="https://img.shields.io/badge/coverage-100%25-brightgreen"></a>
77
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
8-
<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>
8+
<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>
99
<a href="https://pypi.org/project/pytest-httpx/"><img alt="Number of downloads" src="https://img.shields.io/pypi/dm/pytest_httpx"></a>
1010
</p>
1111

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

2932
## Add responses
3033

@@ -393,6 +396,21 @@ def test_exception_raising(httpx_mock: HTTPXMock):
393396

394397
```
395398

399+
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:
400+
401+
```python
402+
import httpx
403+
import pytest
404+
from pytest_httpx import HTTPXMock
405+
406+
407+
def test_timeout(httpx_mock: HTTPXMock):
408+
with httpx.Client() as client:
409+
with pytest.raises(httpx.TimeoutException):
410+
client.get("http://test_url")
411+
412+
```
413+
396414
### How callback is selected
397415

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

484502
Matching is performed on equality.
503+
504+
## Migrating to pytest-httpx
505+
506+
Here is how to migrate from well-known testing libraries to `pytest-httpx`.
507+
508+
### From responses
509+
510+
| Feature | responses | pytest-httpx |
511+
|:--------|:----------|:-------------|
512+
| Add a response | `responses.add()` | `httpx_mock.add_response()` |
513+
| Add a callback | `responses.add_callback()` | `httpx_mock.add_callback()` |
514+
| Retrieve requests | `responses.calls` | `httpx_mock.get_requests()` |
515+
516+
#### Add a response or a callback
517+
518+
Undocumented parameters means that they are unchanged between `responses` and `pytest-httpx`.
519+
Below is a list of parameters that will require a change in your code.
520+
521+
| Parameter | responses | pytest-httpx |
522+
|:--------|:----------|:-------------|
523+
| method | `method=responses.GET` | `method="GET"` |
524+
| body (as bytes) | `body=b"sample"` | `data=b"sample"` |
525+
| body (as str) | `body="sample"` | `data="sample"` |
526+
| status code | `status=201` | `status_code=201` |
527+
| headers | `adding_headers={"name": "value"}` | `headers={"name": "value"}` |
528+
| content-type header | `content_type="application/custom"` | `headers={"content-type": "application/custom"}` |
529+
| Match the full query | `match_querystring=True` | The full query is always matched when providing the `url` parameter. |
530+
531+
Sample adding a response with `responses`:
532+
```python
533+
from responses import RequestsMock
534+
535+
def test_response(responses: RequestsMock):
536+
responses.add(
537+
method=responses.GET,
538+
url="http://test_url",
539+
body=b"This is the response content",
540+
status=400,
541+
)
542+
543+
```
544+
545+
Sample adding the same response with `pytest-httpx`:
546+
```python
547+
from pytest_httpx import HTTPXMock
548+
549+
def test_response(httpx_mock: HTTPXMock):
550+
httpx_mock.add_response(
551+
method="GET",
552+
url="http://test_url",
553+
data=b"This is the response content",
554+
status_code=400,
555+
)
556+
557+
```
558+
559+
### From aioresponses
560+
561+
| Feature | aioresponses | pytest-httpx |
562+
|:--------|:----------|:-------------|
563+
| Add a response | `aioresponses.method()` | `httpx_mock.add_response(method="METHOD")` |
564+
| Add a callback | `aioresponses.method()` | `httpx_mock.add_callback(method="METHOD")` |
565+
566+
#### Add a response or a callback
567+
568+
Undocumented parameters means that they are unchanged between `responses` and `pytest-httpx`.
569+
Below is a list of parameters that will require a change in your code.
570+
571+
| Parameter | responses | pytest-httpx |
572+
|:--------|:----------|:-------------|
573+
| body (as bytes) | `body=b"sample"` | `data=b"sample"` |
574+
| body (as str) | `body="sample"` | `data="sample"` |
575+
| body (as JSON) | `payload=["sample"]` | `json=["sample"]` |
576+
| status code | `status=201` | `status_code=201` |
577+
578+
Sample adding a response with `aioresponses`:
579+
```python
580+
from aioresponses import aioresponses
581+
582+
583+
@pytest.fixture
584+
def mock_aioresponse():
585+
with aioresponses() as m:
586+
yield m
587+
588+
589+
def test_response(mock_aioresponse):
590+
mock_aioresponse.get(
591+
url="http://test_url",
592+
body=b"This is the response content",
593+
status=400,
594+
)
595+
596+
```
597+
598+
Sample adding the same response with `pytest-httpx`:
599+
```python
600+
def test_response(httpx_mock):
601+
httpx_mock.add_response(
602+
method="GET",
603+
url="http://test_url",
604+
data=b"This is the response content",
605+
status_code=400,
606+
)
607+
608+
```

pytest_httpx/_httpx_mock.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __init__(
4646
):
4747
self.nb_calls = 0
4848
self.url = url
49-
self.method = method
49+
self.method = method.upper() if method else method
5050
self.headers = match_headers
5151
self.content = match_content
5252

@@ -77,7 +77,7 @@ def _method_match(self, request: httpx.Request) -> bool:
7777
if not self.method:
7878
return True
7979

80-
return request.method == self.method.upper()
80+
return request.method == self.method
8181

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

9595
return request.read() == self.content
9696

97+
def __str__(self) -> str:
98+
matcher_description = f"Match {self.method or 'all'} requests"
99+
if self.url:
100+
matcher_description += f" on {self.url}"
101+
if self.headers:
102+
matcher_description += f" with {self.headers} headers"
103+
if self.content is not None:
104+
matcher_description += f" and {self.content} body"
105+
elif self.content is not None:
106+
matcher_description += f" with {self.content} body"
107+
return matcher_description
108+
97109

98110
class HTTPXMock:
99111
def __init__(self):
@@ -170,10 +182,43 @@ def _handle_request(
170182
if callback:
171183
return callback(request=request, timeout=timeout)
172184

173-
raise httpx.HTTPError(
174-
f"No mock can be found for {request.method} request on {request.url}.",
175-
request=request,
185+
raise httpx.TimeoutException(
186+
self._explain_that_no_response_was_found(request), request=request
187+
)
188+
189+
def _explain_that_no_response_was_found(self, request: httpx.Request) -> str:
190+
expect_headers = set(
191+
[
192+
header
193+
for matcher, _ in self._responses + self._callbacks
194+
if matcher.headers
195+
for header in matcher.headers
196+
]
176197
)
198+
expect_body = any(
199+
[
200+
matcher.content is not None
201+
for matcher, _ in self._responses + self._callbacks
202+
]
203+
)
204+
205+
request_description = f"{request.method} request on {request.url}"
206+
if expect_headers:
207+
request_description += f" with {dict({name: value for name, value in request.headers.items() if name in expect_headers})} headers"
208+
if expect_body:
209+
request_description += f" and {request.read()} body"
210+
elif expect_body:
211+
request_description += f" with {request.read()} body"
212+
213+
matchers_description = "\n".join(
214+
[str(matcher) for matcher, _ in self._responses + self._callbacks]
215+
)
216+
217+
message = f"No response can be found for {request_description}"
218+
if matchers_description:
219+
message += f" amongst:\n{matchers_description}"
220+
221+
return message
177222

178223
def _get_response(self, request: httpx.Request) -> Optional[Response]:
179224
responses = [
@@ -323,4 +368,4 @@ def to_response(
323368
else []
324369
)
325370
body = stream(data=data, files=files, json=json, boundary=boundary)
326-
return (http_version.encode(), status_code, b"", headers, body)
371+
return http_version.encode(), status_code, b"", headers, body

0 commit comments

Comments
 (0)