首页
/ FastAPI 应用测试入门:用 TestClient 与 pytest 编写首个 API 测试

FastAPI 应用测试入门:用 TestClient 与 pytest 编写首个 API 测试

2026-09-06 19:00:42作者:段琳惟

得益于 Starlette 提供的 TestClient,为 FastAPI 应用编写测试变得轻松而愉快。它基于 [HTTPX] 实现,而 HTTPX 又是在 Requests 的基础上设计的,因此 API 风格十分熟悉、直观。你可以直接把 pytestFastAPI 搭配使用:编写普通 def 测试函数、发起同步请求、用 assert 做断言,无需任何特殊框架适配。阅读本文后,你将掌握:搭建测试环境、使用 TestClient 对路径操作发起 GET/POST 请求并断言状态码与 JSON 响应、把测试拆分为独立文件融入真实项目结构,以及使用 pytest 一键运行全部测试的完整流程。

本文以官方教程文档 docs/en/docs/tutorial/testing.md 为骨架展开,并补充了 FastAPI 仓库中对应的可运行示例源码与仓库自带的测试用例,方便你对照验证。

为什么可以用 pytest 直接测试 FastAPI

FastAPI 测试能力的基石是 Starlette 的 TestClient。在 fastapi/testclient.py 中可以看到它只做了一件事——把 Starlette 的 TestClient 原样重新导出:

from starlette.testclient import TestClient as TestClient  # noqa

也就是说,from fastapi.testclient import TestClientfrom starlette.testclient import TestClient 是同一个对象,FastAPI 只是出于开发者便利将它再暴露一次。技术细节层面,它仍然直接来自 Starlette。

TestClient 内部基于 HTTPX,而 HTTPX 的设计又以 Requests 为蓝本,所以三者共享高度一致的使用体验:

  • client.get(...)client.post(...) 等发起请求;
  • 通过 response.status_code 读取状态码;
  • 通过 response.json() 读取解析后的 JSON 响应体;
  • 通过 response.textresponse.headers 等访问文本与头信息。

正因如此,你可以零成本地把测试逻辑直接交给 pytest:测试函数命名以 test_ 开头即可被 pytest 自动发现。官方文档明确说明,测试函数应写成普通 def(而非 async def),对 client 的调用也是普通调用(不使用 await),这样 pytest 无需任何插件即可直接运行。

快速上手:第一个 TestClient 测试

安装依赖

要使用 TestClient,请先安装 httpx

$ uv add httpx

仓库中对应的入门示例位于 docs_src/app_testing/tutorial001_py310.py,完整代码如下:

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}


client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

要点拆解:

  1. 导入 TestClient:从 fastapi.testclient 导入。
  2. 创建客户端:把 FastAPI 应用实例传给 TestClientclient = TestClient(app)TestClient 会像真实服务器一样驱动 ASGI 应用处理请求。
  3. test_ 前缀定义函数test_read_main 是标准 pytest 约定,会被自动收集为一条测试用例。
  4. 像使用 httpx 一样调用 clientclient.get("/") 直接返回响应对象。
  5. assert 做标准断言:既断言状态码为 200,也断言返回的 JSON 与预期完全一致。

这里需要留意:路径操作函数 read_main 本身是 async def,但测试函数是普通 def,调用也是同步的——TestClient 在内部帮你完成了事件循环的编排。这也正是它能与 pytest 无缝配合的原因。仓库自带的回归测试 tests/test_tutorial/test_testing/test_tutorial001.py 不仅执行了这个示例测试函数,还会请求 /openapi.json 并断言自动生成的 OpenAPI 架构快照,说明「测试客户端运行应用 + 访问自动生成的接口文档」都是同一套机制可以覆盖的。

在真实项目中分离测试文件

真实应用很少只有单文件,测试通常放在独立文件中。官方文档延续了 Bigger Applications - 多文件应用 中介绍的项目结构来演示分层。

应用文件结构

假设你拥有如下结构:

.
├── app
│   ├── __init__.py
│   └── main.py

main.py 中定义 FastAPI 应用,对应源码见 docs_src/app_testing/app_a_py310/main.py

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}

在同一个包内放置测试文件

把测试文件 test_main.py 放进同一个 Python 包(即与 main.py 同目录且该目录含 __init__.py),目录结构变为:

.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py

因为测试文件与 main.py 处于同一包中,可以直接使用相对导入拿到 app 对象,对应源码见 docs_src/app_testing/app_a_py310/test_main.py

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

与入门示例相比,唯一的差别在于通过 from .main import app 导入应用;其余测试代码完全一致。仓库测试 tests/test_tutorial/test_testing/test_main_a.py 会在项目 CI 中实际导入 docs_src.app_testing.app_a_py310.test_main 并调用 test_read_main() 与对 /openapi.json 的断言,直接验证了这套示例的可运行性。

扩展实战:测试带认证与多状态码的接口

官方文档随后把示例升级为更贴近真实业务的场景:接口要求 X-Token 请求头,GET 可能返回错误,POST 可能返回多种错误。

扩展版应用

对应的 main.py 源码位于 docs_src/app_testing/app_b_an_py310/main.py

from typing import Annotated

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: str | None = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/")
async def create_item(item: Item, x_token: Annotated[str, Header()]) -> Item:
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item.model_dump()
    return item

该应用覆盖了几种典型测试场景:

  • GET /items/{item_id}:使用内存字典 fake_db 模拟数据库,Token 无效返回 400,条目不存在返回 404;
  • POST /items/:校验请求体(Pydantic 的 Item 模型)与 X-Token,条目已存在时返回 409 冲突;
  • 两个接口都以 Annotated[str, Header()] 声明必需请求头 X-Token,缺失或错误都会触发 400。

仓库中另有等价写法 docs_src/app_testing/app_b_py310/main.py,二者仅在类型标注风格上不同,便于你选择。

扩展版测试

对应的 test_main.py 源码位于 docs_src/app_testing/app_b_an_py310/test_main.py

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_item():
    response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 200
    assert response.json() == {
        "id": "foo",
        "title": "Foo",
        "description": "There goes my hero",
    }


def test_read_item_bad_token():
    response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_read_nonexistent_item():
    response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}


def test_create_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
    )
    assert response.status_code == 200
    assert response.json() == {
        "id": "foobar",
        "title": "Foo Bar",
        "description": "The Foo Barters",
    }


def test_create_item_bad_token():
    response = client.post(
        "/items/",
        headers={"X-Token": "hailhydra"},
        json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
    )
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_create_existing_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={
            "id": "foo",
            "title": "The Foo ID Stealers",
            "description": "There goes my stealer",
        },
    )
    assert response.status_code == 409
    assert response.json() == {"detail": "Item already exists"}

这套测试逐一覆盖了「正确 Token + 正常读取」「错误 Token」「条目不存在」「创建新条目」「创建重复条目」等分支。仓库中的 tests/test_tutorial/test_testing/test_main_b.py 以 pytest fixture 参数化的方式同时导入了 app_b_py310app_b_an_py310 两个版本的 test_main,依次调用 6 个测试函数,从项目自身测试体系中印证了示例的正确性。

在测试请求中传递各类数据

当你不确定如何通过 client 在请求中携带某种数据时,可以先去检索 httpx(或 requests,因为二者设计同源)的用法,然后在测试里照做即可。常见映射如下:

  • 路径或查询参数:直接写进 URL,例如 client.get("/items/{item_id}?verbose=1")client.get("/items/foo")
  • JSON 请求体:把一个 Python 对象(如 dict)传给 json 参数,例如 client.post("/items/", json={"id": "foobar"})
  • 表单数据(Form Data):改用 data 参数传 dict,例如 client.post("/login", data={"username": "johndoe"})
  • 请求头:以 dict 传给 headers 参数,例如示例中的 headers={"X-Token": "coneofsilence"}
  • Cookie:以 dict 传给 cookies 参数。

一个容易踩坑的点是:TestClient 接收的是可以被 JSON 序列化的数据,而不是 Pydantic 模型本身。如果测试中持有 Pydantic 模型并希望以 JSON 形式发给应用,应先用 JSON 兼容编码器教程 中介绍的 jsonable_encoder 转换后再传给 json 参数。

运行测试

先安装 pytest:

$ uv add pytest

随后在项目根目录直接运行:

$ uv run pytest

pytest 会自动发现以 test_ 开头的文件与函数,逐个执行并汇总报告:

$ uv run pytest

================ test session starts ================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 6 items

---> 100%

test_main.py ......                            [100%]

================= 1 passed in 0.03s =================

6 个测试用例全部通过。FastAPI 仓库自身也正是用这一模式来守护行为:例如 tests/test_tutorial/test_testing/test_main_b.py 等文件直接 import docs_src 下的示例并执行其中的测试函数,让教程代码始终与框架实现保持同步、可运行。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐