tva
← 洞察

一个 PR 中的数十个后端测试:我们的 FastAPI 测试策略

这个 PR 的说明写道:“新增测试套件——52 个测试,覆盖认证、数据层和 API 接口。”它合并时,所覆盖的 FastAPI 后端已经在生产环境运行了三个月。这并不符合应有的测试实践,也不值得庆祝。但仍有必要坦诚分析,因为“迟写测试”与“始终不写测试”有着实质区别,而两者之间的差距,恰好就是一个可持续维护的代码库与一个最终不得不重写的代码库之间的差距。

本文介绍我们最终采用的测试架构、在实际 FastAPI 项目中经得起长期使用的具体模式,以及这个 PR 除了测试数量之外究竟包含什么。

项目背景

这个后端是一个 FastAPI 服务,为一家电商运营商处理面向客户的业务:订单管理、库存查询、定价规则,以及与第三方物流 API 的集成。数据库采用 PostgreSQL,通过支持异步操作的 SQLAlchemy 访问。认证基于 JWT,令牌由独立的认证服务校验。正常运行时会调用三个外部 API:物流服务商、货币兑换服务和产品数据源。

在生产环境运行三个月,也意味着三个月的缺陷修复、功能新增和渐进式修改,全都没有测试。这个代码库并非无法测试。设计时已经考虑了可测试性:通过依赖注入提供依赖,不使用全局状态,并通过 FastAPI 的依赖注入系统为每个请求创建数据库会话。只是测试一直没有写。集中处理测试的 PR 是合适的载体,并不是因为值得推荐一次性补齐测试覆盖,而是因为“一个包含测试的 PR”能够得到审查、合并和持续维护。“以后逐步补测试”则往往无限期地停留在待办中。

fixture 层级

pytest 的 fixture 系统需要有意识地设计。对于使用真实数据库的 FastAPI 项目,自然的层级划分包含三个作用域:session、module 和 function;这套测试主要使用 session 和 function 作用域。

session 作用域的 fixture 在每轮测试运行中只执行一次。数据库创建、创建数据模式(下方示例使用 Base.metadata.create_all;Alembic 迁移也可放在同一阶段),以及填充测试之间不会变化的参考数据,都应放在这里。session 作用域的数据库引擎由所有测试共享,因此创建数据模式的开销只需承担一次,而不是每个测试文件一次。

function 作用域的 fixture 为每个测试提供独立状态。其中关键的是事务回滚 fixture:测试开始时开启事务,结束时回滚。测试执行的所有写入,包括创建用户、插入订单和更新价格,都会自动撤销。这样无需在各次测试之间清空表,也能实现隔离。

import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

@pytest_asyncio.fixture(scope="session")
async def engine():
    engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

@pytest_asyncio.fixture
async def db_session(engine):
    """Each test gets a transaction that rolls back on completion."""
    async with engine.begin() as conn:
        await conn.begin_nested()
        session_factory = sessionmaker(
            conn, class_=AsyncSession, expire_on_commit=False
        )
        async with session_factory() as session:
            yield session
            await session.rollback()

begin_nested() 调用在外层事务中创建保存点。其目的是让测试代码可以在自己的会话中自由提交,同时在清理时通过回滚外层事务撤销所有改动。但所示的 fixture 并未完全实现这一点:engine.begin() 会在代码块正常退出时提交,而 session.rollback() 只会回滚会话自身的操作。要实现完全隔离,应使用 engine.connect() 打开连接,通过 conn.begin() 启动外层事务,以 join_transaction_mode="create_savepoint" 将会话绑定到连接,并在测试结束后显式回滚外层事务。采用这一设置后,应用代码中对 session.commit() 的调用只会释放保存点,外层回滚仍会撤销这些改动。

模拟外部 API

三个外部 API,意味着访问网络的测试存在三个可能导致结果不稳定的来源。标准做法是模拟 HTTP 客户端,使用 respx 即可方便地实现;它在传输层拦截 httpx 请求。

import respx
import httpx
import pytest

@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
    mock_response = {
        "carrier": "DHL",
        "estimated_days": 3,
        "cost_eur": 8.50
    }

    with respx.mock:
        respx.post("https://api.logistics-provider.com/v2/rates").mock(
            return_value=httpx.Response(200, json=mock_response)
        )
        response = await test_client.post(
            "/api/v1/shipping/estimate",
            json={"destination_zip": "10115", "weight_kg": 2.5},
            headers={"Authorization": f"Bearer {valid_token}"}
        )

    assert response.status_code == 200
    assert response.json()["estimated_days"] == 3

respx.mock 上下文管理器将所有出站 httpx 请求交给模拟层处理。任何未显式注册的请求都会报错,这是正确的默认行为。测试意外访问生产 API 是一类难以察觉的缺陷,一旦产生副作用,代价可能很高。

货币兑换服务会在每个非 EUR 计价订单中被调用。我们用共享 fixture 提供预先配置好标准汇率的 respx 路由器。测试货币逻辑时覆盖特定货币对的汇率;测试无关行为但碰巧触发货币依赖时,则直接使用 fixture 的默认值,无需额外考虑。这种区分让测试意图清晰:物流测试只关注物流,无需关心当时配置了什么汇率。

测试认证流程

FastAPI 的依赖注入系统是它在测试方面的一大优势。本项目的认证作为依赖提供:每个受保护路由都声明 current_user: User = Depends(get_current_user),其中 get_current_user 校验 JWT 并返回用户对象。测试时,在应用层覆盖这个依赖:

from app.main import app
from app.dependencies import get_current_user
from app.models import User

def make_user(role: str = "operator") -> User:
    return User(id="test-user-id", email="[email protected]", role=role)

@pytest.fixture
def operator_client(db_session):
    app.dependency_overrides[get_current_user] = lambda: make_user("operator")
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

@pytest.fixture
def admin_client(db_session):
    app.dependency_overrides[get_current_user] = lambda: make_user("admin")
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

为不同角色分别提供 fixture,可以清楚地表达权限边界测试。操作员尝试执行管理员操作时,断言返回 403 的测试,与其所落实的业务规则一样直观。fixture 命名为 operator_client 和 admin_client 后,无需阅读测试主体就能看出意图。

另有少量测试直接验证 JWT 校验代码路径,包括过期令牌、格式错误的令牌,以及受众声明不正确的令牌。这些测试不使用覆盖依赖的 fixture,而是通过测试客户端传入原始令牌,并断言返回 401 或 403。这种区分是有意的:大多数测试关注认证后的行为,因此使用依赖覆盖;认证测试则关注认证逻辑本身。混在一起,会让每个测试究竟覆盖什么变得不清楚。

有意识地测试错误路径

正常路径的测试容易编写,也往往是在时间紧迫时唯一写出的测试。然而,进入生产环境的缺陷很少出现在正常路径中。对这个代码库而言,未经测试且影响最大的行为在错误处理中:物流 API 返回 503 时会怎样?请求缺少必填字段时 API 返回什么?并发修改违反数据库约束时又会怎样?

对每个外部 API 的模拟,我们都在成功用例之外至少加入一个错误用例。物流 API 的 fixture 有一个返回 503 的变体,用来确认端点会返回带有恰当 HTTP 状态码的结构化错误响应,而不是任由异常传播成 500。数据库约束测试尝试插入冲突数据,并确认 API 返回 409,而不是未处理的数据完整性错误。

事后补测试时,这些覆盖不会自然出现。设计测试时需要主动枚举失败模式。这也确实是支持与实现同步编写测试的理由之一:实现思路还清晰、边界情况尚未遗忘时,更容易列全失败模式。

迟写测试的投入回报

迟了三个月才写测试,仍然好过从不写,理由并不抽象。编写这个 PR 时发现了三个已有缺陷:可选地址字段缺少空值检查,导致账单地址没有第二行的订单返回 500;分页逻辑存在差一错误,只在空结果集时出现;资源创建端点返回了错误的 HTTP 状态码,使用 200 而非 201。这些缺陷已在生产环境存在数月,却没有引发用户投诉,这恰好就是它们一直未被发现的原因。测试在一个下午就找出了它们。

更持久的回报是行为文档。覆盖认证边界、外部 API 交互和错误情况的测试套件,是一份描述 API 行为的规格,只要测试通过,它就保持准确。“操作员不能访问管理员端点”的测试不仅能发现回归,也能向下一位阅读代码库的人传达设计意图。一个服务迟早会由原作者之外的人维护,每个服务最终都如此;对这样的服务,这份规格的价值远高于编写测试时发现的那几个缺陷。

反对迟写测试的理由是,成本集中在前期:一个大型 PR、集中投入的审查,以及原本可以用于开发功能的时间。这是事实。但另一种选择是,因为补测试的合适时机始终没有到来,代码库一直没有测试。它带来的成本会不断累积,却很少被衡量,因为成本分散在后续每次修改中。迟写测试套件只会集中付出一次成本;没有测试套件则会持续付出成本。

相关洞察

延伸阅读