codecamp

7.1 建立异步接口测试

建立异步接口测试

异步接口的测试重点不是把测试函数写成 async def,而是让测试客户端通过 ASGI 应用发送请求,并且确实等待每一次 I/O。当前 FastAPI 文档推荐使用 HTTPX 的 AsyncClientASGITransportAsyncClient(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 验收。

资料来源

6.3 按环境管理文档入口
7.2 隔离数据库并覆盖关键失败路径
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }