Skip to content

Commit 90a935f

Browse files
committed
Refactor time-based tests to use fake and sharp loop time
Utilise a new library — https://github.com/nolar/looptime — to have a fake time in event loops. The library does a lot of things, but the most important one is the sharp time with predictable steps with **no code overhead included**, which is typically random. Everything else goes as a side-effect: e.g. the fast execution of such tests with zero real-time for any fake duration of loop time, convenient `looptime` fixture for assertions, etc. Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
1 parent 2f03e1d commit 90a935f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+796
-931
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ test = [
7777
"codecov",
7878
"coverage>=7.12.0",
7979
"freezegun",
80+
"looptime>=0.7",
8081
"lxml",
8182
"pyngrok",
8283
"pytest>=9.0.0",
@@ -116,7 +117,7 @@ ignore_missing_imports = true
116117
minversion = "9.0"
117118
asyncio_mode = "auto"
118119
asyncio_default_fixture_loop_scope = "function"
119-
addopts = ["--strict-markers"]
120+
addopts = ["--strict-markers", "--looptime"]
120121

121122
[tool.isort]
122123
line_length = 100

tests/apis/test_api_requests.py

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -138,23 +138,23 @@ async def test_parsing_in_streams(
138138
(delete, 'delete'),
139139
])
140140
async def test_direct_timeout_in_requests(
141-
resp_mocker, aresponses, hostname, fn, method, settings, logger, timer):
141+
resp_mocker, aresponses, hostname, fn, method, settings, logger, looptime):
142142

143143
async def serve_slowly():
144-
await asyncio.sleep(1.0)
144+
await asyncio.sleep(10)
145145
return aiohttp.web.json_response({})
146146

147147
mock = resp_mocker(side_effect=serve_slowly)
148148
aresponses.add(hostname, '/url', method, mock)
149149

150-
with timer, pytest.raises(asyncio.TimeoutError):
151-
timeout = aiohttp.ClientTimeout(total=0.1)
150+
with pytest.raises(asyncio.TimeoutError):
151+
timeout = aiohttp.ClientTimeout(total=1.23)
152152
# aiohttp raises an asyncio.TimeoutError which is automatically retried.
153153
# To reduce the test duration we disable retries for this test.
154154
settings.networking.error_backoffs = None
155155
await fn('/url', timeout=timeout, settings=settings, logger=logger)
156156

157-
assert 0.1 < timer.seconds < 0.2
157+
assert looptime == 1.23
158158

159159
# Let the server request finish and release all resources (tasks).
160160
# TODO: Remove when fixed: https://github.com/aio-libs/aiohttp/issues/7551
@@ -168,23 +168,23 @@ async def serve_slowly():
168168
(delete, 'delete'),
169169
])
170170
async def test_settings_timeout_in_requests(
171-
resp_mocker, aresponses, hostname, fn, method, settings, logger, timer):
171+
resp_mocker, aresponses, hostname, fn, method, settings, logger, looptime):
172172

173173
async def serve_slowly():
174-
await asyncio.sleep(1.0)
174+
await asyncio.sleep(10)
175175
return aiohttp.web.json_response({})
176176

177177
mock = resp_mocker(side_effect=serve_slowly)
178178
aresponses.add(hostname, '/url', method, mock)
179179

180-
with timer, pytest.raises(asyncio.TimeoutError):
181-
settings.networking.request_timeout = 0.1
180+
with pytest.raises(asyncio.TimeoutError):
181+
settings.networking.request_timeout = 1.23
182182
# aiohttp raises an asyncio.TimeoutError which is automatically retried.
183183
# To reduce the test duration we disable retries for this test.
184184
settings.networking.error_backoffs = None
185185
await fn('/url', settings=settings, logger=logger)
186186

187-
assert 0.1 < timer.seconds < 0.2
187+
assert looptime == 1.23
188188

189189
# Let the server request finish and release all resources (tasks).
190190
# TODO: Remove when fixed: https://github.com/aio-libs/aiohttp/issues/7551
@@ -193,24 +193,24 @@ async def serve_slowly():
193193

194194
@pytest.mark.parametrize('method', ['get']) # the only supported method at the moment
195195
async def test_direct_timeout_in_streams(
196-
resp_mocker, aresponses, hostname, method, settings, logger, timer):
196+
resp_mocker, aresponses, hostname, method, settings, logger, looptime):
197197

198198
async def serve_slowly():
199-
await asyncio.sleep(1.0)
199+
await asyncio.sleep(10)
200200
return "{}"
201201

202202
mock = resp_mocker(side_effect=serve_slowly)
203203
aresponses.add(hostname, '/url', method, mock)
204204

205-
with timer, pytest.raises(asyncio.TimeoutError):
206-
timeout = aiohttp.ClientTimeout(total=0.1)
205+
with pytest.raises(asyncio.TimeoutError):
206+
timeout = aiohttp.ClientTimeout(total=1.23)
207207
# aiohttp raises an asyncio.TimeoutError which is automatically retried.
208208
# To reduce the test duration we disable retries for this test.
209209
settings.networking.error_backoffs = None
210210
async for _ in stream('/url', timeout=timeout, settings=settings, logger=logger):
211211
pass
212212

213-
assert 0.1 < timer.seconds < 0.2
213+
assert looptime == 1.23
214214

215215
# Let the server request finish and release all resources (tasks).
216216
# TODO: Remove when fixed: https://github.com/aio-libs/aiohttp/issues/7551
@@ -219,46 +219,47 @@ async def serve_slowly():
219219

220220
@pytest.mark.parametrize('method', ['get']) # the only supported method at the moment
221221
async def test_settings_timeout_in_streams(
222-
resp_mocker, aresponses, hostname, method, settings, logger, timer):
222+
resp_mocker, aresponses, hostname, method, settings, logger, looptime):
223223

224224
async def serve_slowly():
225-
await asyncio.sleep(1.0)
225+
await asyncio.sleep(10)
226226
return "{}"
227227

228228
mock = resp_mocker(side_effect=serve_slowly)
229229
aresponses.add(hostname, '/url', method, mock)
230230

231-
with timer, pytest.raises(asyncio.TimeoutError):
232-
settings.networking.request_timeout = 0.1
231+
with pytest.raises(asyncio.TimeoutError):
232+
settings.networking.request_timeout = 1.23
233233
# aiohttp raises an asyncio.TimeoutError which is automatically retried.
234234
# To reduce the test duration we disable retries for this test.
235235
settings.networking.error_backoffs = None
236236
async for _ in stream('/url', settings=settings, logger=logger):
237237
pass
238238

239-
assert 0.1 < timer.seconds < 0.2
239+
assert looptime == 1.23
240240

241241
# Let the server request finish and release all resources (tasks).
242242
# TODO: Remove when fixed: https://github.com/aio-libs/aiohttp/issues/7551
243243
await asyncio.sleep(1.0)
244244

245245

246-
@pytest.mark.parametrize('delay, expected', [
247-
pytest.param(0.0, [], id='instant-none'),
248-
pytest.param(0.1, [{'fake': 'result1'}], id='fast-single'),
249-
pytest.param(9.9, [{'fake': 'result1'}, {'fake': 'result2'}], id='inf-double'),
246+
@pytest.mark.parametrize('delay, expected_times, expected_items', [
247+
pytest.param(0, [], [], id='instant-none'),
248+
pytest.param(2, [1], [{'fake': 'result1'}], id='fast-single'),
249+
pytest.param(9, [1, 4], [{'fake': 'result1'}, {'fake': 'result2'}], id='inf-double'),
250250
])
251251
@pytest.mark.parametrize('method', ['get']) # the only supported method at the moment
252252
async def test_stopper_in_streams(
253-
resp_mocker, aresponses, hostname, method, delay, expected, settings, logger):
253+
resp_mocker, aresponses, hostname, method, delay, settings, logger, looptime,
254+
expected_items, expected_times):
254255

255256
async def stream_slowly(request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
256257
response = aiohttp.web.StreamResponse()
257258
await response.prepare(request)
258259
try:
259-
await asyncio.sleep(0.05)
260+
await asyncio.sleep(1)
260261
await response.write(b'{"fake": "result1"}\n')
261-
await asyncio.sleep(0.15)
262+
await asyncio.sleep(3)
262263
await response.write(b'{"fake": "result2"}\n')
263264
await response.write_eof()
264265
except ConnectionError:
@@ -271,9 +272,13 @@ async def stream_slowly(request: aiohttp.web.Request) -> aiohttp.web.StreamRespo
271272
asyncio.get_running_loop().call_later(delay, stopper.set_result, None)
272273

273274
items = []
275+
times = []
274276
async for item in stream('/url', stopper=stopper, settings=settings, logger=logger):
275277
items.append(item)
278+
times.append(float(looptime))
276279

277-
assert items == expected
280+
assert items == expected_items
281+
assert times == expected_times
278282

279-
await asyncio.sleep(0.2) # give the response some time to be cancelled and its tasks closed
283+
# Give the response some time to be cancelled and its tasks closed. That is aiohttp's issue.
284+
await asyncio.sleep(30)

tests/conftest.py

Lines changed: 31 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import aiohttp.web
1414
import pytest
15+
import pytest_asyncio
1516

1617
import kopf
1718
from kopf._cogs.clients.auth import APIContext
@@ -207,7 +208,6 @@ class K8sMocks:
207208
patch: Mock
208209
delete: Mock
209210
stream: Mock
210-
sleep: Mock
211211

212212

213213
@pytest.fixture()
@@ -226,7 +226,6 @@ async def itr(*_, **__):
226226
patch=mocker.patch('kopf._cogs.clients.api.patch', return_value={}),
227227
delete=mocker.patch('kopf._cogs.clients.api.delete', return_value={}),
228228
stream=mocker.patch('kopf._cogs.clients.api.stream', side_effect=itr),
229-
sleep=mocker.patch('kopf._cogs.aiokits.aiotime.sleep', return_value=None),
230229
)
231230

232231

@@ -566,69 +565,6 @@ def no_certvalidator():
566565
yield from _with_module_absent('certvalidator')
567566

568567

569-
#
570-
# Helpers for the timing checks.
571-
#
572-
573-
@pytest.fixture()
574-
def timer():
575-
return Timer()
576-
577-
578-
class Timer:
579-
"""
580-
A helper context manager to measure the time of the code-blocks.
581-
Also, supports direct comparison with time-deltas and the numbers of seconds.
582-
583-
Usage:
584-
585-
with Timer() as timer:
586-
do_something()
587-
print(f"Executing for {timer.seconds}s already.")
588-
do_something_else()
589-
590-
print(f"Executed in {timer.seconds}s.")
591-
assert timer < 5.0
592-
"""
593-
594-
def __init__(self):
595-
super().__init__()
596-
self._ts = None
597-
self._te = None
598-
599-
@property
600-
def seconds(self):
601-
if self._ts is None:
602-
return None
603-
elif self._te is None:
604-
return time.perf_counter() - self._ts
605-
else:
606-
return self._te - self._ts
607-
608-
def __repr__(self):
609-
status = 'new' if self._ts is None else 'running' if self._te is None else 'finished'
610-
return f'<Timer: {self.seconds}s ({status})>'
611-
612-
def __enter__(self):
613-
self._ts = time.perf_counter()
614-
self._te = None
615-
return self
616-
617-
def __exit__(self, exc_type, exc_val, exc_tb):
618-
self._te = time.perf_counter()
619-
620-
async def __aenter__(self):
621-
return self.__enter__()
622-
623-
async def __aexit__(self, exc_type, exc_val, exc_tb):
624-
return self.__exit__(exc_type, exc_val, exc_tb)
625-
626-
def __int__(self):
627-
return int(self.seconds)
628-
629-
def __float__(self):
630-
return float(self.seconds)
631-
632568
#
633569
# Helpers for the logging checks.
634570
#
@@ -708,13 +644,8 @@ def assert_logs_fn(patterns, prohibited=[], strict=False):
708644
#
709645
# Helpers for asyncio checks.
710646
#
711-
@pytest.fixture()
712-
async def loop():
713-
yield asyncio.get_running_loop()
714-
715-
716-
@pytest.fixture(autouse=True)
717-
def _no_asyncio_pending_tasks(loop: asyncio.AbstractEventLoop):
647+
@pytest_asyncio.fixture(autouse=True)
648+
def _no_asyncio_pending_tasks(request: pytest.FixtureRequest):
718649
"""
719650
Ensure there are no unattended asyncio tasks after the test.
720651
@@ -735,7 +666,28 @@ def _no_asyncio_pending_tasks(loop: asyncio.AbstractEventLoop):
735666

736667
# Let the pytest-asyncio's async2sync wrapper to finish all callbacks. Otherwise, it raises:
737668
# <Task pending name='Task-2' coro=<<async_generator_athrow without __name__>()>>
738-
loop.run_until_complete(asyncio.sleep(0))
669+
# We don't know which loops were used in the test & fixtures, so we wait on all of them.
670+
for fixture_name, fixture_value in request.node.funcargs.items():
671+
if isinstance(fixture_value, asyncio.BaseEventLoop):
672+
fixture_value.run_until_complete(asyncio.sleep(0))
673+
674+
# Safe-guards for Python 3.10 until deprecated in ≈Oct'2026 (not needed for 3.11+).
675+
try:
676+
from asyncio import Runner as stdlib_Runner # python >= 3.11 (absent in 3.10)
677+
except ImportError:
678+
pass
679+
else:
680+
if isinstance(fixture_value, stdlib_Runner):
681+
fixture_value.get_loop().run_until_complete(asyncio.sleep(0))
682+
683+
# In case pytest's asyncio libraries use the backported runners in Python 3.10.
684+
try:
685+
from backports.asyncio.runner import Runner as backported_Runner
686+
except ImportError:
687+
pass
688+
else:
689+
if isinstance(fixture_value, backported_Runner):
690+
fixture_value.get_loop().run_until_complete(asyncio.sleep(0))
739691

740692
# Detect all leftover tasks.
741693
after = _get_all_tasks()
@@ -760,3 +712,9 @@ def _get_all_tasks() -> set[asyncio.Task]:
760712
else:
761713
break
762714
return {t for t in tasks if not t.done()}
715+
716+
717+
@pytest.fixture()
718+
def loop():
719+
"""Sync aiohttp's server-side timeline with kopf's client-side timeline."""
720+
return asyncio.get_running_loop()

0 commit comments

Comments
 (0)