跳转至

进阶 4 · 测试与部署

API 写完不测等于裸奔。本章讲怎么用 TestClient/httpx 对 FastAPI 做端到端测试(对照 Spring 的 MockMvc / @SpringBootTest),以及本地运行部署。FastAPI 的依赖注入让测试格外好写——可以不连真实数据库、不打网络端口地测整个 API。

贯穿项目 bookmarks-api/tests/ 是本章代码的完整范例(10 个测试全绿)。


4.1 TestClient:最简入门

FastAPI 的 TestClient 基于 httpx,能直接对 ASGI app 发"假请求",无需启动服务器:

uv add --dev httpx        # TestClient 依赖 httpx
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()

三个关键点:

  1. 内存 SQLite + StaticPool:内存库每个连接是独立的,不共享就找不到表。StaticPool 让所有请求共用一个连接,保证一致。每个测试 fixture 新建一个,测试间完全隔离(对照 @Transactional 回滚的测试隔离)。
  2. ASGITransport:直接把 app 对象当传输层,不监听网络端口,快且无副作用。
  3. 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 运行测试

cd bookmarks-api
uv run pytest -v                # 全部
uv run pytest tests/test_api.py::test_health -v   # 单个

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 是单进程)
容器化 Dockerfilepython: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(titlemin_length=1 约束)。

参考答案
async def test_empty_title_rejected(client):
    key = await _register(client)
    r = await client.post("/bookmarks",
        json={"url": "https://x.com", "title": ""},
        headers={"X-API-Key": key})
    assert r.status_code == 422
练习 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 启动)。

参考答案提示
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install uv && uv sync --no-dev
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "bookmarks_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

上一章:进阶 3 · 持久化与认证← 回首页核心教程路线图