FastAPI 应用测试入门:用 TestClient 与 pytest 编写首个 API 测试
得益于 Starlette 提供的 TestClient,为 FastAPI 应用编写测试变得轻松而愉快。它基于 [HTTPX] 实现,而 HTTPX 又是在 Requests 的基础上设计的,因此 API 风格十分熟悉、直观。你可以直接把 pytest 与 FastAPI 搭配使用:编写普通 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 TestClient 与 from starlette.testclient import TestClient 是同一个对象,FastAPI 只是出于开发者便利将它再暴露一次。技术细节层面,它仍然直接来自 Starlette。
TestClient 内部基于 HTTPX,而 HTTPX 的设计又以 Requests 为蓝本,所以三者共享高度一致的使用体验:
- 用
client.get(...)、client.post(...)等发起请求; - 通过
response.status_code读取状态码; - 通过
response.json()读取解析后的 JSON 响应体; - 通过
response.text、response.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"}
要点拆解:
- 导入
TestClient:从fastapi.testclient导入。 - 创建客户端:把 FastAPI 应用实例传给
TestClient:client = TestClient(app)。TestClient会像真实服务器一样驱动 ASGI 应用处理请求。 - 以
test_前缀定义函数:test_read_main是标准 pytest 约定,会被自动收集为一条测试用例。 - 像使用
httpx一样调用 client:client.get("/")直接返回响应对象。 - 用
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_py310 与 app_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 下的示例并执行其中的测试函数,让教程代码始终与框架实现保持同步、可运行。
延伸阅读
- 若你想在测试中调用除发送请求之外的
async函数(例如异步数据库操作),可以参考进阶教程中的 异步测试(Async Tests); - 多文件应用的组织方式见 Bigger Applications - 多文件应用;
- 有关 Pydantic 模型在测试中序列化的问题,参见 JSON 兼容编码器(JSON Compatible Encoder);
- 仓库还提供了异步测试示例源码 docs_src/async_tests 与依赖注入场景的测试示例 docs_src/dependency_testing,可作为进阶参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00