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.

Check your understanding
1/4

Why can a test for an async def route be written as a plain, non-async test function?