Skip to content

Commit

Permalink
[fix] Fixes a bug that caused tests to run in the wrong event loop wh…
Browse files Browse the repository at this point in the history
…en requesting larger-scoped fixtures in a narrower-scoped test.

Previously, pytest-asyncio relied on marks applied to pytest Collectors (e.g. classes and modules) to determine the loop scope. This logic is no longer applicable for fixtures, because pytest-asyncio now relies on the asyncio mark applied to tests. As a result, fixtures were looking for an "asyncio" mark on surrounding collectors to no avail and defaulted to choosing a function-scoped loop.

This patch chooses the loop scope based on the fixture scope.

Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de>
  • Loading branch information
seifertm committed Nov 16, 2023
1 parent fe12dcb commit 810c9d7
Show file tree
Hide file tree
Showing 7 changed files with 336 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
class TestClassScopedLoop:
loop: asyncio.AbstractEventLoop

@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="class")
async def my_fixture(self):
TestClassScopedLoop.loop = asyncio.get_running_loop()

Expand Down
38 changes: 29 additions & 9 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,6 @@ def _preprocess_async_fixtures(
config = collector.config
asyncio_mode = _get_asyncio_mode(config)
fixturemanager = config.pluginmanager.get_plugin("funcmanage")
marker = collector.get_closest_marker("asyncio")
scope = marker.kwargs.get("scope", "function") if marker else "function"
if scope == "function":
event_loop_fixture_id = "event_loop"
else:
event_loop_node = _retrieve_scope_root(collector, scope)
event_loop_fixture_id = event_loop_node.stash.get(_event_loop_fixture_id, None)
for fixtures in fixturemanager._arg2fixturedefs.values():
for fixturedef in fixtures:
func = fixturedef.func
Expand All @@ -222,6 +215,14 @@ def _preprocess_async_fixtures(
# Ignore async fixtures without explicit asyncio mark in strict mode
# This applies to pytest_trio fixtures, for example
continue
scope = fixturedef.scope
if scope == "function":
event_loop_fixture_id = "event_loop"
else:
event_loop_node = _retrieve_scope_root(collector, scope)
event_loop_fixture_id = event_loop_node.stash.get(
_event_loop_fixture_id, None
)
_make_asyncio_fixture_function(func)
function_signature = inspect.signature(func)
if "event_loop" in function_signature.parameters:
Expand Down Expand Up @@ -589,6 +590,12 @@ def scoped_event_loop(
yield loop
loop.close()
asyncio.set_event_loop_policy(old_loop_policy)
# When a test uses both a scoped event loop and the event_loop fixture,
# the "_provide_clean_event_loop" finalizer of the event_loop fixture
# will already have installed a fresh event loop, in order to shield
# subsequent tests from side-effects. We close this loop before restoring
# the old loop to avoid ResourceWarnings.
asyncio.get_event_loop().close()
asyncio.set_event_loop(old_loop)

# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
Expand Down Expand Up @@ -680,7 +687,9 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
)
# Add the scoped event loop fixture to Metafunc's list of fixture names and
# fixturedefs and leave the actual parametrization to pytest
metafunc.fixturenames.insert(0, event_loop_fixture_id)
# The fixture needs to be appended to avoid messing up the fixture evaluation
# order
metafunc.fixturenames.append(event_loop_fixture_id)
metafunc._arg2fixturedefs[
event_loop_fixture_id
] = fixturemanager._arg2fixturedefs[event_loop_fixture_id]
Expand Down Expand Up @@ -885,8 +894,13 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
fixturenames = item.fixturenames # type: ignore[attr-defined]
# inject an event loop fixture for all async tests
if "event_loop" in fixturenames:
# Move the "event_loop" fixture to the beginning of the fixture evaluation
# closure for backwards compatibility
fixturenames.remove("event_loop")
fixturenames.insert(0, event_loop_fixture_id)
fixturenames.insert(0, "event_loop")
else:
if event_loop_fixture_id not in fixturenames:
fixturenames.append(event_loop_fixture_id)
obj = getattr(item, "obj", None)
if not getattr(obj, "hypothesis", False) and getattr(
obj, "is_hypothesis_test", False
Expand Down Expand Up @@ -944,6 +958,12 @@ def _session_event_loop(
yield loop
loop.close()
asyncio.set_event_loop_policy(old_loop_policy)
# When a test uses both a scoped event loop and the event_loop fixture,
# the "_provide_clean_event_loop" finalizer of the event_loop fixture
# will already have installed a fresh event loop, in order to shield
# subsequent tests from side-effects. We close this loop before restoring
# the old loop to avoid ResourceWarnings.
asyncio.get_event_loop().close()
asyncio.set_event_loop(old_loop)


Expand Down
4 changes: 2 additions & 2 deletions tests/async_fixtures/test_async_fixtures_with_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
import pytest


@pytest.mark.asyncio
@pytest.mark.asyncio(scope="module")
async def test_module_with_event_loop_finalizer(port_with_event_loop_finalizer):
await asyncio.sleep(0.01)
assert port_with_event_loop_finalizer


@pytest.mark.asyncio
@pytest.mark.asyncio(scope="module")
async def test_module_with_get_event_loop_finalizer(port_with_get_event_loop_finalizer):
await asyncio.sleep(0.01)
assert port_with_get_event_loop_finalizer
Expand Down
31 changes: 31 additions & 0 deletions tests/markers/test_class_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,34 @@ async def test_runs_is_same_loop_as_fixture(self, my_fixture):
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_allows_combining_class_scoped_fixture_with_function_scoped_test(
pytester: pytest.Pytester,
):
pytester.makepyfile(
dedent(
"""\
import asyncio
import pytest
import pytest_asyncio
loop: asyncio.AbstractEventLoop
class TestMixedScopes:
@pytest_asyncio.fixture(scope="class")
async def async_fixture(self):
global loop
loop = asyncio.get_running_loop()
@pytest.mark.asyncio(scope="function")
async def test_runs_in_different_loop_as_fixture(self, async_fixture):
global loop
assert asyncio.get_running_loop() is not loop
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)
61 changes: 61 additions & 0 deletions tests/markers/test_module_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,64 @@ async def test_runs_is_same_loop_as_fixture(my_fixture):
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_allows_combining_module_scoped_fixture_with_class_scoped_test(
pytester: Pytester,
):
pytester.makepyfile(
dedent(
"""\
import asyncio
import pytest
import pytest_asyncio
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(scope="module")
async def async_fixture():
global loop
loop = asyncio.get_running_loop()
@pytest.mark.asyncio(scope="class")
class TestMixedScopes:
async def test_runs_in_different_loop_as_fixture(self, async_fixture):
global loop
assert asyncio.get_running_loop() is not loop
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_allows_combining_module_scoped_fixture_with_function_scoped_test(
pytester: Pytester,
):
pytester.makepyfile(
__init__="",
test_mixed_scopes=dedent(
"""\
import asyncio
import pytest
import pytest_asyncio
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(scope="module")
async def async_fixture():
global loop
loop = asyncio.get_running_loop()
@pytest.mark.asyncio(scope="function")
async def test_runs_in_different_loop_as_fixture(async_fixture):
global loop
assert asyncio.get_running_loop() is not loop
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)
91 changes: 91 additions & 0 deletions tests/markers/test_package_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,94 @@ async def test_runs_in_same_loop_as_fixture(my_fixture):
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_allows_combining_package_scoped_fixture_with_module_scoped_test(
pytester: Pytester,
):
pytester.makepyfile(
__init__="",
test_mixed_scopes=dedent(
"""\
import asyncio
import pytest
import pytest_asyncio
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(scope="package")
async def async_fixture():
global loop
loop = asyncio.get_running_loop()
@pytest.mark.asyncio(scope="module")
async def test_runs_in_different_loop_as_fixture(async_fixture):
global loop
assert asyncio.get_running_loop() is not loop
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_allows_combining_package_scoped_fixture_with_class_scoped_test(
pytester: Pytester,
):
pytester.makepyfile(
__init__="",
test_mixed_scopes=dedent(
"""\
import asyncio
import pytest
import pytest_asyncio
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(scope="package")
async def async_fixture():
global loop
loop = asyncio.get_running_loop()
@pytest.mark.asyncio(scope="class")
class TestMixedScopes:
async def test_runs_in_different_loop_as_fixture(self, async_fixture):
global loop
assert asyncio.get_running_loop() is not loop
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_allows_combining_package_scoped_fixture_with_function_scoped_test(
pytester: Pytester,
):
pytester.makepyfile(
__init__="",
test_mixed_scopes=dedent(
"""\
import asyncio
import pytest
import pytest_asyncio
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(scope="package")
async def async_fixture():
global loop
loop = asyncio.get_running_loop()
@pytest.mark.asyncio
async def test_runs_in_different_loop_as_fixture(async_fixture):
global loop
assert asyncio.get_running_loop() is not loop
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)
Loading

0 comments on commit 810c9d7

Please sign in to comment.