diff --git a/pytest_trio/_tests/test_async_fixture.py b/pytest_trio/_tests/test_async_fixture.py new file mode 100644 index 0000000..8ca95c4 --- /dev/null +++ b/pytest_trio/_tests/test_async_fixture.py @@ -0,0 +1,142 @@ +import pytest + + +def test_single_async_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + @pytest.fixture + async def fix1(): + await trio.sleep(0) + return 'fix1' + + @pytest.mark.trio + async def test_simple(fix1): + assert fix1 == 'fix1' + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=1) + + +def test_async_fixture_recomputed_for_each_test(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + counter = 0 + + @pytest.fixture + async def fix1(): + global counter + await trio.sleep(0) + counter += 1 + return counter + + @pytest.mark.trio + async def test_first(fix1): + assert fix1 == 1 + + @pytest.mark.trio + async def test_second(fix1): + assert fix1 == 2 + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=2) + + +def test_nested_async_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + @pytest.fixture + async def fix1(): + await trio.sleep(0) + return 'fix1' + + @pytest.fixture + async def fix2(fix1): + await trio.sleep(0) + return 'fix2(%s)' % fix1 + + @pytest.mark.trio + async def test_simple(fix2): + assert fix2 == 'fix2(fix1)' + + @pytest.mark.trio + async def test_both(fix1, fix2): + assert fix1 == 'fix1' + assert fix2 == 'fix2(fix1)' + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=2) + + +def test_async_within_sync_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + @pytest.fixture + async def async_fix(): + await trio.sleep(0) + return 42 + + @pytest.fixture + def sync_fix(async_fix): + return async_fix + + @pytest.mark.trio + async def test_simple(sync_fix): + assert sync_fix == 42 + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=1) + + +# In pytest, ERROR status occurs when an exception is raised in fixture code. +# The trouble is our async fixtures must be run whithin a trio context, hence +# they are actually run just before the test, providing no way to make the +# difference between an exception comming from the real test or from an +# async fixture... +@pytest.mark.xfail(reason='Not implemented yet') +def test_raise_in_async_fixture_cause_pytest_error(testdir): + + testdir.makepyfile( + """ + import pytest + + @pytest.fixture + async def fix1(): + raise ValueError('Ouch !') + + @pytest.mark.trio + async def test_base(fix1): + pass # Crash should have occures before arriving here + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(error=1) diff --git a/pytest_trio/_tests/test_async_yield_fixture.py b/pytest_trio/_tests/test_async_yield_fixture.py new file mode 100644 index 0000000..88ed8ff --- /dev/null +++ b/pytest_trio/_tests/test_async_yield_fixture.py @@ -0,0 +1,226 @@ +import sys +import pytest + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6") +def test_single_async_yield_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + events = [] + + @pytest.fixture + async def fix1(): + events.append('fix1 setup') + await trio.sleep(0) + + yield 'fix1' + + await trio.sleep(0) + events.append('fix1 teardown') + + def test_before(): + assert not events + + @pytest.mark.trio + async def test_actual_test(fix1): + assert events == ['fix1 setup'] + assert fix1 == 'fix1' + + def test_after(): + assert events == [ + 'fix1 setup', + 'fix1 teardown', + ] + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=3) + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6") +def test_nested_async_yield_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + events = [] + + @pytest.fixture + async def fix2(): + events.append('fix2 setup') + await trio.sleep(0) + + yield 'fix2' + + await trio.sleep(0) + events.append('fix2 teardown') + + @pytest.fixture + async def fix1(fix2): + events.append('fix1 setup') + await trio.sleep(0) + + yield 'fix1' + + await trio.sleep(0) + events.append('fix1 teardown') + + def test_before(): + assert not events + + @pytest.mark.trio + async def test_actual_test(fix1): + assert events == [ + 'fix2 setup', + 'fix1 setup', + ] + assert fix1 == 'fix1' + + def test_after(): + assert events == [ + 'fix2 setup', + 'fix1 setup', + 'fix1 teardown', + 'fix2 teardown', + ] + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=3) + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6") +def test_async_yield_fixture_within_sync_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + events = [] + + @pytest.fixture + async def fix2(): + events.append('fix2 setup') + await trio.sleep(0) + + yield 'fix2' + + await trio.sleep(0) + events.append('fix2 teardown') + + @pytest.fixture + def fix1(fix2): + return 'fix1' + + def test_before(): + assert not events + + @pytest.mark.trio + async def test_actual_test(fix1): + assert events == [ + 'fix2 setup', + ] + assert fix1 == 'fix1' + + def test_after(): + assert events == [ + 'fix2 setup', + 'fix2 teardown', + ] + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=3) + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6") +def test_async_yield_fixture_within_sync_yield_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + events = [] + + @pytest.fixture + async def fix2(): + events.append('fix2 setup') + await trio.sleep(0) + + yield 'fix2' + + await trio.sleep(0) + events.append('fix2 teardown') + + @pytest.fixture + def fix1(fix2): + events.append('fix1 setup') + yield 'fix1' + events.append('fix1 teardown') + + def test_before(): + assert not events + + @pytest.mark.trio + async def test_actual_test(fix1): + assert events == [ + 'fix2 setup', + 'fix1 setup', + ] + assert fix1 == 'fix1' + + def test_after(): + assert events == [ + 'fix2 setup', + 'fix1 setup', + 'fix1 teardown', + 'fix2 teardown', + ] + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=3) + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6") +def test_async_yield_fixture_with_multiple_yields(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + @pytest.fixture + async def fix1(): + await trio.sleep(0) + yield 'good' + await trio.sleep(0) + yield 'bad' + + @pytest.mark.trio + async def test_actual_test(fix1): + pass + """ + ) + + result = testdir.runpytest() + + # TODO: should trigger error instead of failure + # result.assert_outcomes(error=1) + result.assert_outcomes(failed=1) diff --git a/pytest_trio/_tests/test_basic.py b/pytest_trio/_tests/test_basic.py index 7f7bc65..369da8a 100644 --- a/pytest_trio/_tests/test_basic.py +++ b/pytest_trio/_tests/test_basic.py @@ -2,14 +2,76 @@ import trio -@pytest.mark.trio -async def test_sleep_with_autojump_clock(autojump_clock): - assert trio.current_time() == 0 +def test_async_test_is_executed(testdir): - for i in range(10): - print("Sleeping {} seconds".format(i)) - start_time = trio.current_time() - await trio.sleep(i) - end_time = trio.current_time() + testdir.makepyfile( + """ + import pytest + import trio - assert end_time - start_time == i + async_test_called = False + + @pytest.mark.trio + async def test_base(): + global async_test_called + await trio.sleep(0) + async_test_called = True + + def test_check_async_test_called(): + assert async_test_called + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=2) + + +def test_async_test_as_class_method(testdir): + + testdir.makepyfile( + """ + import pytest + import trio + + async_test_called = False + + @pytest.fixture + async def fix(): + await trio.sleep(0) + return 'fix' + + class TestInClass: + @pytest.mark.trio + async def test_base(self, fix): + global async_test_called + assert fix == 'fix' + await trio.sleep(0) + async_test_called = True + + def test_check_async_test_called(): + assert async_test_called + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=2) + + +@pytest.mark.xfail(reason='Raises pytest internal error so far...') +def test_sync_function_with_trio_mark(testdir): + + testdir.makepyfile( + """ + import pytest + + @pytest.mark.trio + def test_invalid(): + pass + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(error=1) diff --git a/pytest_trio/_tests/test_clock_fixture.py b/pytest_trio/_tests/test_clock_fixture.py new file mode 100644 index 0000000..22c803a --- /dev/null +++ b/pytest_trio/_tests/test_clock_fixture.py @@ -0,0 +1,14 @@ +import pytest +import trio + + +@pytest.mark.trio +async def test_sleep_with_autojump_clock(autojump_clock): + assert trio.current_time() == 0 + + for i in range(10): + start_time = trio.current_time() + await trio.sleep(i) + end_time = trio.current_time() + + assert end_time - start_time == i diff --git a/pytest_trio/_tests/test_sync_fixture.py b/pytest_trio/_tests/test_sync_fixture.py new file mode 100644 index 0000000..a530051 --- /dev/null +++ b/pytest_trio/_tests/test_sync_fixture.py @@ -0,0 +1,46 @@ +import pytest + + +@pytest.fixture +def sync_fix(): + return 'sync_fix' + + +@pytest.mark.trio +async def test_single_sync_fixture(sync_fix): + assert sync_fix == 'sync_fix' + + +def test_single_yield_fixture(testdir): + + testdir.makepyfile( + """ + import pytest + + events = [] + + @pytest.fixture + def fix1(): + events.append('fixture setup') + yield 'fix1' + events.append('fixture teardown') + + def test_before(): + assert not events + + @pytest.mark.trio + async def test_actual_test(fix1): + assert events == ['fixture setup'] + assert fix1 == 'fix1' + + def test_after(): + assert events == [ + 'fixture setup', + 'fixture teardown', + ] + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(passed=3) diff --git a/pytest_trio/plugin.py b/pytest_trio/plugin.py index 7f9abc8..a6b2bef 100644 --- a/pytest_trio/plugin.py +++ b/pytest_trio/plugin.py @@ -1,9 +1,15 @@ """pytest-trio implementation.""" import contextlib -import inspect import socket -from functools import partial from traceback import format_exception +from inspect import iscoroutinefunction, isgeneratorfunction +try: + from inspect import isasyncgenfunction +except ImportError: + # `inspect.isasyncgenfunction` not available with Python<3.6 + def isasyncgenfunction(x): + return False + import pytest import trio @@ -24,13 +30,15 @@ def _trio_test_runner_factory(item): @trio_test async def _bootstrap_fixture_and_run_test(**kwargs): - kwargs = await _resolve_async_fixtures_in(kwargs) - await testfunc(**kwargs) + __tracebackhide__ = True + resolved_kwargs = await _setup_async_fixtures_in(kwargs) + await testfunc(**resolved_kwargs) + await _teardown_async_fixtures_in(kwargs) return _bootstrap_fixture_and_run_test -async def _resolve_async_fixtures_in(deps): +async def _setup_async_fixtures_in(deps): resolved_deps = {**deps} async def _resolve_and_update_deps(afunc, deps, entry): @@ -38,74 +46,161 @@ async def _resolve_and_update_deps(afunc, deps, entry): async with trio.open_nursery() as nursery: for depname, depval in resolved_deps.items(): - if isinstance(depval, AsyncFixture): + if isinstance(depval, BaseAsyncFixture): nursery.start_soon( - _resolve_and_update_deps, depval.resolve, resolved_deps, + _resolve_and_update_deps, depval.setup, resolved_deps, depname ) return resolved_deps -class AsyncFixture: +async def _teardown_async_fixtures_in(deps): + async with trio.open_nursery() as nursery: + for depval in deps.values(): + if isinstance(depval, BaseAsyncFixture): + nursery.start_soon(depval.teardown) + + +class BaseAsyncFixture: """ Represent a fixture that need to be run in a trio context to be resolved. - Can be async function fixture or a syncronous fixture with async - dependencies fixtures. """ - NOTSET = object() - def __init__(self, fixturefunc, fixturedef, deps={}): - self.fixturefunc = fixturefunc - # Note fixturedef.func + def __init__(self, fixturedef, deps={}): self.fixturedef = fixturedef self.deps = deps - self._ret = self.NOTSET + self.setup_done = False + self.teardown_done = False + self.result = None + self.lock = trio.Lock() + + async def setup(self): + async with self.lock: + if not self.setup_done: + self.result = await self._setup() + self.setup_done = True + return self.result + + async def _setup(self): + raise NotImplementedError() + + async def teardown(self): + async with self.lock: + if not self.teardown_done: + await self._teardown() + self.teardown_done = True + + async def _teardown(self): + raise NotImplementedError() + + +class AsyncYieldFixture(BaseAsyncFixture): + """ + Async generator fixture. + """ + + def __init__(self, *args): + super().__init__(*args) + self.agen = None + + async def _setup(self): + resolved_deps = await _setup_async_fixtures_in(self.deps) + self.agen = self.fixturedef.func(**resolved_deps) + return await self.agen.asend(None) + + async def _teardown(self): + try: + await self.agen.asend(None) + except StopAsyncIteration: + await _teardown_async_fixtures_in(self.deps) + else: + raise RuntimeError('Only one yield in fixture is allowed') + + +class SyncFixtureWithAsyncDeps(BaseAsyncFixture): + """ + Synchronous function fixture with asynchronous dependencies fixtures. + """ + + async def _setup(self): + resolved_deps = await _setup_async_fixtures_in(self.deps) + return self.fixturedef.func(**resolved_deps) + + async def _teardown(self): + await _teardown_async_fixtures_in(self.deps) + + +class SyncYieldFixtureWithAsyncDeps(BaseAsyncFixture): + """ + Synchronous generator fixture with asynchronous dependencies fixtures. + """ + + def __init__(self, *args): + super().__init__(*args) + self.agen = None + + async def _setup(self): + resolved_deps = await _setup_async_fixtures_in(self.deps) + self.gen = self.fixturedef.func(**resolved_deps) + return self.gen.send(None) + + async def _teardown(self): + try: + await self.gen.send(None) + except StopIteration: + await _teardown_async_fixtures_in(self.deps) + else: + raise RuntimeError('Only one yield in fixture is allowed') + - async def resolve(self): - if self._ret is self.NOTSET: - resolved_deps = await _resolve_async_fixtures_in(self.deps) - if inspect.iscoroutinefunction(self.fixturefunc): - self._ret = await self.fixturefunc(**resolved_deps) - else: - self._ret = self.fixturefunc(**resolved_deps) - return self._ret +class AsyncFixture(BaseAsyncFixture): + """ + Regular async fixture (i.e. coroutine). + """ + + async def _setup(self): + resolved_deps = await _setup_async_fixtures_in(self.deps) + return await self.fixturedef.func(**resolved_deps) + + async def _teardown(self): + await _teardown_async_fixtures_in(self.deps) def _install_async_fixture_if_needed(fixturedef, request): - deps = {dep: request.getfixturevalue(dep) for dep in fixturedef.argnames} asyncfix = None - if not deps and inspect.iscoroutinefunction(fixturedef.func): - # Top level async fixture - asyncfix = AsyncFixture(fixturedef.func, fixturedef) - elif any(dep for dep in deps.values() if isinstance(dep, AsyncFixture)): - # Fixture with async fixture dependencies - asyncfix = AsyncFixture(fixturedef.func, fixturedef, deps) - # The async fixture must be evaluated from within the trio context - # which is spawed in the function test's trio decorator. - # The trick is to make pytest's fixture call return the AsyncFixture - # object which will be actully resolved just before we run the test. + deps = {dep: request.getfixturevalue(dep) for dep in fixturedef.argnames} + if iscoroutinefunction(fixturedef.func): + asyncfix = AsyncFixture(fixturedef, deps) + elif isasyncgenfunction(fixturedef.func): + asyncfix = AsyncYieldFixture(fixturedef, deps) + elif any(dep for dep in deps.values() + if isinstance(dep, BaseAsyncFixture)): + if isgeneratorfunction(fixturedef.func): + asyncfix = SyncYieldFixtureWithAsyncDeps(fixturedef, deps) + else: + asyncfix = SyncFixtureWithAsyncDeps(fixturedef, deps) if asyncfix: - fixturedef.func = lambda **kwargs: asyncfix + fixturedef.cached_result = (asyncfix, request.param_index, None) + return asyncfix -@pytest.hookimpl(tryfirst=True) -def pytest_fixture_setup(fixturedef, request): - if 'trio' in request.keywords: - _install_async_fixture_if_needed(fixturedef, request) - - -@pytest.hookimpl(tryfirst=True) -def pytest_collection_modifyitems(session, config, items): - # Retrieve test marked as `trio` - for item in items: - if 'trio' not in item.keywords: - continue - if not inspect.iscoroutinefunction(item.function): +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_call(item): + if 'trio' in item.keywords: + if not iscoroutinefunction(item.obj): pytest.fail( 'test function `%r` is marked trio but is not async' % item ) item.obj = _trio_test_runner_factory(item) + yield + + +@pytest.hookimpl() +def pytest_fixture_setup(fixturedef, request): + if 'trio' in request.keywords: + return _install_async_fixture_if_needed(fixturedef, request) + @pytest.hookimpl(tryfirst=True) def pytest_exception_interact(node, call, report):