Skip to content

Commit

Permalink
Merge pull request #45 from sashgorokhov/feature/async_fixtures
Browse files Browse the repository at this point in the history
Add async fixture support (#41)
  • Loading branch information
Tinche committed Mar 24, 2017
2 parents 9fb0880 + f97e5f1 commit a3e9d83
Show file tree
Hide file tree
Showing 10 changed files with 180 additions and 76 deletions.
14 changes: 6 additions & 8 deletions .travis.yml
@@ -1,15 +1,13 @@
language: python
python: 3.5

env:
- TOX_ENV=py33
- TOX_ENV=py34
- TOX_ENV=py35
python:
- "3.3"
- "3.4"
- "3.5"

install:
- pip install tox
- pip install tox tox-travis

script: tox -e $TOX_ENV
script: tox

after_success:
- pip install coveralls && cd tests && coveralls
20 changes: 19 additions & 1 deletion README.rst
Expand Up @@ -42,7 +42,7 @@ Features
- fixtures for injecting unused tcp ports
- pytest markers for treating tests as asyncio coroutines
- easy testing with non-default event loops

- support of `async def` fixtures and async generator fixtures

Installation
------------
Expand Down Expand Up @@ -122,6 +122,23 @@ when several unused TCP ports are required in a test.
port1, port2 = unused_tcp_port_factory(), unused_tcp_port_factory()
...
``async fixtures``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This fixtures may be defined as common pytest fixture:

.. code-block:: python
@pytest.fixture(scope='function')
async def async_gen_fixture():
yield await asyncio.sleep(0.1)
@pytest.fixture(scope='function')
async def async_fixture():
return await asyncio.sleep(0.1)
They behave just like a common fixtures, except that they **must** be function-scoped.
That ensures that they a run in the same event loop as test function.

Markers
-------

Expand Down Expand Up @@ -172,6 +189,7 @@ Changelog
- Using ``forbid_global_loop`` now allows tests to use ``asyncio``
subprocesses.
`#36 <https://github.com/pytest-dev/pytest-asyncio/issues/36>`_
- support for async and async gen fixtures

0.5.0 (2016-09-07)
~~~~~~~~~~~~~~~~~~
Expand Down
2 changes: 0 additions & 2 deletions pytest_asyncio/__init__.py
@@ -1,4 +1,2 @@
"""The main point for importing pytest-asyncio items."""
__version__ = '0.5.0'

from .plugin import async_fixture
83 changes: 43 additions & 40 deletions pytest_asyncio/plugin.py
@@ -1,19 +1,15 @@
"""pytest-asyncio implementation."""
import asyncio
import functools
import contextlib
import inspect
import socket

import sys
from concurrent.futures import ProcessPoolExecutor
from contextlib import closing

import pytest

from _pytest.fixtures import FixtureFunctionMarker
from _pytest.python import transfer_markers



class ForbiddenEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
"""An event loop policy that raises errors on most operations.
Expand Down Expand Up @@ -87,6 +83,35 @@ def pytest_fixture_setup(fixturedef, request):
return outcome


@asyncio.coroutine
def initialize_async_fixtures(funcargs, testargs):
"""
Get async generator fixtures first value, and await coroutine fixtures
"""
for name, value in funcargs.items():
if name not in testargs:
continue
if sys.version_info >= (3, 6) and inspect.isasyncgen(value):
try:
testargs[name] = yield from value.__anext__()
except StopAsyncIteration:
raise RuntimeError("async generator didn't yield") from None
elif sys.version_info >= (3, 5) and inspect.iscoroutine(value):
testargs[name] = yield from value


@asyncio.coroutine
def finalize_async_fixtures(funcargs, testargs):
for name, value in funcargs.items():
if sys.version_info >= (3, 6) and inspect.isasyncgen(value):
try:
yield from value.__anext__()
except StopAsyncIteration:
continue
else:
raise RuntimeError("async generator didn't stop")


@pytest.mark.tryfirst
def pytest_pyfunc_call(pyfuncitem):
"""
Expand All @@ -100,8 +125,17 @@ def pytest_pyfunc_call(pyfuncitem):
funcargs = pyfuncitem.funcargs
testargs = {arg: funcargs[arg]
for arg in pyfuncitem._fixtureinfo.argnames}
event_loop.run_until_complete(
asyncio.async(pyfuncitem.obj(**testargs), loop=event_loop))

@asyncio.coroutine
def func_executor(event_loop):
"""Ensure that test function and async fixtures run in one loop"""
yield from initialize_async_fixtures(funcargs, testargs)
try:
yield from asyncio.async(pyfuncitem.obj(**testargs), loop=event_loop)
finally:
yield from finalize_async_fixtures(funcargs, testargs)

event_loop.run_until_complete(func_executor(event_loop))
return True


Expand Down Expand Up @@ -140,7 +174,7 @@ def event_loop_process_pool(event_loop):
@pytest.fixture
def unused_tcp_port():
"""Find an unused localhost TCP port from 1024-65535 and return it."""
with closing(socket.socket()) as sock:
with contextlib.closing(socket.socket()) as sock:
sock.bind(('127.0.0.1', 0))
return sock.getsockname()[1]

Expand All @@ -161,34 +195,3 @@ def factory():

return port
return factory


class AsyncFixtureFunctionMarker(FixtureFunctionMarker):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def __call__(self, coroutine):
"""The parameter is the actual fixture coroutine."""
if not _is_coroutine(coroutine):
raise ValueError('Only coroutine functions supported')

@functools.wraps(coroutine)
def inner(*args, **kwargs):
loop = None
return loop.run_until_complete(coroutine(*args, **kwargs))

inner._pytestfixturefunction = self
return inner


def async_fixture(scope='function', params=None, autouse=False, ids=None):
if callable(scope) and params is None and not autouse:
# direct invocation
marker = AsyncFixtureFunctionMarker(
'function', params, autouse)
return marker(scope)
if params is not None and not isinstance(params, (list, tuple)):
params = list(params)
return AsyncFixtureFunctionMarker(
scope, params, autouse, ids=ids)
Empty file.
41 changes: 41 additions & 0 deletions tests/async_fixtures/test_async_fixtures_35.py
@@ -0,0 +1,41 @@
import asyncio
import unittest.mock

import pytest

START = object()
END = object()
RETVAL = object()


@pytest.fixture
def mock():
return unittest.mock.Mock(return_value=RETVAL)


@pytest.fixture
async def async_fixture(mock):
return await asyncio.sleep(0.1, result=mock(START))


@pytest.mark.asyncio
async def test_async_fixture(async_fixture, mock):
assert mock.call_count == 1
assert mock.call_args_list[-1] == unittest.mock.call(START)
assert async_fixture is RETVAL


@pytest.fixture(scope='module')
async def async_fixture_module_cope():
return await asyncio.sleep(0.1, result=RETVAL)


@pytest.mark.asyncio
async def test_async_fixture_module_cope1(async_fixture_module_cope):
assert async_fixture_module_cope is RETVAL


@pytest.mark.asyncio
@pytest.mark.xfail(reason='Only function scoped async fixtures are supported')
async def test_async_fixture_module_cope2(async_fixture_module_cope):
assert async_fixture_module_cope is RETVAL
39 changes: 39 additions & 0 deletions tests/async_fixtures/test_async_gen_fixtures_36.py
@@ -0,0 +1,39 @@
import asyncio
import unittest.mock

import pytest

START = object()
END = object()
RETVAL = object()


@pytest.fixture(scope='module')
def mock():
return unittest.mock.Mock(return_value=RETVAL)


@pytest.fixture
async def async_gen_fixture(mock):
try:
yield mock(START)
except Exception as e:
mock(e)
else:
mock(END)


@pytest.mark.asyncio
async def test_async_gen_fixture(async_gen_fixture, mock):
assert mock.called
assert mock.call_args_list[-1] == unittest.mock.call(START)
assert async_gen_fixture is RETVAL


@pytest.mark.asyncio
async def test_async_gen_fixture_finalized(mock):
try:
assert mock.called
assert mock.call_args_list[-1] == unittest.mock.call(END)
finally:
mock.reset_mock()
29 changes: 29 additions & 0 deletions tests/async_fixtures/test_coroutine_fixtures.py
@@ -0,0 +1,29 @@
import asyncio
import unittest.mock

import pytest

START = object()
END = object()
RETVAL = object()

pytestmark = pytest.mark.skip(reason='@asyncio.coroutine fixtures are not supported yet')


@pytest.fixture
def mock():
return unittest.mock.Mock(return_value=RETVAL)


@pytest.fixture
@asyncio.coroutine
def coroutine_fixture(mock):
yield from asyncio.sleep(0.1, result=mock(START))


@pytest.mark.asyncio
@asyncio.coroutine
def test_coroutine_fixture(coroutine_fixture, mock):
assert mock.call_count == 1
assert mock.call_args_list[-1] == unittest.mock.call(START)
assert coroutine_fixture is RETVAL
3 changes: 3 additions & 0 deletions tests/conftest.py
Expand Up @@ -8,6 +8,9 @@
collect_ignore.append("test_simple_35.py")
collect_ignore.append("markers/test_class_marker_35.py")
collect_ignore.append("markers/test_module_marker_35.py")
collect_ignore.append("async_fixtures/test_async_fixtures_35.py")
if sys.version_info[:2] < (3, 6):
collect_ignore.append("async_fixtures/test_async_gen_fixtures_36.py")


@pytest.yield_fixture()
Expand Down
25 changes: 0 additions & 25 deletions tests/test_async_fixtures.py

This file was deleted.

0 comments on commit a3e9d83

Please sign in to comment.