刚开始写接口时,我们很容易形成一套顺手的检查方法:把服务运行起来,打开接口文档,填几个参数,看到绿色的 200 就觉得差不多了。这个动作很适合探索接口,却回答不了几个更麻烦的问题:没有令牌时是不是一定会被拒绝?优先级传成 0 时返回的错误结构有没有变化?上一条测试写进数据库的数据,会不会让下一条测试误判?内部异常发生时,客户端会不会看到敏感的错误栈?
更现实的场景是,你修改了一个看似无关的标题清理函数,结果创建任务接口突然开始接受空标题。手工点一次通常碰不到这个边界,自动化测试却会稳定地把它拦下来。测试的价值不在于证明代码永远没问题,而在于把我们已经理解的规则保存成一组可以反复运行的证据。
这一章我们围绕同一个任务清单接口展开。先把最容易验证的标题规则写成纯函数,再让一个请求穿过路由、数据校验、依赖和数据库,接着覆盖认证依赖、隔离数据库、检查异步链路。最后故意制造失败,从 pytest 的差异、日志和异常栈一路找到根因。你会看到,测试和调试其实是一件事的两个阶段:测试负责把错误稳定地暴露出来,调试负责解释它为什么发生。

“多写测试”是个太模糊的目标。更好用的办法,是先问每一条测试准备证明哪一段边界。任务清单应用可以分成三层:
这就是测试金字塔在 FastAPI 项目里的落地方式:底层测试数量多、速度快;越往上数量越少,只保留真正需要跨组件证明的关键流程。不要用接口测试重复纯函数的所有输入组合,也不要只写纯函数测试然后假设 HTTP 层自然会工作。
我们的示例先从这棵目录树开始:
.
├── pyproject.toml
├── todo_api
│ ├── __init__.py
│ ├── domain.py
│ └── main.py
└── tests
├── conftest.py
├── test_domain.py
├── test_api.py
└── test_async.py安装运行应用与测试所需的包:
uv add fastapi uvicorn sqlalchemy httpx
uv add --dev pytest anyio把 pytest 的基本配置放进 pyproject.toml:
[tool.pytest.ini_options]
addopts = "-ra"
testpaths = ["tests"]
pythonpath = ["."]testpaths 限定测试发现范围,pythonpath 让项目根目录中的 todo_api 可以被测试文件导入。项目变大后还可以注册 unit、api、slow 等标记,但先别急着分类,先把最小闭环跑起来。
任务标题有两条规则:连续空白合并成一个空格,整理后不能为空且不能超过 40 个字符。这个规则没有理由绑在路由函数里。把它抽成纯函数以后,测试既快,也不会被认证或数据库故障干扰。
# todo_api/domain.py
def normalize_title(raw: str) -> str:
"""把任务标题整理成适合入库的形式。"""
title = " ".join(raw.split())
if not title:
raise ValueError("标题不能为空")
if len(title) > 40:
raise ValueError("标题不能超过 40 个字符")
return title先写最直接的一条:
# tests/test_domain.py
from todo_api.domain import normalize_title
def test_normalize_title_merges_repeated_spaces():
result = normalize_title(" 写 接口测试 ")
assert result == "写 接口测试"这条测试没有启动服务器。pytest 导入函数、传入字符串,再执行普通的 assert。如果将来有人误删了空白整理逻辑,失败位置会准确落在这一条业务规则上,而不会只告诉我们“创建任务接口返回内容不对”。
同一条规则经常有好几种输入:普通文本、连续空格、换行符和制表符。复制三份测试函数会让意图散开,参数化更适合表达“这些输入都遵守同一规则”。
import pytest
from todo_api.domain import normalize_title
@pytest.mark.parametrize(
("raw", "expected"),
[
("写测试", "写测试"),
(" 写 接口测试 ", "写 接口测试"),
("修复\n登录\t问题", "修复 登录 问题"),
],
ids=["plain", "spaces"
运行这一份文件:
uv run pytest -q tests/test_domain.py.... [100%]
4 passed in 0.01sids 不是为了影响结果,而是让失败报告能显示有意义的用例名。边界组合变多时,你会很快感受到它的好处:报告告诉你失败的是空格场景,还是换行场景。
单元测试最适合保护稳定的业务规则。遇到必须读请求对象、调用依赖或写数据库的逻辑,不要为了追求“纯”而做大量脆弱模拟;让它进入接口测试,通常更自然。
纯函数正确,不代表接口一定正确。请求还要经过路径匹配、请求体解析、字段校验、依赖解析、函数调用和响应序列化。测试客户端会在测试进程里把请求交给 ASGI 应用,因此不需要先占用一个真实端口,但处理链并没有被跳过。

先看应用中与创建任务有关的核心结构:
# todo_api/main.py
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .domain import normalize_title
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=80)
priority:
创建端点把 HTTP 层、业务规则、认证与数据库连在一起:
@app.post("/tasks", status_code=201, response_model=TaskOut)
def create_task(payload: TaskCreate, db: SessionDep, user: UserDep):
try:
title = normalize_title(payload.title)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
task = Task(
title=
接口测试的写法很接近一次真实调用:
def test_create_task_walks_through_the_http_stack(client):
response = client.post(
"/tasks",
headers={"X-Token": "dev-secret"},
json={"title": " 补齐 测试 ", "priority": 2},
)
assert response.status_code == 201
assert response.json() == {
"id": 1,
"title"
这一个断言块同时证明了几件事:请求体能被解析,字段通过了校验,令牌被认证依赖接受,标题规则确实被调用,记录写入数据库,响应模型也生成了预期字段。它比直接调用 create_task() 更接近使用者看到的行为。
测试函数本身用普通 def,对客户端的调用也不用 await。测试客户端会替我们处理应用中的异步边界。如果测试还需要直接等待异步数据库函数,就改用后面介绍的异步客户端。
测试客户端仍然是进程内调用。它不会自动证明反向代理、TLS、跨域配置、容器网络或生产服务器参数正确。这些边界要留给少量端到端测试。
只测 201 的接口很容易制造虚假的安全感。真实流量里,大量请求会走失败路径:凭证缺失、字段越界、资源不存在、数据冲突或内部异常。错误状态码和错误结构同样属于接口契约。
“受保护接口能被有效用户访问”只证明了放行分支。认证至少要覆盖三类输入:
def test_missing_token_is_rejected(client):
response = client.get("/tasks")
assert response.status_code == 401
assert response.json() == {"detail": "凭证无效"}
assert response.headers["www-authenticate"] == "Bearer"
def test_wrong_token_is_rejected(client):
response = client.get("/tasks", headers={"X-Token": "wrong"
第三类有效凭证已经由创建任务测试覆盖。更完整的认证模块还应测试过期令牌、签名错误、已禁用用户、普通用户访问管理员接口等情况。不要只断言状态码;认证挑战头和不泄露敏感原因的错误内容也值得保护。
priority 被声明为 1 到 5。传入 0 时,端点函数根本不会执行,FastAPI 会先返回请求校验错误:
def test_invalid_priority_returns_422(client):
response = client.post(
"/tasks",
headers={"X-Token": "dev-secret"},
json={"title": "检查边界", "priority": 0},
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error[
对应响应是:
422
{
"detail": [
{
"type": "greater_than_equal",
"loc": ["body", "priority"],
"msg": "Input should be greater than or equal to 1",
"input": 0,
"ctx": {"ge": 1}
}
]
}这里有一个断言策略:只固定真正属于你接口契约的字段。完整比对整个校验响应会把框架文案也锁死,依赖升级后即使业务语义没变,测试也可能变红。示例只固定错误位置和类型,既确认了边界,又保留了文案调整空间。
资源不存在时,除了检查状态码,还要检查返回内容以及是否留下了可调查的日志:
def test_missing_task_returns_404_and_a_useful_log(client, caplog):
caplog.set_level(logging.WARNING, logger="todo_api")
response = client.get(
"/tasks/999", headers={"X-Token": "dev-secret"}
)
assert response.status_code == 404
assert response.json() == {"detail": "任务不存在"}
assert "task_not_found task_id=999 owner=xiaohu" in caplog.text真实项目还要考虑“存在但属于别人”的情况。为了避免泄露资源是否存在,很多接口会对“没有权限”和“确实不存在”统一返回 404。测试应当固定你选择的安全策略。
FastAPI 的依赖注入让测试不必真的获取令牌、访问生产数据库或请求收费的第三方服务。app.dependency_overrides 是一个以原依赖函数为键、替代函数为值的字典。测试发请求时,FastAPI 会沿用正常的依赖解析流程,只是在指定节点换成测试实现。

例如,我们想证明管理员用户创建的任务会记录正确的所有者,又不想让这一条测试关心令牌格式:
from todo_api.main import User, app, get_current_user
def test_auth_dependency_can_be_overridden(client):
app.dependency_overrides[get_current_user] = lambda: User(
username="test-admin",
role="admin",
)
response = client.post(
"/tasks",
json={"title": "检查依赖覆盖", "priority": 1},
)
这条测试有意绕过认证实现,目标是验证“当前用户进入业务逻辑后发生什么”。前面的认证测试仍然保留,负责验证认证本身。把两种测试分开,失败时才知道该查令牌解析还是业务权限。
覆盖的键必须是应用在 Depends() 中使用的那个函数对象。写成另一个同名函数,或者覆盖函数调用结果,都不会生效。
更重要的是,覆盖属于应用的可变全局状态,必须清理。最稳妥的做法是在 fixture 的 finally 中统一处理:
@pytest.fixture
def client(db_session):
def override_db():
yield db_session
app.dependency_overrides[get_db] = override_db
try:
with TestClient(app) as test_client:
yield test_client
finally:
app.dependency_overrides.clear()使用 with TestClient(app) 还有一个好处:如果应用声明了 lifespan 启动和关闭逻辑,上下文管理器会执行它们。只在模块顶部写 client = TestClient(app),并不适合所有需要启动资源的应用。
数据库测试最难发现的问题,常常不是某条 SQL 写错,而是测试之间互相影响。第一条测试创建了编号为 1 的任务,第二条测试恰好依赖它,于是单独运行第二条会失败,整个套件一起运行反而通过。这类“顺序依赖”会让测试越来越难信任。

一个实用策略是:每条测试拿到独立连接,外层开启事务,应用内部即使执行 commit() 也只落在保存点中,测试结束时再回滚外层事务。
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from todo_api.main import Base
@pytest.fixture
def db_session(tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'test.db'}",
connect_args={"check_same_thread": False},
tmp_path 为每条测试提供独立目录。join_transaction_mode="create_savepoint" 让应用中的 session.commit() 使用保存点配合外层事务,最后仍能整体回滚。我们可以用连续两条测试验证隔离:
def test_database_write_is_rolled_back(client):
response = client.post(
"/tasks",
headers={"X-Token": "dev-secret"},
json={"title": "这条记录不会留给下一个测试", "priority": 3},
)
assert response.status_code == 201
def test_database_starts_empty_for_each_test(client):
response = client.get(
"/tasks"
运行这两条:
uv run pytest -q \
tests/test_api.py::test_database_write_is_rolled_back \
tests/test_api.py::test_database_starts_empty_for_each_test.. [100%]
2 passed in 0.04sSQLite 适合快速示例,但它和 PostgreSQL、MySQL 在字段类型、并发锁、约束、事务语义和 SQL 方言上并不完全相同。如果生产使用 PostgreSQL,至少要有一组数据库集成测试连接临时 PostgreSQL 实例,覆盖迁移、唯一约束、大小写比较、并发更新等高风险行为。
可以把测试分成两组:日常快速套件使用事务隔离,提交前或持续集成再运行真实数据库套件。关键不是执着于某一种数据库,而是明确这一层测试到底证明了什么。
测试配置必须使用独立数据库账号和独立数据库名。不要让测试通过环境变量误连生产库,也不要依赖“测试运行完会删掉”来兜底。连接之前就应检查数据库主机与名称是否属于允许的测试范围。
fixture 不是为了隐藏所有细节,而是把重复的资源生命周期放在一个地方:创建数据库、覆盖依赖、构造客户端、测试结束后清理。测试函数只声明自己需要什么,pytest 会根据参数名注入对应对象。
我们目前的依赖关系可以读成:
测试函数
└── client
└── db_session
├── 临时数据库
├── 外层事务
└── 测试结束后回滚与关闭fixture 的作用域要谨慎选择:
function 是默认值,每条测试重新创建,隔离最好。module 在一个测试文件中共享,适合创建成本高且不会被修改的资源。session 整次测试只创建一次,适合只读配置或全局服务,但最容易引入状态泄漏。数据库会被写入,所以示例保留默认的函数级作用域。为了省几百毫秒把它改成会话级,往往会换来很难解释的顺序依赖。
payload 字段增加以后,到处复制完整字典会让测试变得吵闹。可以加一个小工厂,只在测试里覆盖关心的字段:
def make_task_payload(**overrides):
payload = {
"title": "整理本周计划",
"priority": 3,
}
payload.update(overrides)
return payload
def test_priority_one_is_accepted(client):
response = client.post(
"/tasks",
headers={"X-Token": "dev-secret"},
json=make_task_payload(
工厂负责提供“通常有效”的数据,单条测试只突出自己正在改变的条件。不要让工厂随机生成关键字段,否则失败输入很难复现。确实需要随机数据时,把随机种子写进失败报告。
测试客户端很适合普通接口测试,但当测试本身还要直接 await 异步数据库或异步服务时,会遇到事件循环边界。更清楚的做法是把整个测试写成异步函数,用 HTTPX 的 ASGITransport 把请求交给应用。

# tests/test_async.py
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import func, select
from todo_api.main import Task, app, get_db
@pytest.mark.anyio
async def test_async_request_and_database_check_share_one_test(db_session):
def override_db():
yield db_session
app.dependency_overrides[get_db] = override_db
transport = ASGITransport(app=app)
try
base_url 即使不会真的访问网络也必须提供,因为客户端需要它补全 /tasks 这样的相对地址。ASGITransport(app=app) 负责把请求直接送进应用。
示例还提供了一个固定后端的 fixture,避免测试插件尝试当前项目没有安装的其他异步后端:
@pytest.fixture
def anyio_backend():
return "asyncio"有两个细节经常被漏掉。第一,ASGITransport 本身不替你管理所有应用 lifespan 场景;如果启动阶段要创建连接池或加载模型,应显式为异步测试管理 lifespan。第二,不要在一个异步测试里混用由另一事件循环创建的连接、锁或客户端,“绑定到不同事件循环”的报错通常就来自这种跨循环共享。
调试最快的入口通常不是加十行 print(),而是完整读一遍失败报告。假设有人把测试期望误写成保留重复空格:
def test_normalize_title_merges_repeated_spaces():
result = normalize_title(" 写 接口测试 ")
assert result == "写 接口测试"运行后,pytest 会把两边的差异直接展开:
E AssertionError: assert '写 接口测试' == '写 接口测试'
E - 写 接口测试
E ? --
E + 写 接口测试这份差异已经回答了三个问题:失败的是哪个表达式,当前值是什么,期望值又是什么。先确认测试表达的规则是否正确,再改业务代码。测试本身也可能错;看到红色不等于一定要迁就测试。
可以按范围逐步缩小运行目标:
# 只运行一个文件
uv run pytest tests/test_api.py
# 只运行一条测试
uv run pytest tests/test_api.py::test_invalid_priority_returns_422
# 按名字筛选
uv run pytest -k "auth or priority"
# 第一次失败就停止,并显示局部变量
uv run pytest -x -l
# 让标准输出直接显示
uv run pytest -s如果失败有时出现、有时消失,先重复运行那一条并检查是否依赖时间、随机数、执行顺序、共享数据库或网络。不要急着用重试掩盖它;不稳定测试本身就是一个需要修复的缺陷。
测试告诉我们“哪条规则没有满足”,日志和异常栈则帮助回答“请求走到哪一步、带着什么上下文、在哪一层断掉”。有效日志应该允许我们沿同一个请求向前后查找,而不是散落一堆没有身份的句子。

下面的中间件优先接受上游传来的请求标识,没有时生成一个,并把它写回响应头:
import logging
from uuid import uuid4
from fastapi import Request
logger = logging.getLogger("todo_api")
@app.middleware("http")
async def attach_request_id(request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid4()))
request.state.request_id = request_id
logger.info(
"request_started request_id=%s method=
日志参数使用占位符,而不是提前拼成 f-string。日志级别未启用时,参数不必先被格式化。记录字段要围绕调查问题选择:请求标识、动作、资源编号、用户编号和耗时通常有用;完整令牌、密码、银行卡号和整份请求体通常不该进入日志。
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def unhandled_exception(request: Request, exc: Exception):
request_id = getattr(request.state, "request_id", "unknown")
logger.exception(
"request_failed request_id=%s path=%s",
request_id,
request.url.path,
)
return JSONResponse(
status_code=500
logger.exception() 应在异常处理上下文中使用,它会把当前异常栈一起写入日志。客户端只收到稳定的错误说明和可用于反馈的请求标识,不会看到源码路径、数据库语句或密钥。
测试 500 响应时,默认测试客户端会把服务端异常重新抛给测试。我们明确关闭这一行为,才能检查最终响应:
def test_500_response_hides_the_stack_but_logs_it(caplog):
caplog.set_level(logging.ERROR, logger="todo_api")
with TestClient(app, raise_server_exceptions=False) as client:
response = client.get(
"/debug/crash",
headers={"X-Request-ID": "debug-42"},
)
assert response.status_code == 500
assert response.json() == {
这里同时保护了两面:对外不泄露,对内保留完整栈。caplog 比截取终端文本更稳,因为它直接访问日志记录和异常信息。
当失败可以稳定复现,却仍看不懂对象在某一步为什么变了,断点就很有价值。最轻量的方式是在可疑位置写:
breakpoint()然后用 pytest -s 运行单条测试。程序暂停后,可以查看:
(Pdb) p payload
(Pdb) p user
(Pdb) p db.in_transaction()
(Pdb) where
(Pdb) next
(Pdb) continue断点适合查看当前变量和调用路径,日志适合保留跨请求证据,异常栈适合定位错误传播链。三者各有用途。不要在共享环境打开交互式调试,也不要把临时断点提交进正常运行路径。
FastAPI 应用也可以在 if __name__ == "__main__": 中调用 uvicorn.run(app, ...),再由编辑器直接以调试模式运行当前文件。这样路由、依赖和异常处理器都能正常命中 IDE 断点。
“所有测试通过”只能说明测试覆盖的条件下没有观察到失败。线上条件和测试条件之间有差异时,绿色结果仍可能漏掉问题。下面这些差异在 FastAPI 项目里尤其常见。
应用在 lifespan 中创建连接池、缓存客户端或后台任务,而测试只是直接实例化客户端,没有进入上下文管理器。测试使用了预先构造的替代对象,线上启动后却发现资源没有初始化。
处理方式是给启动和关闭逻辑写专门测试,并使用能执行 lifespan 的客户端上下文。异步测试也要显式管理生命周期。
SQLite 接受的类型、锁行为或 SQL,在 PostgreSQL 上可能不同。测试还可能直接 create_all(),而生产靠迁移脚本建表,结果迁移漏了一列。
处理方式是让一组集成测试运行生产同类数据库,并从空库执行全部迁移,再验证关键查询和约束。
每条接口测试都覆盖了认证、数据库和外部服务,业务逻辑当然很稳定,但真正的依赖组合从没一起运行过。模拟对象还可能返回生产 SDK 永远不会返回的理想数据。
处理方式是保留快速覆盖测试,同时为每个外部边界写契约测试,并用少量完整流程测试验证真实组合。
测试默认值允许缺失配置,生产环境却因为大小写、布尔值解析、时区或密钥格式启动失败。还有一种更危险的情况:测试意外读取了开发者机器上的 .env,持续集成中才暴露缺项。
处理方式是从明确的环境映射构造配置,测试“必需项缺失时应失败”,并在部署前校验配置,不依赖个人机器的隐式文件。
测试一次只发一个请求,线上却同时更新同一条记录。两个请求都读到旧版本,后写入者覆盖前一个结果。普通单线程测试不会发现竞争条件。
处理方式是为库存、余额、幂等键等高风险流程写并发测试,并依靠数据库约束、行锁或乐观锁保证原子性。
进程内客户端直接访问 /tasks,线上却经过网关,加了路径前缀、转发头和 HTTPS 终止。重定向地址、客户端 IP 或文档路径可能因此不对。
处理方式是在预发布环境用与生产一致的代理配置跑少量冒烟测试,检查转发头、根路径、跨域和大请求体限制。
开发机、持续集成和生产安装到不同的小版本,测试客户端、数据校验或数据库驱动行为随之改变。某个弃用警告长期被忽略,升级后就会变成错误。
处理方式是锁定依赖、让三处使用同一锁文件,并把警告纳入定期清理计划。升级依赖时先运行完整测试,再阅读与本项目相关的变更。
遇到“本地绿、线上红”,不要先凭直觉改代码。先把两边的 Python 版本、依赖锁、环境变量、数据库类型、迁移版本、启动命令、代理路径和并发条件列出来对照。差异本身往往就是线索。
完整运行示例测试:
uv run pytest -q --disable-warnings.............. [100%]
14 passed, 1 warning in 0.13s测试数量不是目标,反馈质量才是。一个实用的日常节奏是:
修改纯业务规则时,先运行对应单元测试文件。它最快,失败也最容易定位。
修改路由、模型、依赖或异常处理时,运行相关接口测试,并检查成功和错误路径。
提交前运行完整快速套件,确保依赖覆盖已清理、数据库测试可以任意顺序运行。
持续集成再运行真实数据库、迁移和关键端到端流程,把本地没有覆盖的组件边界补上。
一条新缺陷被发现时,先把它缩成可以稳定失败的最小测试,再修代码。修完以后,这条测试会留在套件中,防止同样的问题悄悄回来。日志里保留请求标识和关键业务字段,500 响应不泄露异常细节;遇到复杂状态变化,再用断点查看现场。
到这里,我们已经不再靠“我刚才点过,好像能用”来判断接口是否可靠。纯函数测试保护业务规则,接口测试保护 HTTP 契约,依赖覆盖让外部边界可控,事务回滚保证数据隔离,异步测试覆盖事件循环内的真实调用方式,日志与异常栈则让未被测试提前发现的问题仍然有迹可循。这套反馈链会直接决定你下一次重构时,敢不敢放心地改。