Skip to content

Commit 2eefc69

Browse files
committed
[feat] Introduce the asyncio_event_loop mark which provides a class-scoped asyncio event loop when a class has the mark.
Signed-off-by: Michael Seifert <[email protected]>
1 parent c99ef93 commit 2eefc69

File tree

3 files changed

+166
-0
lines changed

3 files changed

+166
-0
lines changed

docs/source/reference/markers.rst

+52
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,57 @@ In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is
3030
automatically to *async* test functions.
3131

3232

33+
``pytest.mark.asyncio_event_loop``
34+
==================================
35+
Test classes with this mark provide a class-scoped asyncio event loop.
36+
37+
This functionality is orthogonal to the `asyncio` mark.
38+
That means the presence of this mark does not imply that async test functions inside the class are collected by pytest-asyncio.
39+
The collection happens automatically in `auto` mode.
40+
However, if you're using strict mode, you still have to apply the `asyncio` mark to your async test functions.
41+
42+
The following code example uses the `asyncio_event_loop` mark to provide a shared event loop for all tests in `TestClassScopedLoop`:
43+
44+
.. code-block:: python
45+
46+
import asyncio
47+
48+
import pytest
49+
50+
51+
@pytest.mark.asyncio_event_loop
52+
class TestClassScopedLoop:
53+
loop: asyncio.AbstractEventLoop
54+
55+
@pytest.mark.asyncio
56+
async def test_remember_loop(self):
57+
TestClassScopedLoop.loop = asyncio.get_running_loop()
58+
59+
@pytest.mark.asyncio
60+
async def test_this_runs_in_same_loop(self):
61+
assert asyncio.get_running_loop() is TestClassScopedLoop.loop
62+
63+
In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted:
64+
65+
.. code-block:: python
66+
67+
import asyncio
68+
69+
import pytest
70+
71+
72+
@pytest.mark.asyncio_event_loop
73+
class TestClassScopedLoop:
74+
loop: asyncio.AbstractEventLoop
75+
76+
async def test_remember_loop(self):
77+
TestClassScopedLoop.loop = asyncio.get_running_loop()
78+
79+
async def test_this_runs_in_same_loop(self):
80+
assert asyncio.get_running_loop() is TestClassScopedLoop.loop
81+
82+
83+
84+
3385
.. |pytestmark| replace:: ``pytestmark``
3486
.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules

pytest_asyncio/plugin.py

+33
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727

2828
import pytest
29+
from _pytest.mark.structures import get_unpacked_marks
2930
from pytest import (
3031
Config,
3132
FixtureRequest,
@@ -176,6 +177,11 @@ def pytest_configure(config: Config) -> None:
176177
"mark the test as a coroutine, it will be "
177178
"run using an asyncio event loop",
178179
)
180+
config.addinivalue_line(
181+
"markers",
182+
"asyncio_event_loop: "
183+
"Provides an asyncio event loop in the scope of the marked test class",
184+
)
179185

180186

181187
@pytest.hookimpl(tryfirst=True)
@@ -339,6 +345,33 @@ def pytest_pycollect_makeitem(
339345
return None
340346

341347

348+
@pytest.hookimpl
349+
def pytest_collectstart(collector: pytest.Collector):
350+
if not isinstance(collector, pytest.Class):
351+
return
352+
# pytest.Collector.own_markers is empty at this point,
353+
# so we rely on _pytest.mark.structures.get_unpacked_marks
354+
marks = get_unpacked_marks(collector.obj, consider_mro=True)
355+
for mark in marks:
356+
if not mark.name == "asyncio_event_loop":
357+
continue
358+
359+
@pytest.fixture(
360+
scope="class",
361+
name="event_loop",
362+
)
363+
def scoped_event_loop(cls) -> Iterator[asyncio.AbstractEventLoop]:
364+
loop = asyncio.get_event_loop_policy().new_event_loop()
365+
yield loop
366+
loop.close()
367+
368+
# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
369+
# know it exists. We work around this by attaching the fixture function to the
370+
# collected Python class, where it will be picked up by pytest.Class.collect()
371+
collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop
372+
break
373+
374+
342375
def pytest_collection_modifyitems(
343376
session: Session, config: Config, items: List[Item]
344377
) -> None:

tests/markers/test_class_marker.py

+81
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test if pytestmark works when defined on a class."""
22
import asyncio
3+
from textwrap import dedent
34

45
import pytest
56

@@ -23,3 +24,83 @@ async def inc():
2324
@pytest.fixture
2425
def sample_fixture():
2526
return None
27+
28+
29+
def test_asyncio_event_loop_mark_provides_class_scoped_loop_strict_mode(
30+
pytester: pytest.Pytester,
31+
):
32+
pytester.makepyfile(
33+
dedent(
34+
"""\
35+
import asyncio
36+
import pytest
37+
38+
@pytest.mark.asyncio_event_loop
39+
class TestClassScopedLoop:
40+
loop: asyncio.AbstractEventLoop
41+
42+
@pytest.mark.asyncio
43+
async def test_remember_loop(self):
44+
TestClassScopedLoop.loop = asyncio.get_running_loop()
45+
46+
@pytest.mark.asyncio
47+
async def test_this_runs_in_same_loop(self):
48+
assert asyncio.get_running_loop() is TestClassScopedLoop.loop
49+
"""
50+
)
51+
)
52+
result = pytester.runpytest("--asyncio-mode=strict")
53+
result.assert_outcomes(passed=2)
54+
55+
56+
def test_asyncio_event_loop_mark_provides_class_scoped_loop_auto_mode(
57+
pytester: pytest.Pytester,
58+
):
59+
pytester.makepyfile(
60+
dedent(
61+
"""\
62+
import asyncio
63+
import pytest
64+
65+
@pytest.mark.asyncio_event_loop
66+
class TestClassScopedLoop:
67+
loop: asyncio.AbstractEventLoop
68+
69+
async def test_remember_loop(self):
70+
TestClassScopedLoop.loop = asyncio.get_running_loop()
71+
72+
async def test_this_runs_in_same_loop(self):
73+
assert asyncio.get_running_loop() is TestClassScopedLoop.loop
74+
"""
75+
)
76+
)
77+
result = pytester.runpytest("--asyncio-mode=auto")
78+
result.assert_outcomes(passed=2)
79+
80+
81+
def test_asyncio_event_loop_mark_is_inherited_to_subclasses(pytester: pytest.Pytester):
82+
pytester.makepyfile(
83+
dedent(
84+
"""\
85+
import asyncio
86+
import pytest
87+
88+
@pytest.mark.asyncio_event_loop
89+
class TestSuperClassWithMark:
90+
pass
91+
92+
class TestWithoutMark(TestSuperClassWithMark):
93+
loop: asyncio.AbstractEventLoop
94+
95+
@pytest.mark.asyncio
96+
async def test_remember_loop(self):
97+
TestWithoutMark.loop = asyncio.get_running_loop()
98+
99+
@pytest.mark.asyncio
100+
async def test_this_runs_in_same_loop(self):
101+
assert asyncio.get_running_loop() is TestWithoutMark.loop
102+
"""
103+
)
104+
)
105+
result = pytester.runpytest("--asyncio-mode=strict")
106+
result.assert_outcomes(passed=2)

0 commit comments

Comments
 (0)