11. Testing Async Code — trust only what you can await and verify¶
~15 min read. Async services feel correct in demos, but race conditions and hidden waits only show up under disciplined tests.
Built on the ELI5 in 00-eli5.md. The order ticket — the runnable request plan — must be tested with the real kitchen lane, not just with hopeful eyeballing.
First picture: async bugs hide between steps¶
Look at the picture first. Synchronous bugs often fail at one line. Async bugs fail between lines, between awaits, and between interacting tasks.
test starts
│
├── schedule request
├── dependency awaits
├── stream yields chunks
└── cleanup runs or leaks
See. That means unit tests alone are not enough. We also need async integration tests. We need fake upstreams. We need timing-aware assertions. Simple, no?
pytest-asyncio and httpx.AsyncClient¶
A common stack is straightforward.
Use pytest.
Add pytest-asyncio for async tests.
Use httpx.AsyncClient to call the ASGI app.
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.mark.asyncio
async def test_chat_route(app):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
response = await client.post("/chat", json={"message": "hi"})
assert response.status_code == 200
This lets the front desk and route stack run realistically. Validation, dependencies, handlers, and response encoding all participate. That is much better than calling the function directly.
Mock async dependencies correctly¶
Now what is the problem?
Many tests patch async dependencies with sync mocks.
Then await behavior becomes fake.
Use AsyncMock or explicit async fakes.
from unittest.mock import AsyncMock
fake_client = AsyncMock()
fake_client.generate.return_value = "hello"
If your app uses dependency injection, overriding dependencies becomes clean. That is another reason FastAPI design matters.
Worked example.
Suppose /chat depends on get_llm_client.
In the test,
override that dependency with a fake client whose generate method is async.
Now you can assert request shape,
response shape,
and whether the fake client was awaited exactly once.
Test streaming and cancellation explicitly¶
Streaming routes deserve their own tests.
Do not only test the final combined body.
Read chunks incrementally.
Assert event order.
Assert done arrives.
You may also need disconnect or timeout tests. Those are harder, but important. A cancellation bug often hides for months. Then costs explode in production.
One practical approach is to isolate the generator.
Test that it yields expected events for normal flow.
Then inject a fake upstream that raises cancellation or timeout.
Assert cleanup happened.
Maybe a mock aclose was awaited.
That is strong evidence.
Determinism beats sleeping in tests¶
A classic async testing mistake is await asyncio.sleep(...) inside tests just to "let things happen."
Bad habit.
That makes tests flaky.
Prefer awaiting explicit signals,
reading the next stream chunk,
or using controlled fakes.
If you test background jobs,
design them so the side effect is observable.
A database row appears.
A queue publish function is called.
A state transition changes from queued to running.
Do not rely on vague timing luck.
Test the unhappy paths first.
Senior teams test failure behavior heavily. What happens when the LLM times out? When auth fails? When a dependency raises 429? When the stream ends early? When cancellation fires during cleanup?
The order ticket is easy to test on a happy day. The hard part is proving the cancel bell and error paths behave correctly. That is where real reliability comes from. See. Passing one golden-path test proves very little.
Where this lives in the wild¶
- OpenAI-compatible gateway — API test engineer: async integration tests validate request schemas, auth dependencies, and streamed event contracts together.
- Anthropic streaming relay — backend engineer: chunk-order tests catch regressions where
doneevents or cleanup steps disappear. - Perplexity retrieval service — platform engineer: async mocks simulate shard timeouts and partial successes without hitting real vendors.
- GitHub Copilot infrastructure — quality engineer: dependency overrides let tests inject fake model clients and deterministic request ids.
- Enterprise document pipeline — backend engineer: failure-path tests confirm job status transitions and cleanup after worker errors.
Pause and recall¶
- Why is
httpx.AsyncClientbetter than calling route functions directly in many tests? - What is wrong with using sync mocks for awaited dependencies?
- Why should streaming tests assert event order, not just final text?
- In the analogy, why must the order ticket be tested on both happy and messy kitchen days?
Interview Q&A¶
Q: Why prefer ASGI-level async tests over direct function calls for FastAPI routes? A: They exercise validation, dependency injection, middleware, and response handling together, which is where many production bugs actually live. Common wrong answer to avoid: "Because direct function calls are impossible in Python tests."
Q: Why are sleep-based async tests fragile? A: They assume timing instead of observable state, so small scheduler or machine differences can create flakes and false confidence. Common wrong answer to avoid: "A small sleep makes async tests realistic."
Q: Why must async dependencies be mocked with awaitable behavior? A: The production code awaits them, so tests need the same control flow shape to validate ordering, exceptions, and cleanup correctly. Common wrong answer to avoid: "A normal mock is enough because return values are all that matter."
Q: Why do strong async test suites emphasize unhappy paths? A: Because cancellations, timeouts, partial streams, and dependency failures are where concurrency bugs and resource leaks usually hide. Common wrong answer to avoid: "If the happy path passes, the async plumbing is probably fine."
Apply now (5 min)¶
Exercise. Write one test case name for each of these: validation failure, LLM timeout, stream success, and client disconnect. For each, note what observable result you would assert.
Sketch from memory. Draw test client → app → fake dependency. Label where the fake async client returns, raises, or streams. Add one note about the kitchen lane.
Bridge. Tests give confidence locally. Now we need the final piece: deploying these async services with workers, health checks, and graceful shutdown. → 12-deployment-production.md