FastAPI 教程

Chapter 09 · 测试:在上线前发现问题

📚 学完本章,你将能够:

  • 解释为什么"手动在浏览器点一点"不是可靠的测试方法
  • 用 TestClient 编写自动化 API 测试
  • 用 dependency_overrides 替换真实依赖为 Mock
  • 为数据库测试创建隔离环境,保证测试互不干扰
  • 运行测试套件并查看覆盖率报告

💡 场景引入

你重构了图书 API 的数据库查询逻辑,自信满满地部署上线。十分钟后,用户反馈"登录后看不到自己的书了"。

原来你在重构时不小心改了一个字段名,但手动测试时只测了"未登录的列表接口",漏掉了"登录后的个人接口"。人总是会漏掉边界情况,自动化测试不会。

9.1 为什么需要自动化测试

手动测试自动化测试
每次改代码都要手动点一遍运行一条命令,全部自动验证
容易遗漏边界情况每个边界都写成用例,不会忘记
无法验证"没坏"重构后跑一次,确保旧功能正常
多人协作时重复劳动CI 自动跑,没过不让合并代码

9.2 TestClient:你的自动化浏览器

FastAPI 提供 TestClient,它在内存中模拟 HTTP 请求,不需要真的启动服务器:

基础测试
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_get_books():
    response = client.get("/books")
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, list)

def test_create_book():
    response = client.post("/books", json={
        "title": "测试书",
        "author": "测试作者",
        "price": 29.9
    })
    assert response.status_code == 201
    assert response.json()["title"] == "测试书"

运行测试:

bash
pip install pytest
pytest tests/ -v

9.3 依赖覆盖:测试的杀手锏

测试认证接口时,你真的要生成 JWT、查数据库吗?不需要。dependency_overrides 让你替换任何依赖:

替换认证依赖
async def mock_current_user():
    return User(id=1, username="tester", role="admin")

# 测试中临时替换
app.dependency_overrides[get_current_user] = mock_current_user

@router.delete("/books/{book_id}")
async def delete_book(..., user: User = Depends(get_current_user)):
    ...

def test_delete_book():
    response = client.delete("/books/1")
    assert response.status_code == 204

# 测试完清理
app.dependency_overrides.clear()
Mock 不是作弊

单元测试的目标是验证"当前函数的逻辑",而不是验证"JWT 库是否正确"。JWT 库由它的作者测试,你只需要验证自己的代码路径。

9.4 数据库隔离:每个测试从零开始

测试不应该污染真实数据库。标准做法是:为测试创建独立的数据库,每个测试在事务中运行,结束后回滚。

conftest.py(pytest 配置)
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from models import Base

TEST_DB_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
engine = create_async_engine(TEST_DB_URL)

@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_db():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)

@pytest_asyncio.fixture
async def db_session():
    async with AsyncSession(engine) as session:
        async with session.begin():
            yield session
        # 事务自动回滚,测试数据不残留

9.5 覆盖率:你测了多少代码?

运行覆盖率
pip install pytest-cov
pytest --cov=app --cov-report=html
# 然后打开 htmlcov/index.html 查看报告

覆盖率告诉你"哪些代码行被测试执行过"。但注意:高覆盖率不等于高质量。100% 覆盖率但断言很少,仍然是不可靠的测试。

🎯 知识检测

问题 1
TestClient 发送的请求会真的走网络吗?
会,它会向 127.0.0.1 发 HTTP 请求
不会,它在内存中直接调用 ASGI 应用
取决于 --reload 参数
只在 Windows 上不走网络
解析:TestClient 基于 httpx 的 ASGI 传输,直接在 Python 内存中调用 FastAPI 应用,不经过 TCP/HTTP 网络栈。这意味着测试很快,且不需要服务器在运行。
问题 2
为什么要用 dependency_overrides 替换 get_current_user?
因为原来的依赖有 bug
为了测试中跳过认证逻辑,直接注入一个假用户
为了让测试运行更快(虽然这也对,但不是主要原因)
因为 FastAPI 不允许在测试中调用真实依赖
解析:单元测试的目标是验证当前接口的业务逻辑,而不是验证 JWT 解析。通过替换依赖,测试可以专注于"给定一个已登录用户,删除操作是否正确",而不关心 Token 怎么生成。
问题 3
覆盖率 100% 意味着?
代码完全没有 bug
测试执行了每一行代码,但不保证逻辑正确
测试运行速度最快
不需要再写更多测试了
解析:覆盖率只统计"哪些代码被跑过",不统计"这些代码的断言是否充分"。100% 覆盖率但缺少断言的测试,仍然可能放过 bug。覆盖率是必要指标,但不是充分指标。

本章小结

✓

自动化测试是安全网

重构和加功能时,测试确保旧代码没坏。

✓

TestClient 在内存中测试

不需要启动服务器,速度快,适合 CI。

✓

dependency_overrides 隔离依赖

替换认证、数据库等外部依赖,专注测试当前逻辑。

✓

事务回滚实现数据隔离

每个测试后自动回滚,测试互不干扰。

🛠️ 动手实验

为你前面章节实现的图书 API 编写测试:测试获取列表(200)、创建书籍(201)、获取不存在的书(404)、删除需要管理员权限(403)。
使用 dependency_overrides 绕过认证,使用独立测试数据库。运行 pytest --cov 查看覆盖率。