7.2 隔离数据库并覆盖关键失败路径
隔离数据库并覆盖关键失败路径
接口测试可以证明路由返回了正确状态码,但如果测试写进了开发库或生产库,它就会改变下一次测试的输入,甚至破坏真实数据。数据库测试首先要解决边界:测试连接串必须指向单独的 PostgreSQL 数据库,测试结束要回滚或清理写入,应用的数据库依赖要明确替换,不能偷偷读取默认生产配置。
本节分两层处理:先用 dependency_overrides 建立快速的接口失败路径检查,确认未认证、非所有者、资源不存在、重复数据和写入失败都有明确结果;再给出 PostgreSQL 测试夹具的连接和事务策略。前一层使用内存替身只是验证 HTTP 契约,不能替代 PostgreSQL 集成测试,也不会把 SQLite 当作 PostgreSQL 的近似品。
先把测试依赖变成可替换的入口
路由不应该在函数体里直接创建数据库连接,否则测试无法控制数据来源。把存储访问放到依赖中,生产环境提供真实实现,测试环境通过 app.dependency_overrides 注入隔离替身或测试会话。
下面的完整示例使用一个明确标记的 TestStore 来检查接口边界。它不模拟 PostgreSQL 的 SQL 语义,只负责让失败路径可以快速、稳定地执行;真实的唯一约束、外键、事务和 PostgreSQL 特有查询必须由后面的专用数据库测试验证。
<!-- file: ch07_db/test_database_boundary.py -->
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, status
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class PostCreate(BaseModel):
title: str = Field(min_length=3, max_length=80)
content: str = Field(min_length=1)
class PostUpdate(BaseModel):
content: str = Field(min_length=1)
class PostResponse(PostCreate):
id: int
author_id: int
class DuplicateTitle(Exception):
"""替身存储用来代表数据库唯一约束冲突。"""
class TestStore:
def __init__(self) -> None:
self.posts: list[dict[str, object]] = []
self.fail_writes = False
def reset(self) -> None:
self.posts.clear()
self.fail_writes = False
def create(self, payload: PostCreate, author_id: int) -> dict[str, object]:
if self.fail_writes:
raise RuntimeError("测试用写入失败")
if any(post["title"] == payload.title for post in self.posts):
raise DuplicateTitle
post = {
"id": len(self.posts) + 1,
"title": payload.title,
"content": payload.content,
"author_id": author_id,
}
self.posts.append(post)
return post
def get(self, post_id: int) -> dict[str, object] | None:
return next((post for post in self.posts if post["id"] == post_id), None)
store = TestStore()
app = FastAPI(title="数据库失败路径示例")
def get_store() -> TestStore:
# 生产实现应在这里提供数据库会话;示例故意要求测试显式覆盖它。
raise RuntimeError("请在应用启动配置中注入真实存储")
def get_current_user(
x_user_id: Annotated[int | None, Header()] = None,
) -> int:
if x_user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="需要登录",
)
return x_user_id
StoreDep = Annotated[TestStore, Depends(get_store)]
UserDep = Annotated[int, Depends(get_current_user)]
@app.post("/posts", response_model=PostResponse, status_code=201)
def create_post(
payload: PostCreate,
user_id: UserDep,
db: StoreDep,
) -> dict[str, object]:
try:
return db.create(payload, author_id=user_id)
except DuplicateTitle as exc:
raise HTTPException(status_code=409, detail="文章标题已存在") from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail="写入暂时不可用") from exc
@app.get("/posts/{post_id}", response_model=PostResponse)
def get_post(post_id: int, db: StoreDep) -> dict[str, object]:
post = db.get(post_id)
if post is None:
raise HTTPException(status_code=404, detail="文章不存在")
return post
@app.patch("/posts/{post_id}", response_model=PostResponse)
def update_post(
post_id: int,
payload: PostUpdate,
user_id: UserDep,
db: StoreDep,
) -> dict[str, object]:
post = db.get(post_id)
if post is None:
raise HTTPException(status_code=404, detail="文章不存在")
if post["author_id"] != user_id:
raise HTTPException(status_code=403, detail="没有修改这篇文章的权限")
post["content"] = payload.content
return post
def self_check() -> None:
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[get_store] = lambda: store
try:
store.reset()
with TestClient(app, raise_server_exceptions=False) as client:
no_auth = client.post(
"/posts",
json={"title": "没有身份", "content": "正文"},
)
assert no_auth.status_code == 401
created = client.post(
"/posts",
headers={"X-User-ID": "10"},
json={"title": "隔离测试文章", "content": "初始正文"},
)
assert created.status_code == 201
assert created.json()["author_id"] == 10
duplicate = client.post(
"/posts",
headers={"X-User-ID": "10"},
json={"title": "隔离测试文章", "content": "重复标题"},
)
assert duplicate.status_code == 409
assert len(store.posts) == 1
missing = client.get("/posts/999")
assert missing.status_code == 404
forbidden = client.patch(
"/posts/1",
headers={"X-User-ID": "20"},
json={"content": "越权修改"},
)
assert forbidden.status_code == 403
assert store.posts[0]["content"] == "初始正文"
store.fail_writes = True
failed = client.post(
"/posts",
headers={"X-User-ID": "10"},
json={"title": "写入失败文章", "content": "不会落库"},
)
assert failed.status_code == 503
assert len(store.posts) == 1
finally:
app.dependency_overrides = original_overrides
assert app.dependency_overrides == {}
print("database boundary self-check passed; no PostgreSQL connection used")
if __name__ == "__main__":
self_check()
在包含 ch07_db/ 的目录运行:
python ch07_db/test_database_boundary.py
预期输出为 database boundary self-check passed; no PostgreSQL connection used。最后一句是边界声明:这个检查验证了依赖覆盖和 HTTP 结果,但没有声称真实数据库已经连接。
测试数据库必须是独立 PostgreSQL
需要检查唯一索引、外键、事务隔离、RETURNING、窗口函数或 PostgreSQL 方言时,应准备专用数据库 URL,例如:
export TEST_DATABASE_URL='postgresql+psycopg://tester:password@127.0.0.1:5432/fastapi_test'
测试代码只读取 TEST_DATABASE_URL,不要回退到业务配置中的 DATABASE_URL。更稳妥的做法是在夹具启动时检查变量存在、协议是 postgresql+psycopg,并由数据库名或容器网络规则确认它属于测试环境;检查失败就停止测试,不能“为了让测试通过”自动连默认库。
下面是同步 SQLAlchemy 会话的最小夹具片段。它是接入真实应用时需要改造的结构示意,不是上一份内存替身的替代代码;如果应用使用 AsyncSession,应使用对应的 AsyncEngine、异步连接和 async 夹具。
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
TEST_DATABASE_URL = os.environ["TEST_DATABASE_URL"]
if not TEST_DATABASE_URL.startswith("postgresql+psycopg://"):
raise RuntimeError("TEST_DATABASE_URL 必须指向 PostgreSQL psycopg 驱动")
engine = create_engine(TEST_DATABASE_URL)
def db_session():
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
try:
yield session
finally:
session.close()
transaction.rollback()
connection.close()
在 pytest 中,应把这个会话通过 app.dependency_overrides[get_db] 提供给路由。每个测试开始前创建事务,结束时回滚,能够清理大多数写入;无法被事务回滚的操作,例如序列值、外部服务调用和独立连接提交,仍要有专门的清理策略。数据库 schema 应在测试会话开始前由迁移或固定建表步骤准备,测试结束不要删除开发者正在使用的数据库。
如果测试使用并行 worker,每个 worker 需要独立数据库或独立 schema,并且要重新评估序列、锁和外部资源的隔离。一个共享的全局测试库加“测试后清空表”很容易在并发运行时相互覆盖,不能把它当成隔离方案。
让失败结果保护数据
失败测试要同时检查状态码和数据状态。409 之后应该确认重复记录没有增加;403 之后应该确认原文章内容没有改变;写入异常之后应该确认没有半条记录留在数据库里。真实 PostgreSQL 测试还应检查事务是否回滚、约束异常是否被转换为预期的业务错误,以及异常转换过程中没有把连接留在 failed transaction 状态。
认证替身也要有边界。示例中的 X-User-ID 只用于测试身份依赖,不能当成生产认证:生产环境应由已验证的会话或令牌依赖提供用户标识,客户端不能通过任意请求头伪造身份。测试应分别覆盖缺少身份、身份格式错误和身份存在但无权限三种情况。
本节的可运行脚本只做了内存边界检查,真实 PostgreSQL 连接、迁移执行、约束行为和事务回滚仍需在专用测试数据库中运行。没有测试数据库时,正确做法是明确记录“未验证”,而不是换成 SQLite 后把结果当作 PostgreSQL 证据。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“测试”和数据库主题。本节把原文的测试实践扩展为依赖覆盖、失败路径和数据库隔离演练。
- 官方文档:FastAPI 测试依赖、FastAPI 测试基础、SQLAlchemy 会话基础。

免费 AI IDE


更多建议: