A suite reports 340 passed, and eleven of those tests never executed a line of their body. That is the failure asyncio_mode exists to prevent in one direction and can cause in the other: in strict mode a coroutine test without the marker is not run by the plugin at all, and in auto mode pytest-asyncio claims coroutine tests that another plugin was meant to handle. Setting it deliberately — and in the right place — takes two lines and removes both.
Prerequisites
pytest >= 8.0andpytest-asyncio >= 0.24.- Knowledge of which async plugins the environment has;
pip list | grep -Ei "asyncio|anyio|trio|tornasync"answers it. - The loop-lifetime rules from pytest-asyncio in depth, which are configured separately.
Solution
Declare the mode in the project's configuration, and override it for any subtree that belongs to a different plugin.
# pyproject.toml — the default for the whole repository
[tool.pytest.ini_options]
# auto: every `async def test_*` and async fixture is claimed automatically,
# so no test needs @pytest.mark.asyncio.
asyncio_mode = "auto"
# Pin this too: leaving it unset means a plugin upgrade can move every
# fixture onto a different loop without any change in your repository.
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
; tests/anyio/pytest.ini — this subtree belongs to AnyIO
[pytest]
; strict: pytest-asyncio claims nothing here unless explicitly marked,
; leaving the anyio plugin free to collect these coroutine tests.
asyncio_mode = strict
# tests/anyio/conftest.py
import pytest
@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request):
# Safe now: pytest-asyncio is in strict mode here and ignores these tests.
return request.param
The split is the whole technique. One mode for the bulk of the suite, a narrower one where a different runner owns the tests, and no per-test markers anywhere.
Why this works
asyncio_mode is consulted during collection, when pytest-asyncio decides which items to wrap with its own pytest_pyfunc_call implementation. In strict mode it inspects each coroutine item for the asyncio marker and passes on anything unmarked; in auto mode it claims every coroutine function and every async generator fixture it sees. Because pytest reads configuration from the nearest applicable file, a pytest.ini inside a subdirectory changes that decision for exactly that subtree, which is what lets one repository host two runners.
Execution is untouched by the setting. Which loop runs a test, and how long that loop lives, comes from loop_scope on the marker or the fixture. Conflating the two is the most common misreading: switching to auto does not change any test's loop, and switching loop_scope does not change what is collected.
Edge cases and failure modes
- Two plugins, one module. Putting an AnyIO-parametrised test in a directory governed by
automode meanspytest-asynciomay claim it first, and theanyio_backendfixture is then never used. Separate directories, separate modes. - Markers left behind after switching to auto. They are harmless but misleading, and
--strict-markerswill not flag them sinceasynciois a real marker. Remove them in the same change. asyncio_modeon the command line.-o asyncio_mode=autoworks, but an IDE's test runner will not pass it, so the same test behaves differently depending on how it was launched. Configuration only.- Async fixtures in auto mode that nothing awaits. In
automode an async generator fixture is claimed even if every test using it is synchronous, which produces a confusing "coroutine was never awaited" at setup. Make the fixture synchronous if it does not await. - Upgrading from 0.21 or earlier.
automode existed, but async fixtures were handled differently; combine the mode change with removing anyevent_loopoverride rather than doing them separately.
Deciding which mode a repository should use
The decision is nearly mechanical once the inventory is done.
If pytest-asyncio is the only async plugin installed, use auto. It removes a decorator from every async test, removes the class of bug where somebody forgets one, and has no downside — there is no other plugin for it to steal tests from.
If anyio, pytest-trio or pytest-tornasync is also present, decide which one owns the majority of the suite. That one gets the root configuration; the minority gets a subdirectory with an overriding configuration file. Trying to keep both in strict mode and marking every test individually works, but it relies on every future test being marked correctly, which is precisely the discipline auto exists to remove.
A wrinkle worth checking: anyio ships a pytest plugin that is active whenever anyio is importable, and anyio is a transitive dependency of httpx, starlette and several other common packages. Many teams have it installed without knowing, which makes auto mode riskier than it appears.
# What is actually registered, as opposed to what you think is installed.
pytest --trace-config 2>&1 | grep -i "plugin registered" | grep -Ei "asyncio|anyio|trio"
plugin registered: <module 'anyio.pytest_plugin'>
plugin registered: <module 'pytest_asyncio.plugin'>
Two registered async plugins with auto mode at the root is the configuration that produces silently skipped tests. Either disable the one you do not use — -p no:anyio in addopts — or adopt the per-directory split above.
anyio arriving as a transitive dependency of httpx is the usual surprise.Verifying the arrangement before trusting it
Two commands confirm that every async test is collected exactly once and by the plugin you intended.
# 1. Nothing is silently unclaimed: no warnings about coroutines.
pytest -q -W error::RuntimeWarning --collect-only 2>&1 | tail -5
# 2. The AnyIO subtree is parametrised by backend, and the rest is not.
pytest tests/anyio --collect-only -q | head -4
pytest tests/unit --collect-only -q | head -4
tests/anyio/test_streams.py::test_send_receive[asyncio]
tests/anyio/test_streams.py::test_send_receive[trio]
tests/unit/test_client.py::test_fetch_returns_json
tests/unit/test_client.py::test_fetch_raises_on_404
Backend suffixes in one tree and not the other is the shape that says both plugins are collecting the tests they own. A missing suffix in tests/anyio means pytest-asyncio claimed those tests despite the override — usually because the override file is in the wrong directory, or because pyproject.toml's [tool.pytest.ini_options] at the root is being treated as the rootdir configuration and the subdirectory file is not a recognised configuration filename.
The last point is worth stating precisely: pytest recognises pytest.ini, pyproject.toml, tox.ini and setup.cfg as configuration files, but only one of them is used per run — the one found at the rootdir. A pytest.ini in a subdirectory does not override the root configuration in the way a conftest.py overrides fixtures. Where a genuine per-directory override is required, the reliable mechanism is a separate invocation for that subtree, or the --override-ini flag in a dedicated CI step, and the marker-based approach becomes the simpler answer.
Migrating a marked suite to auto mode
For an asyncio-only repository the migration is a single mechanical change, and it is worth doing because every future async test is then correct by default.
# Remove the decorator form and the module-level pytestmark assignments.
grep -rln "pytest.mark.asyncio" tests/ | xargs sed -i \
-e '/^@pytest\.mark\.asyncio$/d' \
-e '/^pytestmark = pytest\.mark\.asyncio$/d'
pytest -q # must report the same number of tests as before
The check afterwards is the part that matters: the collected count must be unchanged. A drop means some tests are no longer being claimed, which in auto mode should be impossible unless another plugin took them — the inventory problem above, discovered at the right moment.
Markers carrying arguments need more care. @pytest.mark.asyncio(loop_scope="module") is not redundant under auto mode, because the loop scope is not implied by the collection mode; deleting it silently moves those tests back to a function-scoped loop and can reintroduce the Event loop is closed failures the marker was added to fix. Delete only the bare form, and keep every marker that carries an argument.
sed patterns above anchor on the whole line precisely so that the parametrised forms are left untouched.Frequently Asked Questions
Does asyncio_mode change how tests execute?
No. It only decides which coroutine tests and fixtures pytest-asyncio claims at collection time. Execution — which loop, how long it lives — is controlled separately by loop_scope. A suite can switch from strict to auto without any test behaving differently, as long as no other async plugin is installed.
Can I use auto mode in one directory and strict in another?
Per-directory configuration files are not merged the way conftest.py files are, so the reliable split is a separate pytest invocation for that subtree, or --override-ini=asyncio_mode=strict in the job that runs it. Markers remain the fallback when a single invocation must cover both.
What happens if both pytest-asyncio and anyio try to run the same test?
Whichever plugin's pytest_pyfunc_call implementation runs first claims the test, and plugin order is not something you should rely on. The test then either runs on the wrong backend or is reported as passing without executing. Keep them in separate directories with separate invocations.
Related
- pytest-asyncio in Depth — the loop-scope half of the configuration, which this setting does not affect.
- Sharing an Event Loop Across a Test Module — what to reach for once collection is settled.
- Testing with AnyIO & Trio — the plugin on the other side of this decision.
- Turning Warnings into Errors with filterwarnings — how the never-awaited warning becomes a failure instead of noise.
← Back to pytest-asyncio in Depth