diff --git a/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py index f912dec9..538f1bd2 100644 --- a/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py +++ b/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py @@ -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() diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 942ad4de..dfbd9958 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -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 @@ -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: @@ -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 @@ -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] @@ -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 @@ -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) diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py index aa2ce3d7..699ac49d 100644 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -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 diff --git a/tests/markers/test_class_scope.py b/tests/markers/test_class_scope.py index 33e5d2db..1f664774 100644 --- a/tests/markers/test_class_scope.py +++ b/tests/markers/test_class_scope.py @@ -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) diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py index 1034af83..b778c9a9 100644 --- a/tests/markers/test_module_scope.py +++ b/tests/markers/test_module_scope.py @@ -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) diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py index fde2e836..3d898c8d 100644 --- a/tests/markers/test_package_scope.py +++ b/tests/markers/test_package_scope.py @@ -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) diff --git a/tests/markers/test_session_scope.py b/tests/markers/test_session_scope.py index 1242cfee..a9a8b7a8 100644 --- a/tests/markers/test_session_scope.py +++ b/tests/markers/test_session_scope.py @@ -227,3 +227,124 @@ 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_session_scoped_fixture_with_package_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="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="package") + 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_session_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="session") + 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_session_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="session") + 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_session_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="session") + 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)