Skip to content

Commit 810c9d7

Browse files
committed
[fix] Fixes a bug that caused tests to run in the wrong event loop when 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 <[email protected]>
1 parent fe12dcb commit 810c9d7

File tree

7 files changed

+336
-12
lines changed

7 files changed

+336
-12
lines changed

docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
class TestClassScopedLoop:
1010
loop: asyncio.AbstractEventLoop
1111

12-
@pytest_asyncio.fixture
12+
@pytest_asyncio.fixture(scope="class")
1313
async def my_fixture(self):
1414
TestClassScopedLoop.loop = asyncio.get_running_loop()
1515

pytest_asyncio/plugin.py

+29-9
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,6 @@ def _preprocess_async_fixtures(
204204
config = collector.config
205205
asyncio_mode = _get_asyncio_mode(config)
206206
fixturemanager = config.pluginmanager.get_plugin("funcmanage")
207-
marker = collector.get_closest_marker("asyncio")
208-
scope = marker.kwargs.get("scope", "function") if marker else "function"
209-
if scope == "function":
210-
event_loop_fixture_id = "event_loop"
211-
else:
212-
event_loop_node = _retrieve_scope_root(collector, scope)
213-
event_loop_fixture_id = event_loop_node.stash.get(_event_loop_fixture_id, None)
214207
for fixtures in fixturemanager._arg2fixturedefs.values():
215208
for fixturedef in fixtures:
216209
func = fixturedef.func
@@ -222,6 +215,14 @@ def _preprocess_async_fixtures(
222215
# Ignore async fixtures without explicit asyncio mark in strict mode
223216
# This applies to pytest_trio fixtures, for example
224217
continue
218+
scope = fixturedef.scope
219+
if scope == "function":
220+
event_loop_fixture_id = "event_loop"
221+
else:
222+
event_loop_node = _retrieve_scope_root(collector, scope)
223+
event_loop_fixture_id = event_loop_node.stash.get(
224+
_event_loop_fixture_id, None
225+
)
225226
_make_asyncio_fixture_function(func)
226227
function_signature = inspect.signature(func)
227228
if "event_loop" in function_signature.parameters:
@@ -589,6 +590,12 @@ def scoped_event_loop(
589590
yield loop
590591
loop.close()
591592
asyncio.set_event_loop_policy(old_loop_policy)
593+
# When a test uses both a scoped event loop and the event_loop fixture,
594+
# the "_provide_clean_event_loop" finalizer of the event_loop fixture
595+
# will already have installed a fresh event loop, in order to shield
596+
# subsequent tests from side-effects. We close this loop before restoring
597+
# the old loop to avoid ResourceWarnings.
598+
asyncio.get_event_loop().close()
592599
asyncio.set_event_loop(old_loop)
593600

594601
# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
@@ -680,7 +687,9 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
680687
)
681688
# Add the scoped event loop fixture to Metafunc's list of fixture names and
682689
# fixturedefs and leave the actual parametrization to pytest
683-
metafunc.fixturenames.insert(0, event_loop_fixture_id)
690+
# The fixture needs to be appended to avoid messing up the fixture evaluation
691+
# order
692+
metafunc.fixturenames.append(event_loop_fixture_id)
684693
metafunc._arg2fixturedefs[
685694
event_loop_fixture_id
686695
] = fixturemanager._arg2fixturedefs[event_loop_fixture_id]
@@ -885,8 +894,13 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
885894
fixturenames = item.fixturenames # type: ignore[attr-defined]
886895
# inject an event loop fixture for all async tests
887896
if "event_loop" in fixturenames:
897+
# Move the "event_loop" fixture to the beginning of the fixture evaluation
898+
# closure for backwards compatibility
888899
fixturenames.remove("event_loop")
889-
fixturenames.insert(0, event_loop_fixture_id)
900+
fixturenames.insert(0, "event_loop")
901+
else:
902+
if event_loop_fixture_id not in fixturenames:
903+
fixturenames.append(event_loop_fixture_id)
890904
obj = getattr(item, "obj", None)
891905
if not getattr(obj, "hypothesis", False) and getattr(
892906
obj, "is_hypothesis_test", False
@@ -944,6 +958,12 @@ def _session_event_loop(
944958
yield loop
945959
loop.close()
946960
asyncio.set_event_loop_policy(old_loop_policy)
961+
# When a test uses both a scoped event loop and the event_loop fixture,
962+
# the "_provide_clean_event_loop" finalizer of the event_loop fixture
963+
# will already have installed a fresh event loop, in order to shield
964+
# subsequent tests from side-effects. We close this loop before restoring
965+
# the old loop to avoid ResourceWarnings.
966+
asyncio.get_event_loop().close()
947967
asyncio.set_event_loop(old_loop)
948968

949969

tests/async_fixtures/test_async_fixtures_with_finalizer.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
import pytest
55

66

7-
@pytest.mark.asyncio
7+
@pytest.mark.asyncio(scope="module")
88
async def test_module_with_event_loop_finalizer(port_with_event_loop_finalizer):
99
await asyncio.sleep(0.01)
1010
assert port_with_event_loop_finalizer
1111

1212

13-
@pytest.mark.asyncio
13+
@pytest.mark.asyncio(scope="module")
1414
async def test_module_with_get_event_loop_finalizer(port_with_get_event_loop_finalizer):
1515
await asyncio.sleep(0.01)
1616
assert port_with_get_event_loop_finalizer

tests/markers/test_class_scope.py

+31
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,34 @@ async def test_runs_is_same_loop_as_fixture(self, my_fixture):
220220
)
221221
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
222222
result.assert_outcomes(passed=1)
223+
224+
225+
def test_asyncio_mark_allows_combining_class_scoped_fixture_with_function_scoped_test(
226+
pytester: pytest.Pytester,
227+
):
228+
pytester.makepyfile(
229+
dedent(
230+
"""\
231+
import asyncio
232+
233+
import pytest
234+
import pytest_asyncio
235+
236+
loop: asyncio.AbstractEventLoop
237+
238+
class TestMixedScopes:
239+
@pytest_asyncio.fixture(scope="class")
240+
async def async_fixture(self):
241+
global loop
242+
loop = asyncio.get_running_loop()
243+
244+
@pytest.mark.asyncio(scope="function")
245+
async def test_runs_in_different_loop_as_fixture(self, async_fixture):
246+
global loop
247+
assert asyncio.get_running_loop() is not loop
248+
249+
"""
250+
),
251+
)
252+
result = pytester.runpytest("--asyncio-mode=strict")
253+
result.assert_outcomes(passed=1)

tests/markers/test_module_scope.py

+61
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,64 @@ async def test_runs_is_same_loop_as_fixture(my_fixture):
219219
)
220220
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
221221
result.assert_outcomes(passed=1)
222+
223+
224+
def test_asyncio_mark_allows_combining_module_scoped_fixture_with_class_scoped_test(
225+
pytester: Pytester,
226+
):
227+
pytester.makepyfile(
228+
dedent(
229+
"""\
230+
import asyncio
231+
232+
import pytest
233+
import pytest_asyncio
234+
235+
loop: asyncio.AbstractEventLoop
236+
237+
@pytest_asyncio.fixture(scope="module")
238+
async def async_fixture():
239+
global loop
240+
loop = asyncio.get_running_loop()
241+
242+
@pytest.mark.asyncio(scope="class")
243+
class TestMixedScopes:
244+
async def test_runs_in_different_loop_as_fixture(self, async_fixture):
245+
global loop
246+
assert asyncio.get_running_loop() is not loop
247+
248+
"""
249+
),
250+
)
251+
result = pytester.runpytest("--asyncio-mode=strict")
252+
result.assert_outcomes(passed=1)
253+
254+
255+
def test_asyncio_mark_allows_combining_module_scoped_fixture_with_function_scoped_test(
256+
pytester: Pytester,
257+
):
258+
pytester.makepyfile(
259+
__init__="",
260+
test_mixed_scopes=dedent(
261+
"""\
262+
import asyncio
263+
264+
import pytest
265+
import pytest_asyncio
266+
267+
loop: asyncio.AbstractEventLoop
268+
269+
@pytest_asyncio.fixture(scope="module")
270+
async def async_fixture():
271+
global loop
272+
loop = asyncio.get_running_loop()
273+
274+
@pytest.mark.asyncio(scope="function")
275+
async def test_runs_in_different_loop_as_fixture(async_fixture):
276+
global loop
277+
assert asyncio.get_running_loop() is not loop
278+
"""
279+
),
280+
)
281+
result = pytester.runpytest("--asyncio-mode=strict")
282+
result.assert_outcomes(passed=1)

tests/markers/test_package_scope.py

+91
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,94 @@ async def test_runs_in_same_loop_as_fixture(my_fixture):
223223
)
224224
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
225225
result.assert_outcomes(passed=1)
226+
227+
228+
def test_asyncio_mark_allows_combining_package_scoped_fixture_with_module_scoped_test(
229+
pytester: Pytester,
230+
):
231+
pytester.makepyfile(
232+
__init__="",
233+
test_mixed_scopes=dedent(
234+
"""\
235+
import asyncio
236+
237+
import pytest
238+
import pytest_asyncio
239+
240+
loop: asyncio.AbstractEventLoop
241+
242+
@pytest_asyncio.fixture(scope="package")
243+
async def async_fixture():
244+
global loop
245+
loop = asyncio.get_running_loop()
246+
247+
@pytest.mark.asyncio(scope="module")
248+
async def test_runs_in_different_loop_as_fixture(async_fixture):
249+
global loop
250+
assert asyncio.get_running_loop() is not loop
251+
"""
252+
),
253+
)
254+
result = pytester.runpytest("--asyncio-mode=strict")
255+
result.assert_outcomes(passed=1)
256+
257+
258+
def test_asyncio_mark_allows_combining_package_scoped_fixture_with_class_scoped_test(
259+
pytester: Pytester,
260+
):
261+
pytester.makepyfile(
262+
__init__="",
263+
test_mixed_scopes=dedent(
264+
"""\
265+
import asyncio
266+
267+
import pytest
268+
import pytest_asyncio
269+
270+
loop: asyncio.AbstractEventLoop
271+
272+
@pytest_asyncio.fixture(scope="package")
273+
async def async_fixture():
274+
global loop
275+
loop = asyncio.get_running_loop()
276+
277+
@pytest.mark.asyncio(scope="class")
278+
class TestMixedScopes:
279+
async def test_runs_in_different_loop_as_fixture(self, async_fixture):
280+
global loop
281+
assert asyncio.get_running_loop() is not loop
282+
"""
283+
),
284+
)
285+
result = pytester.runpytest("--asyncio-mode=strict")
286+
result.assert_outcomes(passed=1)
287+
288+
289+
def test_asyncio_mark_allows_combining_package_scoped_fixture_with_function_scoped_test(
290+
pytester: Pytester,
291+
):
292+
pytester.makepyfile(
293+
__init__="",
294+
test_mixed_scopes=dedent(
295+
"""\
296+
import asyncio
297+
298+
import pytest
299+
import pytest_asyncio
300+
301+
loop: asyncio.AbstractEventLoop
302+
303+
@pytest_asyncio.fixture(scope="package")
304+
async def async_fixture():
305+
global loop
306+
loop = asyncio.get_running_loop()
307+
308+
@pytest.mark.asyncio
309+
async def test_runs_in_different_loop_as_fixture(async_fixture):
310+
global loop
311+
assert asyncio.get_running_loop() is not loop
312+
"""
313+
),
314+
)
315+
result = pytester.runpytest("--asyncio-mode=strict")
316+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)