Testing Async FastAPI With SQLAlchemy and pytest
- Author
- Vishal Maurya
- Published on
- Reading time
- 4 min read
Overview
Async test suites for FastAPI tend to fail in confusing ways: RuntimeError: Event loop is closed, Task attached to a different loop, data leaking from one test into the next, or async def functions are not natively supported because tests were silently skipped. The causes are usually the event loop lifetime and the database session lifetime.
This guide shows a setup where each test gets an isolated, rolled-back database state.
1. Install and Configure pytest-asyncio
pip install pytest pytest-asyncio httpx
# pytest.ini
[pytest]
asyncio_mode = auto
In auto mode, async def tests and async fixtures are handled without a marker on each. In strict mode (the default) you decorate tests with @pytest.mark.asyncio and async fixtures with @pytest_asyncio.fixture. Fixture loop-scope options have changed across pytest-asyncio versions, so check the documentation for yours.
2. Use httpx With ASGITransport
TestClient is sync and runs the app in its own thread and loop, which makes sharing an async engine awkward. For async tests use httpx.AsyncClient.
import httpx
from app.main import app
async def test_health():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health")
assert response.status_code == 200
Note that this does not run lifespan events by default. If your app relies on startup, use a lifespan manager or construct what you need in fixtures.
3. Engine and Per-Test Rollback
Create the engine once, create tables once, and give each test a connection with an outer transaction that is rolled back.
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from app.models import Base
TEST_DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/app_test"
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def session(engine):
async with engine.connect() as conn:
trans = await conn.begin()
async with AsyncSession(
bind=conn,
join_transaction_mode="create_savepoint",
expire_on_commit=False,
) as session:
yield session
await trans.rollback()
join_transaction_mode="create_savepoint" (SQLAlchemy 2.0) makes session.commit() inside the app release a savepoint rather than committing the outer transaction, so the final rollback undoes everything. On SQLAlchemy 1.4 the pattern differs, using event listeners to restart nested transactions; check the documentation for your version.
4. Override the Dependency
Point the app's session dependency at the test session.
import httpx
import pytest_asyncio
from app.deps import get_session
from app.main import app
@pytest_asyncio.fixture
async def client(session):
async def override_get_session():
yield session
app.dependency_overrides[get_session] = override_get_session
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
Always clear overrides so they do not leak to other tests. Now tests are short and isolated:
async def test_create_and_read_user(client):
created = await client.post("/users", json={"name": "Asha"})
assert created.status_code == 201
fetched = await client.get(f"/users/{created.json()['id']}")
assert fetched.json()["name"] == "Asha"
5. Fixing Event Loop Errors
The usual cause is an async resource (an engine, a connection pool) created in one event loop and used in another. If you create the engine at import time in your app module and each test runs in a fresh loop, pooled connections bound to an old loop fail with Event loop is closed.
Options:
- Keep session-scoped async fixtures and tests on the same loop scope. In recent pytest-asyncio versions this is controlled with loop scope settings; in older ones people overrode an
event_loopfixture, which is deprecated and removed in newer versions. Follow the docs for your installed version rather than copying old snippets. - In tests, use
poolclass=NullPoolon the test engine so no connection outlives a loop. - Create the app engine in the lifespan or in a factory, not at import time, so tests can substitute theirs.
from sqlalchemy.pool import NullPool
engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
6. Other Pitfalls
- If your code calls
session.commit()and you test that data persisted, remember it persists only within the test transaction. A separate connection will not see it. - Code that opens its own session (background tasks, scripts) bypasses your override. Route all session creation through the dependency or a factory you can patch.
- Run migrations in CI against the test database at least once, rather than relying only on
create_all, so schema drift shows up. - Use a real PostgreSQL for tests if you use PostgreSQL features; SQLite behaves differently for constraints, types and locking.
Conclusion
A reliable async test setup needs a consistent event loop scope, an httpx client with ASGI transport, a dependency override for the session, and a per-test transaction that is rolled back.
If your FastAPI test suite is flaky or slow and you want it restructured, I can help. Contact me.