Async Tests — pytest-asyncio & AsyncClient
Advancedhttpx.AsyncClient over ASGITransport calls the app inside a real event loop — required when tests must await alongside requests (async DB checks, gather, WebSockets). pytest-asyncio's loop-scope config is the gotcha.
Overview
TestClient wraps your async app in a synchronous facade — fine until the test itself needs to await: seeding an async database session, asserting on the DB after a request, firing concurrent requests with gather to catch race conditions, or driving WebSocket conversations. The async testing stack is pytest-asyncio (async test functions run in an event loop) plus httpx.AsyncClient with ASGITransport — the same in-process call, awaitable. The infamous gotcha is event-loop scope: async fixtures (engine, client) and tests must share a loop, or you hit "attached to a different loop" errors — solved with one line of loop_scope configuration. WebSocket tests, interestingly, stay on the sync TestClient, whose websocket_connect context manager drives full conversations. The guiding rule keeps suites sane: sync TestClient wherever it suffices, AsyncClient exactly where awaiting is the point.
AsyncClient + ASGITransport — Awaitable In-Process Calls
Same app, same in-process transport, but now the test can await around the request — the pattern for async-SQLAlchemy apps where seeding and asserting happen with await.
# pip install pytest-asyncio
# pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto" # async def tests just work
# asyncio_default_fixture_loop_scope = "session" # ← THE gotcha fix:
# fixtures + tests share one loop; without it, session-scoped async
# fixtures die with "Future attached to a different loop"
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def aclient():
transport = ASGITransport(app=app) # in-process, no server — TestClient's
async with AsyncClient(transport=transport,
base_url="http://test") as c:
yield c
async def test_create_student(aclient, async_db): # async db fixture
# arrange — AWAIT the seed (impossible around sync TestClient):
await seed_students(async_db, count=3)
# act
resp = await aclient.get("/students?branch=CS")
# assert on response AND database state:
assert resp.status_code == 200
row = (await async_db.execute(
select(func.count()).select_from(Student))).scalar()
assert row == 3
# ── concurrency bugs only async tests can catch ──
import asyncio
async def test_no_double_registration_race(aclient):
"""Two simultaneous registrations for the last seat: exactly one wins."""
results = await asyncio.gather(
aclient.post("/drives/7/register/1"),
aclient.post("/drives/7/register/1"),
)
codes = sorted(r.status_code for r in results)
assert codes == [201, 409] # one created, one conflict — not [201, 201]!
# TestClient cannot fire truly concurrent requests; gather can.
# This test catches the missing unique-constraint/locking bug.WebSocket Tests + When to Use Which Client
websocket_connect on the SYNC TestClient drives full WS conversations — send, receive, assert, disconnect. Then the decision rule that keeps the suite simple.
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_chat_echo_and_broadcast():
with client.websocket_connect("/ws/drives/7/chat") as ws:
joined = ws.receive_json() # the "someone joined" sys msg
assert joined["sys"] == "someone joined"
ws.send_json({"name": "Asha", "text": "TCS drive when?"})
msg = ws.receive_json()
assert msg == {"from": "Asha", "text": "TCS drive when?"}
# context-manager exit = clean disconnect — manager cleanup runs
def test_ws_rejects_bad_token():
import pytest
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/ws/notifications?token=garbage"):
pass
assert exc.value.code == 1008 # policy violation
def test_two_clients_see_each_other():
with client.websocket_connect("/ws/drives/7/chat") as a, \
client.websocket_connect("/ws/drives/7/chat") as b:
a.receive_json(); b.receive_json(); a.receive_json() # join notices
a.send_json({"name": "Asha", "text": "hi"})
assert b.receive_json()["text"] == "hi" # broadcast reached b
# ── Which client, when ──
# plain endpoint tests, validation, auth → TestClient (sync, simplest)
# need to await around the call → AsyncClient (async DB, seeding)
# concurrent-request race tests → AsyncClient + gather
# WebSocket conversations → TestClient.websocket_connect
# SSE/streaming → client.stream(...) — iterate
# chunks, assert frames arrive
# Mixed suites are normal: 80% sync tests, async where awaiting IS the test.Key Points to Remember
- 1AsyncClient + ASGITransport = awaitable in-process calls for async-native tests
- 2asyncio_default_fixture_loop_scope fixes the "different loop" fixture error
- 3asyncio.gather in tests catches race conditions TestClient cannot produce
- 4WebSockets test via sync TestClient.websocket_connect; SSE via client.stream
Interview Questions
Sign in to ask AriaWrite a test proving two concurrent registrations for one seat cannot both succeed.
Why does TestClient suffice for async endpoints, and when does it stop sufficing?
You get "Future attached to a different loop" in async fixtures — what happened?
Ask Aria about Async Tests — pytest-asyncio & AsyncClient
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.