mrkeyoor.com_
Thu 06 Aug 01:02 UTC
PyPIUtilsupdated 05 Aug 2026

pytest-asyncio

pytest-asyncio is the pytest plugin that lets you write `async def` test functions. Plain pytest collects an async test, never awaits it, and reports a pass while your assertions never ran. This plugin intercepts those test items, creates an asyncio event loop, runs the coroutine to completion inside it, and tears the loop down afterwards. It does the same for async fixtures, including async generator fixtures where the code after `yield` becomes teardown. On top of that it gives you control over which tests share an event loop, through the `loop_scope` argument and matching config options, so a session-wide database connection opened in one loop is not used by a test running in a different one.

Verdict

The default answer for testing asyncio code with pytest, and worth the config friction because nothing else covers async fixtures and shared event loops as thoroughly. The cost is real upgrade churn across releases, so pin it and read the changelog before bumping.

API stability2/5Four painful changes in recent memory: the 0.23 loop rework that the changelog itself flagged as breaking suites, the scope to loop_scope rename in 0.24, removal of the event_loop fixture in 1.0, and deprecation of overriding event_loop_policy in 1.4 in favor of a new hook.
Docs4/5The readthedocs site has a concepts page, task-shaped how-to guides for loop scopes and uvloop, a full config reference, and dedicated migration guides from 0.21 and 0.23; the README itself is a stub that explains almost nothing.
Maintenance4/5Pushed August 2026 with 38 open issues (50 counting PRs), released 1.4.0 in May 2026, lives in the pytest-dev organization, and tracks new pytest and Python versions including free-threaded 3.14t.
Ecosystem5/5Around 64 million weekly downloads and the plugin that async Python libraries assume in their own test suites; adjacent plugins such as pytest-aiohttp are built on top of it.

Use it if

  • Your code under test is asyncio-based and you want `async def test_...` functions with real `await` calls instead of wrapping every test body in asyncio.run
  • You need async fixtures: an async database pool, an httpx.AsyncClient, or an aiohttp test server that is set up and torn down with await
  • You need several tests to share one event loop, for example a session-scoped connection pool, which is what loop_scope and asyncio_default_fixture_loop_scope exist to control
  • You are already on pytest 8.4 or newer and want the de facto standard plugin that async libraries assume in their contributing docs
Skip it if

Setup reality

pip install pytest-asyncio and pytest picks it up with no further wiring, but the defaults are deliberately conservative and will bite you. Strict mode is the default, so every async test needs @pytest.mark.asyncio and every async fixture needs @pytest_asyncio.fixture rather than @pytest.fixture; decorating an async fixture with plain @pytest.fixture in strict mode produces a deprecation warning and a fixture that yields a coroutine object instead of your value. Most teams set asyncio_mode = auto in pytest config and stop thinking about markers. You will also see a warning on every run until you set asyncio_default_fixture_loop_scope explicitly, because the unset default is going to change to function scope in a future release. Two more traps: scope and loop_scope on pytest_asyncio.fixture mean different things (how often the fixture runs versus which loop it runs in), and a function-scoped async fixture cannot be used by a session-scoped loop without matching them up. If you are upgrading from 0.21 or 0.23, read the two migration guides in the docs first; the event_loop fixture that older conftest.py files override no longer exists.

Patterns

Write an async test in the default strict modebasic-async-test

import pytest

@pytest.mark.asyncio
async def test_fetches_user(api):
    user = await api.get_user(1)
    assert user.name == "ada"

Without the marker, pytest collects the coroutine, never awaits it, and reports a pass with a PytestUnhandledCoroutineWarning. Any assertion inside would never have run.

Turn on auto mode so markers are unnecessaryauto-mode-config

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

# pytest.ini equivalent
# [pytest]
# asyncio_mode = auto
# asyncio_default_fixture_loop_scope = function

In auto mode every async test and every async fixture is handled automatically, so plain @pytest.fixture works on async fixtures too. Setting asyncio_default_fixture_loop_scope also silences the warning pytest-asyncio emits on every run while it is unset.

Define an async fixture with setup and teardownasync-fixture

import pytest_asyncio

@pytest_asyncio.fixture
async def client():
    c = await connect("postgres://localhost/test")
    yield c
    await c.close()

In strict mode you must use pytest_asyncio.fixture, not pytest.fixture; the plain decorator hands your test an un-awaited coroutine and emits a deprecation warning. Code after yield is the async teardown and runs in the same loop.

Share one expensive resource across the whole sessionsession-scoped-resource

import pytest_asyncio

@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def pool():
    pool = await create_pool(dsn, min_size=1, max_size=5)
    yield pool
    await pool.close()

scope controls how often the fixture body runs; loop_scope controls which event loop it runs in. They are separate on purpose, and getting them out of sync is the top source of 'attached to a different loop' errors, because most async clients bind to the loop that created them.

Run all tests in a module on one event loopmodule-scoped-loop

import pytest

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

async def test_writes(db):
    await db.execute("insert into t values (1)")

async def test_reads(db):
    assert await db.fetchval("select count(*) from t") == 1

Since 1.0 the marker no longer requires a matching pytest collector, so loop_scope='class' works even without a class. Use pytestmark to apply the marker to every test in the file instead of repeating it.

Change the default loop scope for every testdefault-test-loop-scope

# pytest.ini
[pytest]
asyncio_mode = auto
asyncio_default_test_loop_scope = session
asyncio_default_fixture_loop_scope = session

asyncio_default_test_loop_scope defaults to function, so each test otherwise gets a fresh loop. Moving the whole suite to a session loop is faster but leaks state between tests: a task left pending in one test can fire during another.

Parametrize an async testparametrize-async

import pytest

@pytest.mark.asyncio
@pytest.mark.parametrize("value", [1, 2, 3])
async def test_accepts(value):
    assert await validate(value) is True

Marker order does not matter here. Each parametrized case is its own test item, so with the default function loop scope each one gets its own event loop.

Run tests on uvloop instead of the default loopuvloop-backend

# conftest.py
import uvloop

def pytest_asyncio_loop_factories(config, item):
    return {"uvloop": uvloop.new_event_loop}

This hook arrived in 1.4.0 and replaces the older trick of overriding the event_loop_policy fixture, which is now deprecated because asyncio.AbstractEventLoopPolicy is itself deprecated in Python 3.14. Return more than one entry and tests are parametrized across the factories.

Pick which loop factories a single test runs onselect-loop-factories

import pytest

@pytest.mark.asyncio(loop_factories=["uvloop"])
async def test_only_on_uvloop():
    assert await throughput() > 10_000

Names must match keys returned by the pytest_asyncio_loop_factories hook. When only one factory is configured, test IDs stay unchanged; with several, the factory name is appended to each ID.

Turn on asyncio debug mode while testingasyncio-debug-mode

pytest tests --asyncio-debug

# or permanently, in pytest.ini
# [pytest]
# asyncio_debug = true

Added in 1.2.0. Debug mode logs coroutines that were never awaited and callbacks that block the loop for too long, which is how you find the slow synchronous call hiding inside an async test.

Replace a conftest that overrides event_loopmigrate-from-event-loop-fixture

# removed in 1.0 and no longer collected:
# @pytest.fixture(scope="session")
# def event_loop():
#     loop = asyncio.new_event_loop()
#     yield loop
#     loop.close()

# do this instead: pick the loop scope, and read the loop from inside the test
import asyncio
import pytest

@pytest.mark.asyncio(loop_scope="session")
async def test_uses_session_loop():
    loop = asyncio.get_running_loop()
    assert loop.is_running()

Old conftest files that define event_loop do not error, they are simply ignored, so a suite silently starts using function-scoped loops and session-scoped clients begin failing with cross-loop errors. Delete the fixture and set the loop scope instead.

Handle test classes that subclass unittestunittest-classes-unsupported

import unittest

class TestApi(unittest.IsolatedAsyncioTestCase):
    async def asyncSetUp(self):
        self.client = await connect()

    async def test_get(self):
        assert await self.client.get("/") == 200

pytest-asyncio does not run async methods on unittest.TestCase subclasses; the marker is ignored there. Use IsolatedAsyncioTestCase from the standard library, which pytest can still collect and run.

Alternatives

PackageRegistryPick it when
anyioPyPIYou want one test suite to run on both asyncio and trio, or your library already targets anyio; its pytest plugin comes in the same package.
pytest-trioPyPIYour code is trio-native and you want trio's nursery and clock fixtures rather than asyncio semantics.
pytest-aiohttpPyPIYou are testing an aiohttp server and want its test client and loop fixtures set up for you; it builds on pytest-asyncio in auto mode.