TestClient vs httpx AsyncClient in FastAPI

Key takeaways:

  • TestClient runs your app on a different event loop, on a different thread, via an AnyIO blocking portal.
  • AsyncClient + ASGITransport awaits your app on the loop the test is already running.
  • Calling TestClient from an async test freezes that test's loop completely — measured below as zero background ticks.
  • TestClient in a with block runs lifespan; ASGITransport never does.
  • TestClient requests cannot overlap; three 200 ms calls took 0.6 s versus 0.2 s under AsyncClient.

This page goes one level below Testing FastAPI Applications, which gives the decision table. Here we establish why that table says what it says, with real transcripts from real pytest runs.

Thread and loop layout of the two test clients With TestClient the test thread blocks while a portal thread runs the app on its own event loop. With AsyncClient the app is awaited on the test's own event loop on the same thread. TestClient: two threads, two loops MainThread test code, blocked loop makes no progress asyncio-portal thread its own event loop runs the ASGI app AsyncClient: one thread, one loop MainThread test coroutine and ASGI app share one event loop other tasks keep running while the request is in flight
Everything else about the two clients follows from this layout.

The Problem This Solves

Two clients, both officially supported, both apparently doing the same thing. The advice you find is usually stylistic — "use AsyncClient if you like async" — which is unhelpful, because the situations where the choice actually matters are not stylistic at all: an async fixture that will not connect, startup state that is mysteriously missing, a test that hangs, a concurrency assertion that never passes.

All of those trace back to one structural fact, so it is worth establishing precisely rather than by rule of thumb.

Why It Happens: One Owns a Loop, One Borrows Yours

Your FastAPI app is an ASGI callable — await app(scope, receive, send). Running it requires an event loop. The two clients differ only in where that loop comes from.

AsyncClient + ASGITransport borrows yours. ASGITransport.handle_async_request is itself a coroutine that awaits your app directly. There is no thread, no second loop, no bridge. When you await client.get(...) from an async test, the app runs as an ordinary part of that test's loop, interleaved with everything else scheduled on it.

TestClient owns one. Starlette's TestClient presents a synchronous API, so it opens an AnyIO blocking portal: a dedicated thread running its own event loop, plus a handle that lets synchronous code submit a coroutine and block until the result comes back. client.get("/x") submits the ASGI call to that portal and waits.

The measurement:

def test_testclient_is_a_sync_api():
    """No await anywhere: TestClient starts a portal that drives the loop on another thread."""
    REQUEST_ID.set("set-by-the-test")
    with TestClient(app) as client:
        SEEN["testclient"] = client.get("/whoami").json()
    assert SEEN["testclient"]["thread"] != threading.current_thread().name


async def test_asyncclient_runs_in_the_calling_loop():
    """ASGITransport awaits the app directly, on the loop the test is already running."""
    REQUEST_ID.set("set-by-the-test")
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        SEEN["asyncclient"] = (await client.get("/whoami")).json()
    assert SEEN["asyncclient"]["loop_id"] == id(asyncio.get_running_loop())
    assert SEEN["asyncclient"]["thread"] == threading.current_thread().name

The endpoint reports id(asyncio.get_running_loop()), its thread name, and a context variable. Real output:

$ GET /pytest/loops
200 OK
..
same_loop            = False
testclient_thread    = asyncio-portal-<id>
asyncclient_thread   = MainThread
contextvar_seen_by_testclient  = 'set-by-the-test'
contextvar_seen_by_asyncclient = 'set-by-the-test'
.
3 passed in 0.00s

same_loop = False is the whole story in one line. Under TestClient the app ran on a thread literally named asyncio-portal-<id>; under AsyncClient it ran on MainThread on the very loop the test was using — the assertion comparing loop ids passed.

The contextvar result is worth pausing on, because it contradicts a common assumption. AnyIO copies the caller's contextvars.Context into the portal, so a value set in the test was visible inside the endpoint under both clients. Request-id context propagation, of the kind described in Structured JSON Logging with Request IDs, therefore works fine under TestClient. What does not transfer is anything holding a reference to a specific loop — an asyncio.Lock that has been awaited, a connection pool that registered callbacks, a task created in your test. Those are loop-bound in a way contextvars are not.

Where TestClient Breaks: It Blocks Your Loop

The most consequential difference is invisible until you look for it. client.get() is a blocking wait. Inside a synchronous test that is fine — there is nothing else to run. Inside an async test, the thread it blocks is the thread running your event loop.

This suite quantifies it. A background task ticks every 10 ms; we count how many ticks land during a 200 ms request.

async def count_ticks_while(action) -> int:
    """A background task ticks every 10ms; how many ticks land during `action`?"""
    ticks = 0

    async def ticker():
        nonlocal ticks
        while True:
            await asyncio.sleep(0.01)
            ticks += 1

    task = asyncio.create_task(ticker())
    await asyncio.sleep(0.05)
    before = ticks
    await action()
    task.cancel()
    return ticks - before


async def test_testclient_freezes_the_calling_loop():
    async def call():
        with TestClient(app) as client:
            client.get("/slow")  # 200ms of app time, spent blocking this thread

    ticks = await count_ticks_while(call)
    assert ticks == 0


async def test_asyncclient_leaves_the_loop_free():
    async def call():
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client:
            await client.get("/slow")

    ticks = await count_ticks_while(call)
    assert ticks >= 15
$ GET /pytest/blocking
200 OK

ticks during a 200ms TestClient call  = 0
.ticks during a 200ms AsyncClient call = at least 15: True
.
2 passed in 0.00s

Zero. During a 200 ms request there was time for roughly twenty ticks, and not one occurred. The loop was completely frozen; the ticker task did not advance a single step. Under AsyncClient the same 200 ms request left the loop free and at least fifteen ticks landed.

This is not an abstract concern. If your test has a running background task, a polling helper, an async context manager that refreshes something on a timer, or a fixture holding an open connection with a heartbeat, TestClient inside an async test stops all of it. And the symptom is not an error — it is a test that hangs, or one whose timing assertions make no sense.

It is worth naming the symmetry: this is the same failure as calling a blocking function inside an async def endpoint, described in Fixing Blocking Calls in Async Routes. The test suite obeys the rules the application obeys.

Where TestClient Wins: Lifespan for Free

The trade runs the other way on startup. TestClient used as a context manager runs the full lifespan; ASGITransport never does, because it only implements the http scope.

$ GET /pytest/lifespan
200 OK

TestClient inside `with`        -> lifespan_ran True
.TestClient without `with`      -> lifespan_ran False
.AsyncClient + ASGITransport    -> lifespan_ran False
.AsyncClient + lifespan_context -> lifespan_ran True
.
4 passed in 0.00s

Four real results against an app whose lifespan flips a flag. The second line is the trap that costs the most debugging time: TestClient(app) without a with block does not raise, does not warn, and silently skips startup. Every request works until one touches app.state and gets an AttributeError.

Under AsyncClient, run the lifespan explicitly when you need it:

async def test_asgitransport_with_lifespanmanager_style_startup():
    """Drive the lifespan yourself when you need startup state under AsyncClient."""
    async with app.router.lifespan_context(app):
        async with AsyncClient(transport=T(app=app), base_url="http://test") as client:
            assert (await client.get("/lifespan")).json() == {"lifespan_ran": True}

That is the fourth line of the transcript, and it passed. app.router.lifespan_context(app) is the same context Starlette itself enters on startup, so the third-party asgi-lifespan package is optional — useful mainly for its extra timeout and error handling. Put this in a session-scoped fixture if startup is expensive, but be aware that shared startup state then leaks between tests, so most suites are better off with a function-scoped lifespan and cheap startup. Lifespan design is covered in Lifespan Events vs Startup Shutdown.

Concurrency: Only One Client Can Express It

A blocking call cannot overlap with another blocking call on the same thread. That makes some tests simply unwritable with TestClient:

def test_testclient_requests_serialise():
    with TestClient(app) as client:
        started = time.perf_counter()
        for _ in range(3):
            client.get("/slow")
        elapsed = time.perf_counter() - started
    assert elapsed >= 0.6


async def test_asyncclient_requests_overlap():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client:
        started = time.perf_counter()
        await asyncio.gather(*[client.get("/slow") for _ in range(3)])
        elapsed = time.perf_counter() - started
    assert elapsed < 0.4
$ GET /pytest/concurrency
200 OK

3 x 200ms via TestClient  = 0.6s
.3 x 200ms via AsyncClient = 0.2s
.
2 passed in 0.00s

0.6 s versus 0.2 s — the sum versus the maximum. Note that the TestClient result says nothing about the app's ability to handle concurrency; the app is identical in both runs. The serialisation happens entirely in the client, because each client.get blocks until it returns.

This matters more than it first appears, because the timing assertion is the standard way to prove an endpoint is not blocking the loop — the technique used throughout Concurrent Requests with asyncio.gather and Running Sync Code in a Threadpool. Written with TestClient, that test can never pass, and you may well conclude your app has a blocking bug it does not have.

Why Async Fixtures Want AsyncClient

Put the pieces together and the fixture rule follows.

An async fixture runs on the test's event loop and typically hands back something created on that loop: an AsyncSession, an httpx.AsyncClient pointed at a fake upstream, an asyncio.Queue. Two consequences:

Loop-bound resources may not survive the trip. Some async libraries tolerate being driven from another loop, and some do not — it depends on whether the object registered anything with the loop it was created on. Rather than auditing every dependency for this property, keep the app on the same loop as the fixtures and the question never arises.

A blocked loop cannot service the fixture. This one is not library-dependent at all. If your fixture yields something that needs the loop to make progress during the request — a connection with a keepalive, a fake server implemented as a task, a queue being fed by a producer coroutine — then TestClient deadlocks it, because the loop is frozen for the duration of the call. The zero-ticks measurement above is this failure in miniature.

So the composition rule is simply: async fixtures and AsyncClient, or synchronous fixtures and TestClient. Mixing async fixtures with TestClient works in easy cases and fails in exactly the cases you added the fixture for. The database instance of this is worked through in Testing with Async Database Fixtures.

Verification

Two habits will catch the mistakes above before they cost you a debugging session.

Assert startup actually ran, once, somewhere in the suite:

async def test_startup_state_exists():
    async with app.router.lifespan_context(app):
        transport = ASGITransport(app=app)
        async with AsyncClient(transport=transport, base_url="http://t") as c:
            assert (await c.get("/health")).status_code == 200
    # If this fails with AttributeError on app.state, the lifespan was skipped.

And when a timing assertion behaves oddly, check which client you used before you suspect the app. A quick way to confirm the layout is to have a debug endpoint report its thread — if it says asyncio-portal-…, you are on TestClient and any concurrency expectation is invalid.

A deprecation you will meet on the way

Importing TestClient on Starlette 1.3.1 with httpx 0.28.1 emits this before your tests produce a single line of output:

StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated;
install `httpx2` instead.

It is a warning about the transport underneath TestClient, not about TestClient itself, and nothing in this guide stops working because of it. Two things follow from it, though. If your suite runs with -W error — a good default — this warning alone will fail collection, so you either move to httpx2 or add a targeted filterwarnings entry rather than silencing the category wholesale. And it is a reminder of the structural point this page keeps returning to: TestClient is a compatibility layer with its own dependency surface, while AsyncClient with ASGITransport is just httpx talking to your app. The layer with fewer moving parts is the one with fewer deprecations to absorb.

Trade-offs and When Not To

TestClient is still the right tool for WebSockets. It ships websocket_connect, which returns a synchronous session with send_json/receive_json. ASGITransport handles the http scope only, so testing a WebSocket route through it is not possible.

A fully synchronous suite does not need AsyncClient. If your app has no async fixtures, no concurrency assertions and no loop-bound resources, TestClient is less ceremony and one fewer plugin. Do not convert a working suite for its own sake.

AsyncClient needs base_url. ASGITransport does not invent a host, so relative URLs fail without one. base_url="http://test" is the convention; the value is arbitrary but must be present.

Neither client tests the protocol layer. In-process means no real HTTP parsing, no proxy, no TLS, no server timeouts. Keep a small smoke suite against a deployed instance for those.

Mixing both in one file is fine. They are independent, and a suite that uses TestClient for a WebSocket test and AsyncClient for everything else is perfectly coherent. What is not fine is one async test that reaches for TestClient because it was quicker to type.

FAQ

What is a blocking portal and why does TestClient need one? A blocking portal is an AnyIO object that runs an event loop on a dedicated thread and lets synchronous code submit coroutines to it and wait for the result. TestClient needs one because your ASGI app is asynchronous while its own API is synchronous, so something has to own a loop on your behalf.

Can I call TestClient from inside an async test? It works, but it freezes the loop your test is running on for the whole request, because the call is a blocking wait on another thread. Any task, timer or fixture depending on that loop makes no progress until the request completes.

Do context variables set in my test reach the endpoint under TestClient? Yes. AnyIO copies the calling context into the portal, so a contextvar set in the test is visible inside the endpoint even though it runs on a different loop and thread. Objects bound to a specific event loop are the things that do not transfer.

Why does my app miss its startup state under AsyncClient? Because ASGITransport only implements the http scope and never sends lifespan messages. Enter the app's lifespan context around the client, or use a lifespan manager, whenever the endpoint depends on something created at startup.

Can I make concurrent requests with TestClient? Not from one client in one thread. Each call blocks until it returns, so a loop of requests takes the sum of their durations. Use AsyncClient with asyncio.gather when a test needs requests to overlap.

Which client should be the default in a new project?AsyncClient with ASGITransport, because it composes with async fixtures and can express concurrency. Keep TestClient for WebSocket tests, for suites that are entirely synchronous, and for the convenience of its lifespan-running context manager.