python.
python5 min read

FastAPI TestClient vs httpx.AsyncClient: When You Actually Need Async Tests

A practical comparison of FastAPI's sync TestClient and httpx.AsyncClient with ASGI transport, including fixture patterns and when each one is the right call.

FastAPI TestClient vs httpx.AsyncClient: When You Actually Need Async Tests

Most FastAPI projects start with TestClient, hit a wall the first time a test needs to share an event loop with the app, and then panic-migrate everything to httpx.AsyncClient. That migration is often unnecessary. Sometimes it's mandatory. Knowing which case you are in saves a week of fixture-shuffling.

This piece walks through both clients, the ASGI transport that backs them, and the fixture patterns that keep your test suite fast without giving up async correctness.

What each client actually is

fastapi.TestClient is a thin wrapper around starlette.testclient.TestClient, which itself wraps httpx.Client (the sync one) plus an ASGI transport that runs your app in a background thread with its own event loop. From the test's perspective, every call is synchronous. You write response = client.get("/items") and never touch await.

httpx.AsyncClient is the async sibling. Paired with httpx.ASGITransport(app=app), it talks to your FastAPI app in-process, in the same event loop your test runs in. No sockets, no background thread, no port binding.

from fastapi import FastAPI
from fastapi.testclient import TestClient
import httpx
import pytest

app = FastAPI()

@app.get("/ping")
async def ping():
    return {"ok": True}

def test_sync_client():
    client = TestClient(app)
    response = client.get("/ping")
    assert response.json() == {"ok": True}

@pytest.mark.asyncio
async def test_async_client():
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get("/ping")
        assert response.json() == {"ok": True}

Both work. Both hit the same async endpoint. The difference is what runs where.

When sync TestClient is the right call

For 80% of FastAPI test suites, TestClient is the better default. The reasoning is mechanical, not aesthetic:

  • Tests stay readable. No async def, no pytest.mark.asyncio, no event-loop fixtures to wire up.
  • It composes cleanly with dependency_overrides and standard sync fixtures.
  • Stack traces are shorter. Async tracebacks routinely span 30+ frames through anyio, asyncio, and httpx internals.
  • Startup and shutdown events fire correctly via the context manager:
def test_with_lifespan():
    with TestClient(app) as client:
        response = client.get("/health")
        assert response.status_code == 200

If your test is "send a request, assert on the response, maybe check the DB," sync TestClient is faster to write and faster to debug. The threaded event loop is an implementation detail you can ignore.

When you actually need httpx.AsyncClient

Three scenarios force the switch. Anything outside these is preference, not necessity.

Scenario 1: shared async resources between test and app. If you build an async DB session, an httpx.AsyncClient for outbound calls, or a Redis pool inside a test fixture, and you want the FastAPI dependency to receive that same object, both pieces must run in the same event loop. TestClient runs the app in a different loop, so passing an async session across the boundary raises RuntimeError: <task> attached to a different loop.

Scenario 2: testing WebSocket flows alongside async business logic. TestClient.websocket_connect works, but if the test also needs to await a background task or query an async cache between WebSocket messages, you need everything on one loop.

Scenario 3: integration tests against a real async dependency you control. Spinning up an async Postgres pool, seeding data, running the app, and tearing down \u2014 all in one event loop \u2014 is straightforward with AsyncClient and painful with the threaded TestClient.

Here is the fixture pattern that works reliably:

import pytest_asyncio
import httpx
from main import app
from db import async_session_maker, override_get_session

@pytest_asyncio.fixture
async def async_client():
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(
        transport=transport,
        base_url="http://test",
    ) as client:
        yield client

@pytest_asyncio.fixture
async def db_session():
    async with async_session_maker() as session:
        yield session
        await session.rollback()

@pytest_asyncio.fixture
async def client_with_db(async_client, db_session):
    app.dependency_overrides[get_session] = lambda: db_session
    yield async_client
    app.dependency_overrides.clear()

Two notes worth knowing. First, pytest-asyncio 0.21+ requires either asyncio_mode = "auto" in pyproject.toml or @pytest_asyncio.fixture on every async fixture \u2014 the older @pytest.fixture decoration silently produces broken fixtures. Second, base_url="http://test" is not optional; httpx.AsyncClient requires a base URL when used with ASGITransport.

The performance question

A common claim is that AsyncClient is faster because it skips the thread hop. In practice the difference is closer to 5% than 3\u00d7 faster for typical request/response tests, and TestClient startup is actually cheaper than spinning up AsyncClient plus an event loop per test.

Where async wins is concurrent in-test load \u2014 running 50 requests in parallel inside a single test:

import asyncio

@pytest.mark.asyncio
async def test_concurrent_load(async_client):
    tasks = [async_client.get("/items") for _ in range(50)]
    responses = await asyncio.gather(*tasks)
    assert all(r.status_code == 200 for r in responses)

You cannot do this with TestClient without threads, and threads inside tests are a debugging hazard. If you need concurrency in your assertions, AsyncClient is the only sane option.

A pragmatic split

Most production codebases end up with both clients living in the same suite:

  • TestClient for route smoke tests, validation tests, auth flow checks, and anything where the test reads like a recipe.
  • AsyncClient for tests that touch async DB sessions, async caches, background tasks, or need concurrent in-test load.

Two fixtures, two import paths, no migration drama. Mixing them is the answer. The mistake teams make is choosing one and treating the other as forbidden \u2014 usually after reading a blog post that declared TestClient deprecated. It is not. It is maintained, documented, and the recommended default in the FastAPI docs.

The migration to AsyncClient is real work: every fixture rewritten, every test decorator changed, every shared helper made async. Do it when you hit one of the three forcing scenarios. Don't do it because it sounds more modern.

References