Skip to content

How tos for loop scopes #687

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 37 additions & 13 deletions docs/source/concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,43 @@ Concepts

asyncio event loops
===================
pytest-asyncio runs each test item in its own asyncio event loop. The loop can be accessed via ``asyncio.get_running_loop()``.

.. code-block:: python

async def test_runs_in_a_loop():
assert asyncio.get_running_loop()

Synchronous test functions can get access to an asyncio event loop via the `event_loop` fixture.

.. code-block:: python

def test_can_access_current_loop(event_loop):
assert event_loop
In order to understand how pytest-asyncio works, it helps to understand how pytest collectors work.
If you already know about pytest collectors, please :ref:`skip ahead <pytest-asyncio-event-loops>`.
Otherwise, continue reading.
Let's assume we have a test suite with a file named *test_all_the_things.py* holding a single test, async or not:

.. include:: concepts_function_scope_example.py
:code: python

The file *test_all_the_things.py* is a Python module with a Python test function.
When we run pytest, the test runner descends into Python packages, modules, and classes, in order to find all tests, regardless whether the tests will run or not.
This process is referred to as *test collection* by pytest.
In our particular example, pytest will find our test module and the test function.
We can visualize the collection result by running ``pytest --collect-only``::

<Module test_all_the_things.py>
<Function test_runs_in_a_loop>

The example illustrates that the code of our test suite is hierarchical.
Pytest uses so called *collectors* for each level of the hierarchy.
Our contrived example test suite uses the *Module* and *Function* collectors, but real world test code may contain additional hierarchy levels via the *Package* or *Class* collectors.
There's also a special *Session* collector at the root of the hierarchy.
You may notice that the individual levels resemble the possible `scopes of a pytest fixture. <https://docs.pytest.org/en/7.4.x/how-to/fixtures.html#scope-sharing-fixtures-across-classes-modules-packages-or-session>`__

.. _pytest-asyncio-event-loops:

Pytest-asyncio provides one asyncio event loop for each pytest collector.
By default, each test runs in the event loop provided by the *Function* collector, i.e. tests use the loop with the narrowest scope.
This gives the highest level of isolation between tests.
If two or more tests share a common ancestor collector, the tests can be configured to run in their ancestor's loop by passing the appropriate *scope* keyword argument to the *asyncio* mark.
For example, the following two tests use the asyncio event loop provided by the *Module* collector:

.. include:: concepts_module_scope_example.py
:code: python

It's highly recommended for neighboring tests to use the same event loop scope.
For example, all tests in a class or module should use the same scope.
Assigning neighboring tests to different event loop scopes is discouraged as it can make test code hard to follow.

Test discovery modes
====================
Expand Down
8 changes: 8 additions & 0 deletions docs/source/concepts_function_scope_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import asyncio

import pytest


@pytest.mark.asyncio
async def test_runs_in_a_loop():
assert asyncio.get_running_loop()
17 changes: 17 additions & 0 deletions docs/source/concepts_module_scope_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import asyncio

import pytest

loop: asyncio.AbstractEventLoop


@pytest.mark.asyncio(scope="module")
async def test_remember_loop():
global loop
loop = asyncio.get_running_loop()


@pytest.mark.asyncio(scope="module")
async def test_runs_in_a_loop():
global loop
assert asyncio.get_running_loop() is loop
14 changes: 14 additions & 0 deletions docs/source/how-to-guides/class_scoped_loop_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import asyncio

import pytest


@pytest.mark.asyncio(scope="class")
class TestInOneEventLoopPerClass:
loop: asyncio.AbstractEventLoop

async def test_remember_loop(self):
TestInOneEventLoopPerClass.loop = asyncio.get_running_loop()

async def test_assert_same_loop(self):
assert asyncio.get_running_loop() is TestInOneEventLoopPerClass.loop
4 changes: 4 additions & 0 deletions docs/source/how-to-guides/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ How-To Guides
.. toctree::
:hidden:

run_class_tests_in_same_loop
run_module_tests_in_same_loop
run_package_tests_in_same_loop
run_session_tests_in_same_loop
multiple_loops
uvloop
test_item_is_async
Expand Down
17 changes: 17 additions & 0 deletions docs/source/how-to-guides/module_scoped_loop_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import asyncio

import pytest

pytestmark = pytest.mark.asyncio(scope="module")

loop: asyncio.AbstractEventLoop


async def test_remember_loop():
global loop
loop = asyncio.get_running_loop()


async def test_assert_same_loop():
global loop
assert asyncio.get_running_loop() is loop
3 changes: 3 additions & 0 deletions docs/source/how-to-guides/package_scoped_loop_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytestmark = pytest.mark.asyncio(scope="package")
8 changes: 8 additions & 0 deletions docs/source/how-to-guides/run_class_tests_in_same_loop.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
======================================================
How to run all tests in a class in the same event loop
======================================================
All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="class")``.
This is easily achieved by using the *asyncio* marker as a class decorator.

.. include:: class_scoped_loop_example.py
:code: python
8 changes: 8 additions & 0 deletions docs/source/how-to-guides/run_module_tests_in_same_loop.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
=======================================================
How to run all tests in a module in the same event loop
=======================================================
All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="module")``.
This is easily achieved by adding a `pytestmark` statement to your module.

.. include:: module_scoped_loop_example.py
:code: python
11 changes: 11 additions & 0 deletions docs/source/how-to-guides/run_package_tests_in_same_loop.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
========================================================
How to run all tests in a package in the same event loop
========================================================
All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="package")``.
Add the following code to the ``__init__.py`` of the test package:

.. include:: package_scoped_loop_example.py
:code: python

Note that this marker is not passed down to tests in subpackages.
Subpackages constitute their own, separate package.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
==========================================================
How to run all tests in the session in the same event loop
==========================================================
All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="session")``.
The easiest way to mark all tests is via a ``pytest_collection_modifyitems`` hook in the ``conftest.py`` at the root folder of your test suite.

.. include:: session_scoped_loop_example.py
:code: python
10 changes: 10 additions & 0 deletions docs/source/how-to-guides/session_scoped_loop_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import pytest

from pytest_asyncio import is_async_test


def pytest_collection_modifyitems(items):
pytest_asyncio_tests = (item for item in items if is_async_test(item))
session_scope_marker = pytest.mark.asyncio(scope="session")
for async_test in pytest_asyncio_tests:
async_test.add_marker(session_scope_marker)