Как тайм-аут асинхронного теста в Pytest с помощью прибора?
Я тестирую асинхронную функцию, которая может зайти в тупик. Я попытался добавить осветитель, чтобы ограничить выполнение функции только 5 секундами, прежде чем вызвать ошибку, но пока это не сработало.
Настроить:
pipenv --python==3.6
pipenv install pytest==4.4.1
pipenv install pytest-asyncio==0.10.0
Код:
import asyncio
import pytest
@pytest.fixture
def my_fixture():
# attempt to start a timer that will stop the test somehow
asyncio.ensure_future(time_limit())
yield 'eggs'
async def time_limit():
await asyncio.sleep(5)
print('time limit reached') # this isn't printed
raise AssertionError
@pytest.mark.asyncio
async def test(my_fixture):
assert my_fixture == 'eggs'
await asyncio.sleep(10)
print('this should not print') # this is printed
assert 0
-
Изменить: решение Михаила работает отлично. Я не могу найти способ включить это в приспособление, все же.
4 ответа
Удобный способ ограничить функцию (или блок кода) тайм-аутом - использовать модуль async-timeout. Вы можете использовать его внутри своей тестовой функции или, например, создать декоратор. В отличие от прибора, он позволяет указать конкретное время для каждого теста:
import asyncio
import pytest
from async_timeout import timeout
def with_timeout(t):
def wrapper(corofunc):
async def run(*args, **kwargs):
with timeout(t):
return await corofunc(*args, **kwargs)
return run
return wrapper
@pytest.mark.asyncio
@with_timeout(2)
async def test_sleep_1():
await asyncio.sleep(1)
assert 1 == 1
@pytest.mark.asyncio
@with_timeout(2)
async def test_sleep_3():
await asyncio.sleep(3)
assert 1 == 1
Не сложно создать декоратор для конкретного времени (with_timeout_5 = partial(with_timeout, 5)
).
Я не знаю, как создать текстуру (если вам действительно нужен фиксатор), но приведенный выше код может обеспечить отправную точку. Также не уверен, есть ли общий способ для достижения цели лучше.
Мне просто понравился подход Куимби к маркировке тестов тайм-аутами. Вот моя попытка улучшить его, используя отметки pytest :
# tests/conftest.py
import asyncio
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_pyfunc_call(pyfuncitem: pytest.Function):
"""
Wrap all tests marked with pytest.mark.async_timeout with their specified timeout.
"""
orig_obj = pyfuncitem.obj
if marker := pyfuncitem.get_closest_marker("async_timeout"):
async def new_obj(*args, **kwargs):
"""Timed wrapper around the test function."""
try:
return await asyncio.wait_for(orig_obj(*args, **kwargs), timeout=marker.args[0])
except (asyncio.CancelledError, asyncio.TimeoutError):
pytest.fail(f"Test {pyfuncitem.name} did not finish in time.")
pyfuncitem.obj = new_obj
yield
def pytest_configure(config: pytest.Config):
config.addinivalue_line("markers", "async_timeout(timeout): cancels the test execution after the specified amount of seconds")
Использование:
@pytest.mark.asyncio
@pytest.mark.async_timeout(10)
async def potentially_hanging_function():
await asyncio.sleep(20)
Нетрудно включить это в asyncio
отметить на
pytest-asyncio
, поэтому мы можем получить такой синтаксис:
@pytest.mark.asyncio(timeout=10)
async def potentially_hanging_function():
await asyncio.sleep(20)
РЕДАКТИРОВАТЬ: похоже, что для этого уже есть PR .
Есть способ использовать фикстуры для тайм-аута, просто нужно добавить следующий хук в
conftest.py
.
- Любое приспособление с префиксом
timeout
должен вернуть количество секунд (int
,float
) тест может быть запущен. - Выбирается ближайшее приспособление относительно прицела.
autouse
приборы имеют меньший приоритет, чем явно выбранные. Предпочтение отдается более позднему. К сожалению, порядок в списке аргументов функции НЕ имеет значения. - Если такого приспособления нет, тест не ограничен и будет выполняться как обычно бесконечно.
- Тест должен быть помечен
pytest.mark.asyncio
тоже, но это все равно нужно.
# Add to conftest.py
import asyncio
import pytest
_TIMEOUT_FIXTURE_PREFIX = "timeout"
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_setup(item: pytest.Item):
"""Wrap all tests marked with pytest.mark.asyncio with their specified timeout.
Must run as early as possible.
Parameters
----------
item : pytest.Item
Test to wrap
"""
yield
orig_obj = item.obj
timeouts = [n for n in item.funcargs if n.startswith(_TIMEOUT_FIXTURE_PREFIX)]
# Picks the closest timeout fixture if there are multiple
tname = None if len(timeouts) == 0 else timeouts[-1]
# Only pick marked functions
if item.get_closest_marker("asyncio") is not None and tname is not None:
async def new_obj(*args, **kwargs):
"""Timed wrapper around the test function."""
try:
return await asyncio.wait_for(
orig_obj(*args, **kwargs), timeout=item.funcargs[tname]
)
except Exception as e:
pytest.fail(f"Test {item.name} did not finish in time.")
item.obj = new_obj
Пример:
@pytest.fixture
def timeout_2s():
return 2
@pytest.fixture(scope="module", autouse=True)
def timeout_5s():
# You can do whatever you need here, just return/yield a number
return 5
async def test_timeout_1():
# Uses timeout_5s fixture by default
await aio.sleep(0) # Passes
return 1
async def test_timeout_2(timeout_2s):
# Uses timeout_2s because it is closest
await aio.sleep(5) # Timeouts
ПРЕДУПРЕЖДЕНИЕ
Может не работать с некоторыми другими плагинами, я проверял только с
pytest-asyncio
, это точно не сработает, если
item
переопределяется некоторым хуком.
Вместо использования приспособления я решил это следующим образом, используя декоратор:
def timeout(delay):
def decorator(func):
@wraps(func)
async def new_func(*args, **kwargs):
async with asyncio.timeout(delay):
return await func(*args, **kwargs)
return new_func
return decorator
@pytest.mark.asyncio
@timeout(3)
async def test_forever_fails():
await asyncio.Future()
Требуется Python 3.11.
Или я верюtrio
предоставляет что-то подобное для более ранних версий Python.