Skip to content

Commit 95ff5c5

Browse files
authored
chore: Replace respx mocks with pytest-httpserver in tests (#532)
- Replace `respx` mocks with `pytest-httpserver` in tests
1 parent 6d783b6 commit 95ff5c5

File tree

6 files changed

+217
-136
lines changed

6 files changed

+217
-136
lines changed

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,15 @@ dev = [
7171
"pydoc-markdown~=4.8.0",
7272
"pytest-asyncio~=1.1.0",
7373
"pytest-cov~=6.2.0",
74+
"pytest-httpserver>=1.1.3",
7475
"pytest-timeout>=2.4.0",
7576
"pytest-xdist~=3.8.0",
7677
"pytest~=8.4.0",
77-
"respx~=0.22.0",
7878
"ruff~=0.12.0",
7979
"setuptools", # setuptools are used by pytest but not explicitly required
8080
"uvicorn[standard]",
81+
"werkzeug~=3.0.0", # Werkzeug is used by httpserver
82+
"yarl~=1.20.0", # yarl is used by crawlee
8183
]
8284

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

tests/unit/actor/test_actor_create_proxy_configuration.py

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import annotations
22

33
from typing import TYPE_CHECKING
4+
from unittest.mock import Mock
45

5-
import httpx
66
import pytest
77

88
from apify_client import ApifyClientAsync
@@ -11,7 +11,8 @@
1111
from apify import Actor
1212

1313
if TYPE_CHECKING:
14-
from respx import MockRouter
14+
from pytest_httpserver import HTTPServer
15+
from werkzeug import Request, Response
1516

1617
from ..conftest import ApifyClientAsyncPatcher
1718

@@ -24,25 +25,29 @@ def patched_apify_client(apify_client_async_patcher: ApifyClientAsyncPatcher) ->
2425
return ApifyClientAsync()
2526

2627

28+
@pytest.mark.usefixtures('patched_httpx_client')
2729
async def test_basic_proxy_configuration_creation(
2830
monkeypatch: pytest.MonkeyPatch,
29-
respx_mock: MockRouter,
31+
httpserver: HTTPServer,
3032
patched_apify_client: ApifyClientAsync,
3133
) -> None:
32-
dummy_proxy_status_url = 'http://dummy-proxy-status-url.com'
34+
dummy_proxy_status_url = str(httpserver.url_for('/')).removesuffix('/')
3335
monkeypatch.setenv(ApifyEnvVars.TOKEN.value, 'DUMMY_TOKEN')
3436
monkeypatch.setenv(ApifyEnvVars.PROXY_STATUS_URL.value, dummy_proxy_status_url)
3537

36-
route = respx_mock.get(dummy_proxy_status_url)
37-
route.mock(
38-
httpx.Response(
39-
200,
40-
json={
41-
'connected': True,
42-
'connectionError': None,
43-
'isManInTheMiddle': True,
44-
},
45-
)
38+
call_mock = Mock()
39+
40+
def request_handler(request: Request, response: Response) -> Response:
41+
call_mock(request.url)
42+
return response
43+
44+
httpserver.expect_oneshot_request('/').with_post_hook(request_handler).respond_with_json(
45+
{
46+
'connected': True,
47+
'connectionError': None,
48+
'isManInTheMiddle': True,
49+
},
50+
status=200,
4651
)
4752

4853
groups = ['GROUP1', 'GROUP2']
@@ -58,32 +63,36 @@ async def test_basic_proxy_configuration_creation(
5863
assert proxy_configuration._country_code == country_code
5964

6065
assert len(patched_apify_client.calls['user']['get']) == 1 # type: ignore[attr-defined]
61-
assert len(route.calls) == 1
66+
assert call_mock.call_count == 1
6267

6368
await Actor.exit()
6469

6570

71+
@pytest.mark.usefixtures('patched_httpx_client')
6672
async def test_proxy_configuration_with_actor_proxy_input(
6773
monkeypatch: pytest.MonkeyPatch,
68-
respx_mock: MockRouter,
74+
httpserver: HTTPServer,
6975
patched_apify_client: ApifyClientAsync,
7076
) -> None:
71-
dummy_proxy_status_url = 'http://dummy-proxy-status-url.com'
77+
dummy_proxy_status_url = str(httpserver.url_for('/')).removesuffix('/')
7278
dummy_proxy_url = 'http://dummy-proxy.com:8000'
7379

7480
monkeypatch.setenv(ApifyEnvVars.TOKEN.value, 'DUMMY_TOKEN')
7581
monkeypatch.setenv(ApifyEnvVars.PROXY_STATUS_URL.value, dummy_proxy_status_url)
7682

77-
route = respx_mock.get(dummy_proxy_status_url)
78-
route.mock(
79-
httpx.Response(
80-
200,
81-
json={
82-
'connected': True,
83-
'connectionError': None,
84-
'isManInTheMiddle': True,
85-
},
86-
)
83+
call_mock = Mock()
84+
85+
def request_handler(request: Request, response: Response) -> Response:
86+
call_mock(request.url)
87+
return response
88+
89+
httpserver.expect_request('/').with_post_hook(request_handler).respond_with_json(
90+
{
91+
'connected': True,
92+
'connectionError': None,
93+
'isManInTheMiddle': True,
94+
},
95+
status=200,
8796
)
8897

8998
await Actor.init()
@@ -138,6 +147,6 @@ async def test_proxy_configuration_with_actor_proxy_input(
138147
)
139148

140149
assert len(patched_apify_client.calls['user']['get']) == 2 # type: ignore[attr-defined]
141-
assert len(route.calls) == 2
150+
assert call_mock.call_count == 2
142151

143152
await Actor.exit()

tests/unit/actor/test_request_list.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
import re
44
from dataclasses import dataclass
5-
from typing import Any, get_args
5+
from typing import TYPE_CHECKING, Any, get_args
6+
from unittest.mock import Mock
67

78
import pytest
8-
import respx
9-
from httpx import Response
9+
from yarl import URL
1010

1111
from crawlee._request import UserData
1212
from crawlee._types import HttpMethod
1313

1414
from apify.storages._request_list import URL_NO_COMMAS_REGEX, RequestList
1515

16+
if TYPE_CHECKING:
17+
from pytest_httpserver import HTTPServer
18+
from werkzeug import Request, Response
19+
1620

1721
@pytest.mark.parametrize(
1822
argnames='request_method',
@@ -67,37 +71,48 @@ async def test_request_list_open_request_types(
6771
assert request.headers.root == optional_input.get('headers', {})
6872

6973

70-
@respx.mock
71-
async def test_request_list_open_from_url_correctly_send_requests() -> None:
74+
async def test_request_list_open_from_url_correctly_send_requests(httpserver: HTTPServer) -> None:
7275
"""Test that requests are sent to expected urls."""
7376
request_list_sources_input: list[dict[str, Any]] = [
7477
{
75-
'requestsFromUrl': 'https://abc.dev/file.txt',
78+
'requestsFromUrl': httpserver.url_for('/file.txt'),
7679
'method': 'GET',
7780
},
7881
{
79-
'requestsFromUrl': 'https://www.abc.dev/file2',
82+
'requestsFromUrl': httpserver.url_for('/file2'),
8083
'method': 'PUT',
8184
},
8285
{
83-
'requestsFromUrl': 'https://www.something.som',
86+
'requestsFromUrl': httpserver.url_for('/something'),
8487
'method': 'POST',
8588
'headers': {'key': 'value'},
8689
'payload': 'some_payload',
8790
'userData': {'another_key': 'another_value'},
8891
},
8992
]
9093

91-
routes = [respx.get(entry['requestsFromUrl']) for entry in request_list_sources_input]
94+
routes: dict[str, Mock] = {}
95+
96+
def request_handler(request: Request, response: Response) -> Response:
97+
routes[request.url]()
98+
return response
99+
100+
for entry in request_list_sources_input:
101+
path = str(URL(entry['requestsFromUrl']).path)
102+
httpserver.expect_oneshot_request(path).with_post_hook(request_handler).respond_with_data(status=200)
103+
routes[entry['requestsFromUrl']] = Mock()
92104

93105
await RequestList.open(request_list_sources_input=request_list_sources_input)
94106

95-
for route in routes:
96-
assert route.called
107+
assert len(routes) == len(request_list_sources_input)
97108

109+
for entity in request_list_sources_input:
110+
entity_url = entity['requestsFromUrl']
111+
assert entity_url in routes
112+
assert routes[entity_url].called
98113

99-
@respx.mock
100-
async def test_request_list_open_from_url() -> None:
114+
115+
async def test_request_list_open_from_url(httpserver: HTTPServer) -> None:
101116
"""Test that create_request_list is correctly reading urls from remote url sources and also from simple input."""
102117
expected_simple_url = 'https://www.someurl.com'
103118
expected_remote_urls_1 = {'http://www.something.com', 'https://www.somethingelse.com', 'http://www.bla.net'}
@@ -111,11 +126,11 @@ class MockedUrlInfo:
111126

112127
mocked_urls = (
113128
MockedUrlInfo(
114-
'https://abc.dev/file.txt',
129+
httpserver.url_for('/file.txt'),
115130
'blablabla{} more blablabla{} , even more blablabla. {} '.format(*expected_remote_urls_1),
116131
),
117132
MockedUrlInfo(
118-
'https://www.abc.dev/file2',
133+
httpserver.url_for('/file2'),
119134
'some stuff{} more stuff{} www.false_positive.com'.format(*expected_remote_urls_2),
120135
),
121136
)
@@ -132,7 +147,8 @@ class MockedUrlInfo:
132147
},
133148
]
134149
for mocked_url in mocked_urls:
135-
respx.get(mocked_url.url).mock(return_value=Response(200, text=mocked_url.response_text))
150+
path = str(URL(mocked_url.url).path)
151+
httpserver.expect_oneshot_request(path).respond_with_data(status=200, response_data=mocked_url.response_text)
136152

137153
request_list = await RequestList.open(request_list_sources_input=request_list_sources_input)
138154
generated_requests = []
@@ -143,23 +159,20 @@ class MockedUrlInfo:
143159
assert {generated_request.url for generated_request in generated_requests} == expected_urls
144160

145161

146-
@respx.mock
147-
async def test_request_list_open_from_url_additional_inputs() -> None:
162+
async def test_request_list_open_from_url_additional_inputs(httpserver: HTTPServer) -> None:
148163
"""Test that all generated request properties are correctly populated from input values."""
149164
expected_url = 'https://www.someurl.com'
150165
example_start_url_input: dict[str, Any] = {
151-
'requestsFromUrl': 'https://crawlee.dev/file.txt',
166+
'requestsFromUrl': httpserver.url_for('/file.txt'),
152167
'method': 'POST',
153168
'headers': {'key': 'value'},
154169
'payload': 'some_payload',
155170
'userData': {'another_key': 'another_value'},
156171
}
157-
158-
respx.get(example_start_url_input['requestsFromUrl']).mock(return_value=Response(200, text=expected_url))
172+
httpserver.expect_oneshot_request('/file.txt').respond_with_data(status=200, response_data=expected_url)
159173

160174
request_list = await RequestList.open(request_list_sources_input=[example_start_url_input])
161175
request = await request_list.fetch_next_request()
162-
163176
# Check all properties correctly created for request
164177
assert request
165178
assert request.url == expected_url

tests/unit/conftest.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
import inspect
55
import os
66
from collections import defaultdict
7+
from logging import getLogger
78
from typing import TYPE_CHECKING, Any, get_type_hints
89

10+
import httpx
911
import pytest
12+
from pytest_httpserver import HTTPServer
1013

1114
from apify_client import ApifyClientAsync
1215
from apify_shared.consts import ApifyEnvVars
@@ -18,7 +21,7 @@
1821
import apify._actor
1922

2023
if TYPE_CHECKING:
21-
from collections.abc import Callable
24+
from collections.abc import Callable, Iterator
2225
from pathlib import Path
2326

2427

@@ -187,3 +190,37 @@ def memory_storage_client() -> MemoryStorageClient:
187190
configuration.write_metadata = True
188191

189192
return MemoryStorageClient.from_config(configuration)
193+
194+
195+
@pytest.fixture(scope='session')
196+
def make_httpserver() -> Iterator[HTTPServer]:
197+
werkzeug_logger = getLogger('werkzeug')
198+
werkzeug_logger.disabled = True
199+
200+
server = HTTPServer(threaded=True, host='127.0.0.1')
201+
server.start()
202+
yield server
203+
server.clear() # type: ignore[no-untyped-call]
204+
if server.is_running():
205+
server.stop() # type: ignore[no-untyped-call]
206+
207+
208+
@pytest.fixture
209+
def httpserver(make_httpserver: HTTPServer) -> Iterator[HTTPServer]:
210+
server = make_httpserver
211+
yield server
212+
server.clear() # type: ignore[no-untyped-call]
213+
214+
215+
@pytest.fixture
216+
def patched_httpx_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
217+
"""Patch httpx client to drop proxy settings."""
218+
219+
class ProxylessAsyncClient(httpx.AsyncClient):
220+
def __init__(self, *args: Any, **kwargs: Any) -> None:
221+
kwargs.pop('proxy', None)
222+
super().__init__(*args, **kwargs)
223+
224+
monkeypatch.setattr(httpx, 'AsyncClient', ProxylessAsyncClient)
225+
yield
226+
monkeypatch.undo()

0 commit comments

Comments
 (0)