Testing Async FastAPI Endpoints with pytest-asyncio

Key takeaways:

  • asyncio_mode = auto removes the per-test marker; strict is safer when other async plugins are installed.
  • Fixture scope and loop scope are separate settings, and mismatching them is the single biggest source of confusion.
  • "got Future attached to a different loop" means a resource crossed loops — almost always a session fixture used by function-loop tests.
  • Match the loop scope of a shared resource to the loop scope of the tests that consume it.
  • Use httpx.AsyncClient with ASGITransport so the app runs on the test's loop.

Your async tests passed individually and then one of them started failing with a RuntimeError about event loops. This page is part of Testing FastAPI Applications, and it covers the configuration that makes async tests work, plus the loop-scope error that everybody hits once. All output below is produced by running pytest 9.1.1 with pytest-asyncio 1.4.0 on Python 3.12.

Loop scope mismatch versus matched loop scope On the left a session fixture on the session loop is used by tests on separate function loops, which fails. On the right the fixture and both tests share the session loop and pass. mismatched fixture on loop A test 1: loop B test 2: loop C RuntimeError: Future attached to a different loop matched fixture on loop A test 1: loop A test 2: loop A 2 passed one loop owns the shared resource
The fixture's loop and the test's loop must be the same loop for any resource that binds to one.

The Configuration

pytest cannot run a coroutine on its own — an async def test function returns a coroutine object that a plain pytest run would collect, never await, and (in recent versions) warn about. pytest-asyncio supplies the plumbing, and asyncio_mode decides how much you have to say about it.

In strict mode, the default, every async test needs @pytest.mark.asyncio and every async fixture needs @pytest_asyncio.fixture. Verbose, but explicit, and it coexists safely with other async plugins such as anyio's, which is why library authors tend to prefer it.

In auto mode, every async def test is treated as an asyncio test and every async fixture is detected automatically. For an application codebase where everything is async, this is what you want:

[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
asyncio_default_test_loop_scope = session

Those last two lines are the part people omit, and they are the subject of the rest of this page.

Scope Is Not Loop Scope

There are two independent questions about an async fixture. How often is it created? — that is scope, the ordinary pytest concept. Which event loop does it run on? — that is loop_scope, which pytest-asyncio added precisely because the two answers are often different.

By default, tests run on a function-scoped event loop: a fresh loop per test, torn down after. That is a good default because it guarantees isolation. But a fixture declared scope="session" is created once and reused, while each consuming test brings its own loop — so any object the fixture bound to its loop is now being used from a different one.

Most async resources bind to a loop at creation or first use: asyncio.Future, asyncio.Lock and asyncio.Queue when first awaited, database connection pools, and clients that maintain persistent connections. Awaiting such an object from a different loop is what produces the error below.

The Real Failure

Here is the smallest reproduction — a session-scoped fixture creating a future, and a test awaiting it:

import asyncio

import pytest


@pytest.fixture(scope="session")
async def pool():
    """A session-scoped async fixture: this future is bound to the loop that ran the fixture."""
    return asyncio.get_running_loop().create_future()


async def test_uses_pool(pool):
    # Each test gets its own event loop by default, so awaiting the fixture's future explodes.
    await asyncio.wait_for(pool, timeout=0.05)

with pytest.ini:

[pytest]
asyncio_mode = auto

Running that under pytest produces this — real output, captured by executing pytest as a subprocess and normalising only the temporary directory paths and the run duration:

$ GET /broken
200 OK
{
  "exit_code": 1,
  "output": [
    "F                                                                        [100%]",
    "=================================== FAILURES ===================================",
    "E   RuntimeError: Task <Task pending name='Task-2' coro=<test_uses_pool() running at /demo/test_case.py:15> cb=[_run_until_complete_cb() at <stdlib>/asyncio/base_events.py:181]> got Future <Future pending> attached to a different loop",
    "<stdlib>/asyncio/tasks.py:520: RuntimeError: Task <Task pending name='Task-2' coro=<test_uses_pool() running at /demo/test_case.py:15> cb=[_run_until_complete_cb() at <stdlib>/asyncio/base_events.py:181]> got Future <Future pending> attached to a different loop",
    "=========================== short test summary info ============================",
    "FAILED test_case.py::test_uses_pool - RuntimeError: Task <Task pending name='...",
    "1 failed in Xs"
  ]
}

got Future <Future pending> attached to a different loop is the exact string, and it is worth recognising because the message names neither the fixture nor the scope setting that caused it. In a real suite this shows up as an SQLAlchemy async engine, an aioredis client, or an httpx.AsyncClient created once for the session — the future in this example is just the smallest object with the same binding behaviour.

A close relative is RuntimeError: Event loop is closed, which appears when the fixture's loop was torn down before the resource was cleaned up. Same root cause, different timing.

The Fix

Align the loop scopes. If a resource is shared across tests, the tests must run on the loop that created it:

[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
asyncio_default_test_loop_scope = session

With that configuration and the same fixture, plus an httpx.AsyncClient bound to the app:

import asyncio

import httpx
import pytest

from app_under_test import app


@pytest.fixture(scope="session")
async def pool():
    return asyncio.get_running_loop().create_future()


@pytest.fixture(scope="session")
async def client():
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
        yield c


async def test_same_loop(pool):
    # Fixture and test now share the session loop, so the future is usable here.
    assert asyncio.get_running_loop() is pool.get_loop()


async def test_endpoint(client):
    response = await client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}

The real result:

$ GET /fixed
200 OK
{
  "exit_code": 0,
  "output": [
    "..                                                                       [100%]",
    "2 passed in Xs"
  ]
}

The test_same_loop assertion is doing real work: pool.get_loop() returns the loop the future is bound to, and comparing it by identity to the running loop is a direct statement of the invariant that was violated before.

If you prefer per-fixture control to a global setting — and in a suite with a mix of shared and per-test resources you probably do — set the scopes at the declaration:

import pytest_asyncio


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def engine():
    ...


@pytest.mark.asyncio(loop_scope="session")
async def test_uses_engine(engine):
    ...

The rule to internalise: a resource's loop scope must be at least as wide as the loop scope of every test that touches it. A session-scoped resource with function-scoped tests is broken. A function-scoped resource with session-scoped tests is merely wasteful.

Why AsyncClient Rather Than TestClient

TestClient is synchronous. It runs your ASGI app on an event loop it manages in a worker thread through anyio's blocking portal, which means the app is not running on your test's loop. That is fine for straightforward request assertions and it is why TestClient works without any async configuration at all — but a session-scoped async fixture created on the test loop and a database session created on the portal's loop are once again two different loops.

httpx.AsyncClient with ASGITransport runs the app in-process on the current loop, so fixtures, the client and the application share one loop and one context. That is what makes shared async resources behave, and it is why async-first suites standardise on it. The full comparison is in TestClient vs httpx AsyncClient.

One consequence worth knowing: with ASGITransport there is no real socket, so the app's lifespan events do not run unless you drive them explicitly. If your startup handler is what populates app.state.db_pool, tests will find it missing. Wrap the client fixture in LifespanManager from asgi-lifespan, or set the state directly in the fixture — see lifespan events vs startup shutdown.

Verification

Two habits catch loop problems before they become mysterious:

@pytest.mark.asyncio(loop_scope="session")
async def test_fixture_and_test_share_a_loop(engine):
    assert asyncio.get_running_loop() is engine.loop_at_creation

and running the suite in a different order (pytest -p no:randomly off, or with pytest-randomly on) to make sure passing is not an accident of ordering. Loop-scope bugs are order-sensitive: the first test to touch a shared resource often succeeds and the second fails, so a suite that always runs alphabetically can hide one for months.

Also run with -W error::RuntimeWarning occasionally. "coroutine was never awaited" warnings usually mean a fixture or a call is being collected rather than executed, which is the strict-mode failure that quietly turns a test into a no-op.

Trade-offs and When Not To

Session-scoped loops trade isolation for shared resources. Tests then share every loop-bound object, so a test that leaves a pending task, an unclosed connection or a mutated global affects the ones after it. If your suite is fast and your resources are cheap to build, function-scoped everything is simpler and safer — a fresh loop per test cannot leak state at all.

The middle ground most projects land on is a session-scoped engine or connection pool with a function-scoped transaction wrapped around each test, rolled back at the end. That gives you one expensive resource and per-test isolation of the data, and it is described in testing with async database fixtures.

Finally, asyncio_mode = auto is convenient and slightly magical: every async test is claimed by pytest-asyncio, including tests you intended another plugin to run. In a repository where some tests use anyio's trio backend, stay in strict mode and mark explicitly. Convenience settings that silently claim ownership are the ones that cost you an afternoon later.

FAQ

What causes 'got Future attached to a different loop' in pytest-asyncio? A resource created on one event loop is awaited on another. It usually means a session-scoped async fixture built a connection, lock or future on the session loop while each test runs on its own function-scoped loop.

What is the difference between fixture scope and loop scope? Scope controls how often the fixture value is created. Loop scope controls which event loop it runs on. They are independent in pytest-asyncio 1.x, so a session-scoped fixture can still execute on a function-scoped loop unless you set loop_scope explicitly.

Should I use asyncio_mode strict or auto? Auto is less typing and treats every async test as an asyncio test, which suits a codebase where everything is async. Strict requires an explicit marker and is safer in a mixed codebase or when another async plugin is installed.

Do I still need TestClient if I use httpx AsyncClient? Not for exercising async endpoints. AsyncClient with ASGITransport runs the app on the test's own loop, which is what makes async fixtures and shared resources work. TestClient runs the app on a loop it manages in a worker thread.

Why does my async fixture yield nothing usable? Usually because it is declared with plain @pytest.fixture in strict mode, so pytest treats the async generator as the value. Use @pytest_asyncio.fixture, or switch to auto mode where async fixtures are detected for you.