进阶 4 · 测试与部署¶
API 写完不测等于裸奔。本章讲怎么用 TestClient/httpx 对 FastAPI 做端到端测试(对照 Spring 的 MockMvc / @SpringBootTest),以及本地运行部署。FastAPI 的依赖注入让测试格外好写——可以不连真实数据库、不打网络端口地测整个 API。
贯穿项目
bookmarks-api/tests/是本章代码的完整范例(10 个测试全绿)。
4.1 TestClient:最简入门¶
FastAPI 的 TestClient 基于 httpx,能直接对 ASGI app 发"假请求",无需启动服务器:
from fastapi.testclient import TestClient
from bookmarks_api.main import app
client = TestClient(app)
def test_health():
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
对照 Spring 的 MockMvc.perform(get("/health"))——但无需 @WebMvcTest、无需 Spring 上下文,直接对着 app 对象发请求。
4.2 异步测试 + 内存数据库(生产级方式)¶
TestClient 是同步的,简单够用。但贯穿项目用了 async DB,更彻底的方式是 async httpx.AsyncClient + 内存 SQLite——和真实请求路径一致、且测试间完全隔离:
# bookmarks-api/tests/conftest.py
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from bookmarks_api.db import Base, get_db
from bookmarks_api.main import app
@pytest_asyncio.fixture
async def client():
engine = create_async_engine(
"sqlite+aiosqlite://", # 内存 SQLite
poolclass=StaticPool, # 关键:共享单连接,保证内存库一致
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) # 建表
TestSession = async_sessionmaker(engine, expire_on_commit=False)
async def override_get_db(): # 覆盖 get_db,指向测试库
async with TestSession() as session:
yield session
app.dependency_overrides[get_db] = override_get_db # 替换依赖!
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear() # 测完清理
await engine.dispose()
三个关键点:
- 内存 SQLite +
StaticPool:内存库每个连接是独立的,不共享就找不到表。StaticPool让所有请求共用一个连接,保证一致。每个测试 fixture 新建一个,测试间完全隔离(对照@Transactional回滚的测试隔离)。 ASGITransport:直接把 app 对象当传输层,不监听网络端口,快且无副作用。dependency_overrides[get_db]:第 2 章依赖注入的测试红利——把"真实 DB session"换成"测试库 session",不改一行业务代码。
pytest-asyncio 让 async 测试函数自动运行(asyncio_mode = "auto")。
4.3 测试认证与权限隔离¶
API 测试最该覆盖的是认证和越权。贯穿项目的测试展示了典型场景:
async def _register(client, username="alice") -> str:
r = await client.post("/users", json={"username": username})
return r.json()["api_key"] # 注册拿到 key
async def test_missing_key_is_unauthorized(client):
r = await client.get("/bookmarks")
assert r.status_code == 401 # 无 key → 401
async def test_permission_isolation(client):
key_a = await _register(client, "alice")
key_b = await _register(client, "bob")
bid = (await client.post("/bookmarks",
json={"url": "https://x.com", "title": "X"},
headers={"X-API-Key": key_a})).json()["id"]
# bob 访问 alice 的书签 → 404(隔离生效)
r = await client.get(f"/bookmarks/{bid}", headers={"X-API-Key": key_b})
assert r.status_code == 404
该测什么(清单):
- ✅ 正常路径(注册→创建→列表→删除)
- ✅ 校验失败(非法 URL → 422)
- ✅ 未认证(无 key / 错 key → 401)
- ✅ 越权(A 看不到 B 的 → 404)
- ✅ 业务约束(重复用户名 → 400)
这些正是贯穿项目 test_api.py 覆盖的 10 个测试。
Pythonic 写法
- 每个测试自给自足(自己注册用户、建数据),不依赖测试顺序——可并行、可单独跑。
- 用 helper(
_register)消除重复。 assert r.status_code == ...直接断言,pytest 失败时信息清晰。
4.4 运行测试¶
4.5 本地运行¶
开发用 --reload 热重载:
uv run uvicorn bookmarks_api.main:app --reload
# INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
# INFO: Application startup complete.
打开 http://127.0.0.1:8000/docs 在 Swagger UI 里交互测试(注册用户→拿 key→在 Authorize 填 key→调书签接口)。
# 命令行实测
uv run python -m bookmarks_api.seed # 种子数据(建表 + alice)
curl -X POST http://127.0.0.1:8000/users -H "Content-Type: application/json" -d '{"username":"alice"}'
curl http://127.0.0.1:8000/bookmarks -H "X-API-Key: bm_..."
4.6 生产化提示(点到为止)¶
蓝图定为"仅本地运行",这里给方向、不展开:
| 主题 | 方向 | 说明 |
|---|---|---|
| 进程管理 | gunicorn -k uvicorn.workers.UvicornWorker -w 4 |
多 worker 利用多核(单 uvicorn 是单进程) |
| 容器化 | Dockerfile(python:3.13-slim + uv 装 deps + uvicorn 启动) |
标准部署单元 |
| 数据库迁移 | Alembic(SQLAlchemy 配套) | 不要在生产用 create_all,用迁移管 schema 变更 |
| 配置 | pydantic-settings + 环境变量(第 1 章) |
12-factor 配置 |
| 反向代理 | Nginx / Caddy 在前 | TLS 终止、静态资源 |
| 监控 | 结构化日志(核心教程第 9 章 logging)+ 指标 |
生产可观测性 |
对照 Spring Boot
- Spring Boot 内嵌 Tomcat 打成 fat jar;FastAPI 本身不含服务器,靠 uvicorn/gunicorn 运行 ASGI app。
- Spring 用 Flyway/Liquibase 迁移;SQLAlchemy 对应 Alembic。
- 部署思维一致:容器化 + 环境变量 + 反向代理。
进阶篇结语¶
四章走完,你已能独立构建一个类型驱动、带数据库、有认证、有测试的 FastAPI 服务——且理解了它与 Spring Boot 的对照(DI ↔ 依赖注入、pydantic ↔ DTO+校验、SQLAlchemy ↔ JPA、TestClient ↔ MockMvc)。
下一步可深入(见核心教程第 14 章路线图):JWT/OAuth2 认证、Alembic 迁移、WebSocket、后台任务(BackgroundTasks/Celery)、Docker/CI 部署。
Happy API building! 🚀
本章练习¶
练习 4.1
为 bookmarks-api 加一个测试:注册用户后,用空标题创建书签应返回 422(title 的 min_length=1 约束)。
练习 4.2
解释为什么测试里要 app.dependency_overrides[get_db] = override_get_db,以及不这么做会发生什么。
参考答案
不覆盖的话,get_db 用模块级真实 engine(指向 bookmarks.db 文件),测试会读写真实数据库——污染数据、测试间互相影响、且依赖文件系统。覆盖后指向内存 SQLite,每次 fixture 独立、干净、快。这是依赖注入的测试红利:业务代码零改动,只换"依赖的实现"。
练习 4.3
写一个最小 Dockerfile,把 bookmarks-api 容器化(Python 3.13-slim + uvicorn 启动)。
上一章:进阶 3 · 持久化与认证 | ← 回首页 | 核心教程路线图