7.1 建立异步接口测试
建立异步接口测试
异步接口的测试重点不是把测试函数写成 async def,而是让测试客户端通过 ASGI 应用发送请求,并且确实等待每一次 I/O。当前 FastAPI 文档推荐使用 HTTPX 的 AsyncClient 和 ASGITransport;AsyncClient(app=app) 是旧式写法,不应作为新代码模板。
本节从文章管理接口的最小基线开始,测试创建、查询和请求字段校验失败。测试使用 pytest 的 AnyIO 插件,并显式选择 asyncio 后端。示例没有数据库,因此每个测试只重置一份教学内存数据;数据库事务和隔离会在下一节处理。
安装测试依赖并固定异步后端
在练习项目的虚拟环境中安装与本教程验证环境相同的接口依赖,再安装测试工具:
python -m pip install \
"fastapi==0.141.1" \
"starlette==0.52.1" \
"httpx==0.28.1" \
"anyio==4.15.0" \
"pytest==9.1.1"
python -m pytest --version
本节核验时使用 Python 3.12.13、FastAPI 0.141.1、Starlette 0.52.1、HTTPX 0.28.1、AnyIO 4.15.0 和 pytest 9.1.1。项目应把这些版本写进自己的开发依赖文件,并在升级时重新运行测试;不要只记录 Python 版本。
用 AsyncClient 请求 ASGI 应用
下面是一份可以直接运行的测试文件。ASGITransport 把请求送入应用,不启动 TCP 端口,所以它适合接口契约测试。base_url 只是给 HTTPX 补全相对路径,并不代表真的连接了这个域名。
<!-- file: ch07_async/test_api.py -->
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
import pytest
from fastapi import FastAPI
from pydantic import BaseModel, Field
class PostCreate(BaseModel):
title: str = Field(min_length=3, max_length=80)
content: str = Field(min_length=1)
class PostResponse(PostCreate):
id: int
posts: list[dict[str, object]] = []
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.started = True
yield
app.state.started = False
app = FastAPI(title="异步测试示例", lifespan=lifespan)
@app.post("/posts", response_model=PostResponse, status_code=201)
async def create_post(payload: PostCreate) -> dict[str, object]:
post = {"id": len(posts) + 1, **payload.model_dump()}
posts.append(post)
return post
@app.get("/posts/{post_id}", response_model=PostResponse)
async def get_post(post_id: int) -> dict[str, object]:
for post in posts:
if post["id"] == post_id:
return post
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="文章不存在")
@pytest.fixture
def anyio_backend() -> str:
# AnyIO 默认可以运行多个后端;这里固定 asyncio,避免测试结果随环境改变。
return "asyncio"
@pytest.fixture(autouse=True)
def reset_posts() -> None:
posts.clear()
@pytest.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
# ASGITransport 不负责触发生命周期,因此在测试夹具中显式进入和退出。
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as async_client:
yield async_client
@pytest.mark.anyio
async def test_create_query_and_validation(
client: httpx.AsyncClient,
) -> None:
assert app.state.started is True
created = await client.post(
"/posts",
json={"title": "测试异步接口", "content": "正文"},
)
assert created.status_code == 201
assert created.json() == {
"id": 1,
"title": "测试异步接口",
"content": "正文",
}
fetched = await client.get("/posts/1")
assert fetched.status_code == 200
assert fetched.json()["title"] == "测试异步接口"
invalid = await client.post(
"/posts",
json={"title": "短", "content": "正文"},
)
assert invalid.status_code == 422
assert invalid.json()["detail"][0]["loc"][-1] == "title"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
在包含 ch07_async/ 的目录运行:
python -m pytest -q ch07_async/test_api.py
预期结果是 1 passed。测试里没有 uvicorn,也没有占用本地端口;它仍然经过 FastAPI 的路由匹配、请求体校验、响应模型和状态码处理。
为什么不用旧式 AsyncClient(app=app)
HTTPX 的 ASGI 传输层把应用作为 transport 参数接收:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
response = await client.get("/health")
这种写法把“HTTPX 如何发请求”和“应用是否需要监听端口”分开。测试不会受端口占用影响,也不会把开发服务器日志混入断言。AsyncClient 仍然需要在异步上下文中关闭;使用 async with 可以保证连接资源释放。
ASGITransport 不会自动运行 ASGI lifespan。示例用 app.router.lifespan_context(app) 显式进入和退出应用生命周期,因此 app.state.started 的断言是真实经过启动阶段的。测试中如果使用连接池、缓存或其他需要清理的资源,应在这个上下文中初始化,并在 yield 后关闭。也可以采用专门的 lifespan 管理工具,但不能假设创建 AsyncClient 就会自动启动应用。
什么时候同步 TestClient 更合适
如果测试只调用同步代码、没有需要 await 的客户端或数据库操作,fastapi.testclient.TestClient 更短,也更容易让初学者阅读。它适合健康检查、简单响应模型和同步依赖的单元测试。只要测试本身需要并发等待、异步数据库会话或异步外部客户端,就应沿用 AsyncClient,不要在测试里用同步包装器掩盖真实执行方式。
测试客户端的选择不能说明接口一定更快。它只改变测试请求的执行方式;线上性能仍由路由函数、数据库驱动、外部服务和部署 worker 共同决定。
从三个断言开始扩展
一个可维护的接口测试至少要覆盖一条成功路径和一条失败路径。本节的三个断言分别检查:创建返回 201 和完整公开字段、详情查询返回刚创建的数据、标题长度不合法时返回 422。后续增加接口时,优先补充会影响客户端决策的状态码、公开字段和数据结果。
测试失败时先判断它属于哪一层:请求体结构错误通常是 422,资源不存在通常是 404,服务端响应模型缺字段则是应用实现错误。不要为了让测试变绿而把所有异常转成 200;失败状态码本身就是接口契约的一部分。
本节使用内存列表,只验证请求链路和应用生命周期,没有验证数据库事务、外部服务重试或并发下的数据一致性。下一节需要用独立测试数据库和依赖覆盖测试这些边界,不能把本节结果当作 PostgreSQL 验收。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“测试”及异步执行主题。本节保留异步测试与可重复检查的方向,代码和案例为教程扩展。
- 官方文档:FastAPI 异步测试、HTTPX ASGI 传输、AnyIO pytest 测试。