Skip to content

Commit 90ac9c1

Browse files
authored
chore: replace respx mocks with pytest-httpserver in tests (#454)
- Replace `respx` mocks with `pytest-httpserver` in tests
1 parent 37f757f commit 90ac9c1

File tree

9 files changed

+339
-250
lines changed

9 files changed

+339
-250
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ dev = [
5252
"pytest-timeout>=2.4.0",
5353
"pytest-xdist~=3.8.0",
5454
"pytest~=8.4.0",
55+
"pytest-httpserver>=1.1.3",
5556
"redbaron~=0.9.0",
5657
"respx~=0.22.0",
5758
"ruff~=0.12.0",
5859
"setuptools", # setuptools are used by pytest but not explicitly required
5960
"types-colorama~=0.4.15.20240106",
61+
"werkzeug~=3.0.0", # Werkzeug is used by pytest-httpserver
6062
]
6163

6264
[tool.hatch.build.targets.wheel]

tests/integration/__init__.py

Whitespace-only changes.

tests/unit/__init__.py

Whitespace-only changes.

tests/unit/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from collections.abc import Iterable, Iterator
2+
from logging import getLogger
3+
4+
import pytest
5+
from pytest_httpserver import HTTPServer
6+
7+
8+
@pytest.fixture(scope='session')
9+
def make_httpserver() -> Iterable[HTTPServer]:
10+
werkzeug_logger = getLogger('werkzeug')
11+
werkzeug_logger.disabled = True
12+
13+
server = HTTPServer(threaded=True, host='127.0.0.1')
14+
server.start()
15+
yield server
16+
server.clear() # type: ignore[no-untyped-call]
17+
if server.is_running():
18+
server.stop() # type: ignore[no-untyped-call]
19+
20+
21+
@pytest.fixture
22+
def httpserver(make_httpserver: HTTPServer) -> Iterable[HTTPServer]:
23+
server = make_httpserver
24+
yield server
25+
server.clear() # type: ignore[no-untyped-call]
26+
27+
28+
@pytest.fixture
29+
def patch_basic_url(httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
30+
server_url = httpserver.url_for('/').removesuffix('/')
31+
monkeypatch.setattr('apify_client.client.DEFAULT_API_URL', server_url)
32+
yield
33+
monkeypatch.undo()

tests/unit/test_client_errors.py

Lines changed: 46 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1+
from __future__ import annotations
2+
13
import json
2-
from collections.abc import AsyncIterator, Generator, Iterator
4+
import time
5+
from typing import TYPE_CHECKING
36

4-
import httpx
57
import pytest
6-
import respx
8+
from werkzeug import Response
79

810
from apify_client._errors import ApifyApiError
911
from apify_client._http_client import HTTPClient, HTTPClientAsync
1012

11-
_TEST_URL = 'http://example.com'
13+
if TYPE_CHECKING:
14+
from collections.abc import Iterator
15+
16+
from pytest_httpserver import HTTPServer
17+
from werkzeug import Request
18+
19+
_TEST_PATH = '/errors'
1220
_EXPECTED_MESSAGE = 'some_message'
1321
_EXPECTED_TYPE = 'some_type'
1422
_EXPECTED_DATA = {
@@ -27,86 +35,80 @@
2735

2836

2937
@pytest.fixture
30-
def mocked_response() -> Generator[respx.MockRouter]:
31-
response_content = json.dumps(
32-
{'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}}
38+
def test_endpoint(httpserver: HTTPServer) -> str:
39+
httpserver.expect_request(_TEST_PATH).respond_with_json(
40+
{'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}}, status=400
3341
)
34-
with respx.mock() as respx_mock:
35-
respx_mock.get(_TEST_URL).mock(return_value=httpx.Response(400, content=response_content))
36-
yield respx_mock
42+
return str(httpserver.url_for(_TEST_PATH))
43+
44+
45+
def streaming_handler(_request: Request) -> Response:
46+
"""Handler for streaming log requests."""
3747

48+
def generate_response() -> Iterator[bytes]:
49+
for i in range(len(RAW_ERROR)):
50+
yield RAW_ERROR[i : i + 1]
51+
time.sleep(0.01)
3852

39-
@pytest.mark.usefixtures('mocked_response')
40-
def test_client_apify_api_error_with_data() -> None:
53+
return Response(
54+
response=(RAW_ERROR[i : i + 1] for i in range(len(RAW_ERROR))),
55+
status=403,
56+
mimetype='application/octet-stream',
57+
headers={'Content-Length': str(len(RAW_ERROR))},
58+
)
59+
60+
61+
def test_client_apify_api_error_with_data(test_endpoint: str) -> None:
4162
"""Test that client correctly throws ApifyApiError with error data from response."""
4263
client = HTTPClient()
4364

4465
with pytest.raises(ApifyApiError) as e:
45-
client.call(method='GET', url=_TEST_URL)
66+
client.call(method='GET', url=test_endpoint)
4667

4768
assert e.value.message == _EXPECTED_MESSAGE
4869
assert e.value.type == _EXPECTED_TYPE
4970
assert e.value.data == _EXPECTED_DATA
5071

5172

52-
@pytest.mark.usefixtures('mocked_response')
53-
async def test_async_client_apify_api_error_with_data() -> None:
73+
async def test_async_client_apify_api_error_with_data(test_endpoint: str) -> None:
5474
"""Test that async client correctly throws ApifyApiError with error data from response."""
5575
client = HTTPClientAsync()
5676

5777
with pytest.raises(ApifyApiError) as e:
58-
await client.call(method='GET', url=_TEST_URL)
78+
await client.call(method='GET', url=test_endpoint)
5979

6080
assert e.value.message == _EXPECTED_MESSAGE
6181
assert e.value.type == _EXPECTED_TYPE
6282
assert e.value.data == _EXPECTED_DATA
6383

6484

65-
def test_client_apify_api_error_streamed() -> None:
85+
def test_client_apify_api_error_streamed(httpserver: HTTPServer) -> None:
6686
"""Test that client correctly throws ApifyApiError when the response has stream."""
6787

6888
error = json.loads(RAW_ERROR.decode())
6989

70-
class ByteStream(httpx._types.SyncByteStream):
71-
def __iter__(self) -> Iterator[bytes]:
72-
yield RAW_ERROR
73-
74-
def close(self) -> None:
75-
pass
76-
77-
stream_url = 'http://some-stream-url.com'
78-
7990
client = HTTPClient()
8091

81-
with respx.mock() as respx_mock:
82-
respx_mock.get(url=stream_url).mock(return_value=httpx.Response(stream=ByteStream(), status_code=403))
83-
with pytest.raises(ApifyApiError) as e:
84-
client.call(method='GET', url=stream_url, stream=True, parse_response=False)
92+
httpserver.expect_request('/stream_error').respond_with_handler(streaming_handler)
93+
94+
with pytest.raises(ApifyApiError) as e:
95+
client.call(method='GET', url=httpserver.url_for('/stream_error'), stream=True, parse_response=False)
8596

8697
assert e.value.message == error['error']['message']
8798
assert e.value.type == error['error']['type']
8899

89100

90-
async def test_async_client_apify_api_error_streamed() -> None:
101+
async def test_async_client_apify_api_error_streamed(httpserver: HTTPServer) -> None:
91102
"""Test that async client correctly throws ApifyApiError when the response has stream."""
92103

93104
error = json.loads(RAW_ERROR.decode())
94105

95-
class AsyncByteStream(httpx._types.AsyncByteStream):
96-
async def __aiter__(self) -> AsyncIterator[bytes]:
97-
yield RAW_ERROR
98-
99-
async def aclose(self) -> None:
100-
pass
101-
102-
stream_url = 'http://some-stream-url.com'
103-
104106
client = HTTPClientAsync()
105107

106-
with respx.mock() as respx_mock:
107-
respx_mock.get(url=stream_url).mock(return_value=httpx.Response(stream=AsyncByteStream(), status_code=403))
108-
with pytest.raises(ApifyApiError) as e:
109-
await client.call(method='GET', url=stream_url, stream=True, parse_response=False)
108+
httpserver.expect_request('/stream_error').respond_with_handler(streaming_handler)
109+
110+
with pytest.raises(ApifyApiError) as e:
111+
await client.call(method='GET', url=httpserver.url_for('/stream_error'), stream=True, parse_response=False)
110112

111113
assert e.value.message == error['error']['message']
112114
assert e.value.type == error['error']['type']

tests/unit/test_client_request_queue.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import TYPE_CHECKING
5+
16
import pytest
2-
import respx
37

48
import apify_client
59
from apify_client import ApifyClient, ApifyClientAsync
610

11+
if TYPE_CHECKING:
12+
from pytest_httpserver import HTTPServer
13+
714
_PARTIALLY_ADDED_BATCH_RESPONSE_CONTENT = """{
815
"data": {
916
"processedRequests": [
@@ -25,12 +32,11 @@
2532
}"""
2633

2734

28-
@respx.mock
29-
async def test_batch_not_processed_raises_exception_async() -> None:
35+
@pytest.mark.usefixtures('patch_basic_url')
36+
async def test_batch_not_processed_raises_exception_async(httpserver: HTTPServer) -> None:
3037
"""Test that client exceptions are not silently ignored"""
31-
client = ApifyClientAsync(token='')
32-
33-
respx.route(method='POST', host='api.apify.com').mock(return_value=respx.MockResponse(401))
38+
client = ApifyClientAsync(token='placeholder_token')
39+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_data(status=401)
3440
requests = [
3541
{'uniqueKey': 'http://example.com/1', 'url': 'http://example.com/1', 'method': 'GET'},
3642
{'uniqueKey': 'http://example.com/2', 'url': 'http://example.com/2', 'method': 'GET'},
@@ -41,12 +47,12 @@ async def test_batch_not_processed_raises_exception_async() -> None:
4147
await rq_client.batch_add_requests(requests=requests)
4248

4349

44-
@respx.mock
45-
async def test_batch_processed_partially_async() -> None:
46-
client = ApifyClientAsync(token='')
50+
@pytest.mark.usefixtures('patch_basic_url')
51+
async def test_batch_processed_partially_async(httpserver: HTTPServer) -> None:
52+
client = ApifyClientAsync(token='placeholder_token')
4753

48-
respx.route(method='POST', host='api.apify.com').mock(
49-
return_value=respx.MockResponse(200, content=_PARTIALLY_ADDED_BATCH_RESPONSE_CONTENT)
54+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_data(
55+
status=200, response_data=_PARTIALLY_ADDED_BATCH_RESPONSE_CONTENT
5056
)
5157
requests = [
5258
{'uniqueKey': 'http://example.com/1', 'url': 'http://example.com/1', 'method': 'GET'},
@@ -59,12 +65,12 @@ async def test_batch_processed_partially_async() -> None:
5965
assert response['unprocessedRequests'] == [requests[1]]
6066

6167

62-
@respx.mock
63-
def test_batch_not_processed_raises_exception_sync() -> None:
68+
@pytest.mark.usefixtures('patch_basic_url')
69+
def test_batch_not_processed_raises_exception_sync(httpserver: HTTPServer) -> None:
6470
"""Test that client exceptions are not silently ignored"""
65-
client = ApifyClient(token='')
71+
client = ApifyClient(token='placeholder_token')
6672

67-
respx.route(method='POST', host='api.apify.com').mock(return_value=respx.MockResponse(401))
73+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_data(status=401)
6874
requests = [
6975
{'uniqueKey': 'http://example.com/1', 'url': 'http://example.com/1', 'method': 'GET'},
7076
{'uniqueKey': 'http://example.com/2', 'url': 'http://example.com/2', 'method': 'GET'},
@@ -75,12 +81,12 @@ def test_batch_not_processed_raises_exception_sync() -> None:
7581
rq_client.batch_add_requests(requests=requests)
7682

7783

78-
@respx.mock
79-
async def test_batch_processed_partially_sync() -> None:
80-
client = ApifyClient(token='')
84+
@pytest.mark.usefixtures('patch_basic_url')
85+
async def test_batch_processed_partially_sync(httpserver: HTTPServer) -> None:
86+
client = ApifyClient(token='placeholder_token')
8187

82-
respx.route(method='POST', host='api.apify.com').mock(
83-
return_value=respx.MockResponse(200, content=_PARTIALLY_ADDED_BATCH_RESPONSE_CONTENT)
88+
httpserver.expect_oneshot_request(re.compile(r'.*'), method='POST').respond_with_data(
89+
status=200, response_data=_PARTIALLY_ADDED_BATCH_RESPONSE_CONTENT
8490
)
8591
requests = [
8692
{'uniqueKey': 'http://example.com/1', 'url': 'http://example.com/1', 'method': 'GET'},

0 commit comments

Comments
 (0)