Testing async routes
Something you've already been doing without noticing
In a normal, real environment,
Concept 6’s test_generate_success
— testing POST /generate, a route declared async def — could be
written as a completely ordinary, non-async test function:
def test_generate_success(mock_call_llm_api, client): # not async def
response = client.post("/generate", json={"prompt": "hello"})
...This works because a real, synchronous TestClient handles running
the event loop internally — calling client.post(...) runs the entire
async def route to completion and hands back a normal,
already-resolved response, without the test itself needing to be
async or use await anywhere. For testing a route through
TestClient, whether that route is declared async def or plain
def makes no difference to how you write the test.
This site’s own version of that exercise used async def anyway —
worth calling out explicitly, not glossing over: this site’s client
fixture, everywhere in this lesson, is an httpx.AsyncClient, not a
real TestClient,
the same environment-driven accommodation covered when parametrized tests were introduced.
That’s a fact about this site’s own grading environment, not about
what real TestClient itself requires — the rule above is the real
one, and it’s what you’d actually rely on outside this course.
When a test genuinely does need to be async
The exception: testing an async def function directly — not
through a route, not through TestClient — by calling and await-ing
it the way
any coroutine gets called.
This does require the test function itself to be async def, plus a
plugin (pytest-asyncio) that teaches pytest how to actually run an
async def test function to completion:
import pytest
async def call_llm_api(prompt: str) -> str:
await asyncio.sleep(0.1)
return f"response to: {prompt}"
@pytest.mark.asyncio
async def test_call_llm_api_directly():
result = await call_llm_api("hello")
assert result == "response to: hello"@pytest.mark.asyncio tells pytest this specific test function needs
pytest-asyncio’s support to run — without it, pytest doesn’t know how
to execute an async def test function at all, since a plain,
un-awaited coroutine object
is exactly the gotcha from the async lesson
— the test function itself would just return an unexecuted coroutine,
never actually running its body or its assertions.
The practical rule
Testing through TestClient (any route, async def or not): write a
normal, non-async test function — TestClient handles the event
loop for you, invisibly. Testing an async def function or coroutine
directly, without going through TestClient at all: write an
async def test function, marked with @pytest.mark.asyncio, and
await it yourself.
Most of this lesson’s tests fall into the first category, precisely
because most of what’s worth testing about a route is reachable
through TestClient — the second category mainly comes up when
testing a standalone async helper function in isolation,
as a genuine unit test,
separate from any route that happens to call it.
Why can a test for an async def route be written as a plain, non-async test function?