Testing Async FastAPI Endpoints with httpx
Your endpoints are async def. Your database driver is async. Your outbound calls are async. And yet the default testing advice still hands you a synchronous client that spins up a background event loop, which means the moment you need to share an async database session between your test and your endpoint, everything falls apart with a cryptic "attached to a different loop" error. This page resolves that: run the whole test through httpx.AsyncClient with an ASGITransport pointed straight at your app object, so the test, the fixtures, and the endpoint all live on one event loop. It extends Testing Python APIs with pytest, the parent guide covering suite structure and coverage strategy.
The commercial argument is simple. In-process async tests cost nothing to start, run four to eight times faster than anything that opens a real socket, and — because they share a loop with your production dependencies — they actually exercise the async database and HTTP code paths you ship. Faster suites mean more pushes per day, and a test suite that runs in twelve seconds instead of ninety is the difference between running it on every save and running it once before a release.
ASGITransport: skip the network entirely
httpx.ASGITransport is a transport implementation that speaks the ASGI protocol directly instead of speaking HTTP over TCP. When your client issues await client.get("/v1/usage"), httpx builds an ASGI scope dictionary, awaits your FastAPI application as a coroutine, and turns the response messages back into an httpx.Response. No port is bound, no socket is opened, no server process exists. Everything happens inside the same task on the same loop as your test function.
That matters far beyond speed. A live server runs your app on its event loop in its process, so a session-scoped SQLAlchemy engine created in a fixture is a completely different object from the one your endpoint uses. You cannot open a transaction in the test, let the endpoint write into it, and roll it back afterwards. With ASGITransport you can, because there is only one loop and one process.
The two options differ on more than plumbing. This is the table I would put in front of anyone still defaulting to the synchronous client.
| Aspect | TestClient (sync) | AsyncClient + ASGITransport |
|---|---|---|
| Event loop | Its own, hidden | Yours, shared |
| Async fixtures | Cannot share state | Share freely |
| Redirects | Followed by default | Not followed by default |
| Lifespan events | Run on context enter | Need asgi-lifespan |
The redirect row bites people during migrations: FastAPI redirects /items to /items/ with a 307, and a suite that passed under TestClient suddenly asserts 307 != 200. Pass follow_redirects=True on the client or fix the paths — fixing the paths is better, because it tells you your client SDK is also paying for an extra round trip.
The setup that actually works
Three files carry the whole pattern: pytest configuration that puts fixtures and tests on the same loop, a session-scoped app fixture that runs your lifespan handlers, and a function-scoped client. Start with configuration, because getting the loop scope wrong is the single most common cause of a hung suite.
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
addopts = "-q --strict-markers"
asyncio_mode = "auto" means every async def test_* runs without an explicit @pytest.mark.asyncio decorator — worth it purely for the class of bug it eliminates, because an un-marked async test is silently collected, never awaited, and reported as passing. The two loop-scope settings (pytest-asyncio 0.26 and later) put session fixtures and test functions on one shared loop; on older versions, decorate tests with @pytest.mark.asyncio(loop_scope="session") instead.
# tests/conftest.py
import os
from collections.abc import AsyncIterator
import httpx
import pytest
from asgi_lifespan import LifespanManager
from app.main import app
@pytest.fixture(scope="session")
async def lifespan_app() -> AsyncIterator:
"""Run startup/shutdown once per suite, not once per test."""
async with LifespanManager(app) as manager:
yield manager.app
@pytest.fixture
async def client(lifespan_app) -> AsyncIterator[httpx.AsyncClient]:
transport = httpx.ASGITransport(app=lifespan_app)
base_url = os.getenv("TEST_BASE_URL", "http://testserver")
async with httpx.AsyncClient(transport=transport, base_url=base_url) as ac:
yield ac
async def test_usage_endpoint_returns_current_period(client: httpx.AsyncClient):
response = await client.get("/v1/usage")
assert response.status_code == 200
assert response.json()["period"] == "current"
ASGITransport does not run the ASGI lifespan protocol, so anything you build in a lifespan handler — a connection pool, a Redis client, a warm cache — stays None unless you wrap the app. asgi-lifespan is a two-line dependency that fixes it, and doing it at session scope means you pay startup once for the whole run rather than once per test. If you have moved connection setup out of lifespan and into module-level globals, do the opposite: put it back in lifespan. Globals initialised at import time bind to whatever loop imported them, which is exactly the trap the next section is about.
Overriding auth and the database
app.dependency_overrides is a plain dict keyed by the dependency callable. FastAPI checks it before resolving anything, so replacing a dependency is a dict assignment — no monkeypatching, no import juggling. The two you will override on nearly every test are the database session and the current user.
For the database, the pattern that gives you both realism and isolation is a transaction the test owns: open a connection, begin a transaction, bind a session to that connection, hand the same session to the endpoint, and roll back afterwards. Every test sees a real Postgres with real constraints, and no test sees another test's rows. This builds on async database access with SQLAlchemy, and the driver choice matters here too — see asyncpg vs psycopg3 for FastAPI.
# tests/conftest.py (continued)
import os
from collections.abc import AsyncIterator
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.auth import get_current_user
from app.db import get_session
from app.main import app
from app.models import User
@pytest.fixture(scope="session")
async def engine():
url = os.getenv("TEST_DATABASE_URL")
if not url:
pytest.skip("TEST_DATABASE_URL is not set")
eng = create_async_engine(url, poolclass=NullPool)
yield eng
await eng.dispose()
@pytest.fixture
async def session(engine) -> AsyncIterator[AsyncSession]:
connection = await engine.connect()
transaction = await connection.begin()
maker = async_sessionmaker(bind=connection, expire_on_commit=False)
async with maker() as db_session:
yield db_session
await transaction.rollback()
await connection.close()
@pytest.fixture
def overrides(session: AsyncSession) -> AsyncIterator[None]:
async def _session_override() -> AsyncIterator[AsyncSession]:
yield session
def _user_override() -> User:
return User(id=int(os.getenv("TEST_USER_ID", "1")), plan="pro")
app.dependency_overrides[get_session] = _session_override
app.dependency_overrides[get_current_user] = _user_override
yield
app.dependency_overrides.clear()
NullPool is deliberate: a pooled engine holds connections between tests, and if any fixture ends up on a different loop those pooled connections become the source of the hangs described in fixing connection pool exhaustion. In tests you want a fresh connection and a clean close.
Overriding the auth dependency rather than minting real tokens is the right call for the ninety percent case — you are testing the endpoint's behaviour, not the signature algorithm. Keep a small number of tests that hit the real dependency with a genuine token so token verification itself stays covered; the mechanics of that live in handling API authentication in Python. Note the clear() after the yield: overrides live on the app object, which is module-level and shared, so forgetting this line leaks a fake user into every test that runs after it and produces failures that only appear when the suite runs in a particular order.
Why async tests hang or leak state
Four failure modes account for almost every "my async tests are broken" issue, and none of them produce a helpful error message.
Mismatched loop scope. A session-scoped fixture that creates a connection pool on one loop, used by function-scoped tests each on a fresh loop, either raises got Future attached to a different loop or simply waits forever on a lock that no running loop will ever release. Fix the scopes so they match, as in the configuration above.
An unmocked outbound call. With ASGITransport there is no network isolation — if your endpoint calls a payment provider, the test will actually call it, and if that host is unreachable in CI the test blocks until the httpx timeout fires. Set an explicit timeout on every outbound client and mock the calls; mocking external APIs with respx is the sibling page covering exactly that, and it plugs into the same transport layer.
Blocking code in an async endpoint. A synchronous database driver, time.sleep, or a heavy CPU loop inside async def blocks the one loop your test also lives on. In production a blocked loop shows up as latency; in a test it looks like a freeze.
Leaked global state. dependency_overrides left populated, an in-process cache that survives between tests, a module-level httpx client that was never closed. These produce order-dependent failures: green locally, red in CI when pytest happens to shard differently. Run pytest -p no:randomly -x versus a randomised order occasionally to catch them early.
When this approach wins, and when it does not
Use ASGITransport for essentially all endpoint tests: request validation, auth rules, error mapping, database behaviour, pagination, quota enforcement. It is the cheapest possible way to get real coverage, and at CI-minute prices the difference is not trivial. The chart below is a 400-test suite on a 2-vCPU runner; your absolute numbers will differ but the ratios hold.
Avoid it for three things. Anything that depends on real HTTP semantics — keep-alive, streaming chunk boundaries, proxy headers, HTTP/2 — needs a real server, because ASGITransport reconstructs the response from ASGI messages rather than parsing wire bytes. Anything that depends on your process manager, such as worker recycling or graceful shutdown behaviour during zero-downtime deploys. And post-deploy smoke checks, which by definition must exercise the deployed URL. Keep a handful of tests in each of those categories and let the in-process suite carry the other ninety-five percent.
Migrating an existing sync suite
The switch takes an afternoon on a mid-sized suite, and you can do it incrementally because both clients can coexist during the transition.
- Add
pytest-asyncio,asgi-lifespan, and thepyproject.tomlblock above. Existing sync tests keep passing. - Add the
lifespan_appandclientfixtures alongside your currentTestClientfixture — do not delete anything yet. - Convert one module:
def test_x(client)becomesasync def test_x(client), and everyclient.get(...)gains anawait. Fix the 307s that appear. - Move database fixtures to the transaction-per-test pattern and delete any truncate-between-tests helper you were using. This is usually where the biggest speed win comes from.
- Convert the rest module by module, then delete the sync fixture and remove
follow_redirects=Trueif you added it as a crutch.
One helper makes the same tests run against a deployed environment for smoke checks, so you are not maintaining two suites:
# tests/transport.py
import os
import httpx
from app.main import app
def build_client() -> httpx.AsyncClient:
mode = os.getenv("TEST_MODE", "asgi")
timeout = httpx.Timeout(float(os.getenv("TEST_TIMEOUT_SECONDS", "5")))
match mode:
case "asgi":
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url=os.getenv("TEST_BASE_URL", "http://testserver"),
timeout=timeout,
)
case "live":
return httpx.AsyncClient(
base_url=os.environ["SMOKE_BASE_URL"],
timeout=timeout,
)
case _:
raise ValueError(f"unsupported TEST_MODE: {mode}")
Builder verdict
httpx.AsyncClient with ASGITransport wins, and it is not close. It is faster than every alternative, it is the only option that lets a fixture and an endpoint share an async database transaction, and it removes an entire category of loop-related flakiness rather than working around it. The synchronous TestClient earns its place in exactly one scenario: a codebase with mostly def endpoints where nobody wants to touch pytest configuration. If you are shipping async endpoints on an async driver — and if you have read this far you are — the async client costs one dependency and one config block, and pays it back in CI minutes within a week. Spend the saved time on the tests that actually protect revenue — quota enforcement, billing webhooks, the auth boundary — because those are the failures that cost money. Pair this with the httpx over requests choice on the outbound side and you have one HTTP library across your app and your tests, which is one less thing to learn twice.
FAQ
Does an in-process test suite actually save meaningful money? On the runner itself, modestly: dropping a suite from 96 to 11 seconds across 40 CI runs a day saves roughly an hour of billed compute a month, which is single-digit dollars. The real saving is behavioural. A suite that finishes before you switch windows gets run on every change, so bugs get caught at the commit that caused them rather than three days later during a customer incident.
Can I keep using the synchronous TestClient for some tests?
Yes, and both can live in the same suite during a migration. What you cannot do is call TestClient from inside an async def test — it starts its own loop and will deadlock against the one you are already on. Keep sync tests as def functions and async tests as async def.
How do I test rate limiting and quota enforcement this way? Override the store your limiter reads (Redis or in-memory) with a fixture-controlled instance, then fire requests in a loop and assert the 429 arrives on the expected call. Because there is no network, a thousand-request quota test runs in well under a second — which makes it practical to test every tier boundary you actually bill on.
What breaks if I upgrade httpx?
The AsyncClient(app=...) shortcut was deprecated and then removed; the supported form is AsyncClient(transport=ASGITransport(app=app)). Pin httpx in your lockfile and centralise client construction in one helper like build_client() above, so a future signature change is a one-line fix rather than a sweep through 400 test files.
Should smoke tests against production reuse these tests? Reuse the request-shaping code, not the assertions. Read-only checks — health, version, an authenticated GET — port cleanly by swapping the transport. Anything that writes should not run against production data; point it at a staging deployment with its own database and its own keys.
Related
- Testing Python APIs with pytest — the parent guide covering suite layout, fixtures, and what to cover first.
- Mocking External APIs with respx — stop outbound calls leaving your test process and hanging the suite.
- Async Database Access with SQLAlchemy — the engine and session setup the transaction-per-test fixture depends on.
- Fixing Connection Pool Exhaustion — why pooled connections and mismatched loops cause the same symptom.
- Setting Up FastAPI — the application and lifespan structure these tests assume.