K 的一隅

Python FastAPI 后端实战

测什么、怎么假:测试方案取舍

单元测试与集成测试的边界、Mock 与 Stub 在 FastAPI 项目中的适用场景,以及依赖 override 与测试金字塔的实践建议。

7 分钟阅读 更新于 2026-07-28
本文目录

测试不是越多越好,而是在合适的层用合适的替身。全 Mock 的路由测试可能永远绿却上线就挂;事事连真 PostgreSQL 的套件又慢又脆。FastAPI 项目天然分成 API、Service、Repository 几层(下一篇细讲),测试也可以对齐:纯逻辑用单元测试 + Stub,编排与 SQL 用集成测试,少量端到端 TestClient 兜住 contract。这篇讲 Mock/Stub 区别、集成测试放哪、以及和 dependency_overrides 怎么配合。

单元测试 vs 集成测试

类型测什么外部依赖速度
单元测试函数/类的输入输出应隔离(Fake/Stub)
集成测试多层协作、SQL、消息真 DB 或 testcontainer较慢
API/契约测试HTTP 状态码与 Body 形状常 override DB中等

单元测试不启动完整 HTTP,直接调 Service 方法,断言返回值或抛出的领域异常。集成测试让 Repository 打真库(或 SQLite 内存),验证约束、事务、查询是否正确。TestClient 适合验证路由 wiring、校验器、统一错误体——不必在每个 API 测试里塞满业务分支。

问自己三个问题决定测试类型:失败时谁背锅(SQL 写错 vs 规则写错 vs 路由没接上)、替换成本高吗(Mock 整个支付网关 vs 内存 dict)、运行要多快(pre-commit 要秒级)。答案指向单元、集成或少量 E2E 的组合,而不是「一律 TestClient」。

Mock 与 Stub:别混为一谈

  • Stub:提供固定、简化的实现(内存仓库、返回常量的 Fake 邮件服务),测试主动选用。
  • Mock:记录「被怎么调用」,并常设定期望(assert_called_once_with);关心交互而非只关心结果。
python
from unittest.mock import Mock

def test_notify_on_ship(service: OrderService, mail_stub: FakeMailer) -> None:
    service.ship_order(order_id=1)
    assert mail_stub.sent[-1].subject == "Your order shipped"


def test_retry_calls_gateway_exactly_three_times() -> None:
    gateway = Mock()
    gateway.charge.side_effect = [TimeoutError(), TimeoutError(), None]
    billing = BillingService(gateway=gateway)
    billing.charge_with_retry(amount=100)
    assert gateway.charge.call_count == 3

Service 业务规则用 Stub/Fake 更读得懂;只有当你必须断言「有没有调外部 API、调了几次」时再用 Mock。过度 Mock 会导致测试与实现细节绑死——重构函数签名全红,却测不出行为对不对。

Fake 往往有可行实现(内存 dict 当 DB);Stub 返回 canned 值;Mock 验证调用。FastAPI 项目里 Fake Repository 实现 OrderRepository Protocol,比 Mock session.query 更稳。

FastAPI 里优先 override,而不是 patch 路由内部

unittest.mock.patch("myapp.routes.SessionLocal") 能工作,但 fragile:路径字符串、import 顺序一改就失效。更稳的是 dependency_overrides(依赖注入篇)——生产与测试共用同一路由,只换 get_dbget_mail_service 的实现。

python
class FakeMailService:
    def __init__(self) -> None:
        self.outbox: list[dict[str, str]] = []

    def send(self, to: str, subject: str, body: str) -> None:
        self.outbox.append({"to": to, "subject": subject, "body": body})


def get_mail_service() -> FakeMailService:
    return FakeMailService()


def test_welcome_email(client: TestClient) -> None:
    fake = FakeMailService()
    app.dependency_overrides[get_mail_service] = lambda: fake
    try:
        client.post("/users", json={"email": "a@b.com"})
        assert len(fake.outbox) == 1
    finally:
        app.dependency_overrides.clear()

Repository 层集成测试则不要 Mock SQLAlchemy Session——用 rollback fixture 或 SQLite 测 CRUD 与唯一约束。

pytest-mockmocker.patch 与标准库 patch 相同,fixture 自动 teardown。仍应优先 overrides;patch 适合测「第三方 SDK 在 adapter 内被正确调用」这类边界 adapter 单测,而不是 patch 路由模块里的全局变量。

测试金字塔在 API 项目里的落地

建议比例直觉(按用例数,非教条):

        / E2E 少量 TestClient 主路径 \
       /  集成:Repository + 真库     \
      /   单元:Service 规则、纯函数    \
     ------------------------------------
  • 多写:Service 里价格计算、库存扣减、状态机非法转移。
  • 适量写:Repository 插入冲突、分页、软删过滤。
  • 少写但要有:登录后创建资源、统一 422/404 envelope、关键 POST 的 happy path。

反模式:每个路由测试都 Mock 整个 Service——那只是在测「路由有没有调 service.method」,价值低;要么测 Service 本身,要么用 Fake Service 测路由到 HTTP 的映射。

双测关键路径:例如库存不足——OrderService 单测断言 InsufficientStockError;一条 TestClient 测 POST /orders 返回 409 且 error.code 正确。前者快且 pinpoint,后者保证 handler 与路由接好。

哪些外部系统要 Fake

依赖单测集成
业务 DBFake Repository / 内存真 test DB
Redisfakeredis 或 dict可选 test 实例
第三方支付Stub 固定响应沙箱环境偶测
对象存储本地 temp 目录 FakeMinIO 容器可选

文件上传(系列第 14 篇)测试应用 SpooledTemporaryFile 或 bytes IO,不要每次 CI 都连云 OSS。

测试数据与工厂

固定 fixture 数据(tenant_a 用户、sku-1 商品)放在 tests/factories.py 或 pytest fixture,避免每个测试 copy 大段 dict。Factory 函数返回 ORM 或 domain 对象,Repository 集成测试先 save 再断言。随机数据用 faker,但断言应用已知 seed 或只断言结构。

CI 分层执行

阶段命令内容
PR 快检pytest -m "not integration" -q单元 + 轻量 API
main 夜间pytest -q含 testcontainers
可选contract / openapi diff破坏性变更

失败 flaky 集成测试会侵蚀信任;先修隔离与 rollback,再堆用例数。

契约测试与消费者驱动

若移动端与 Web 共用 API,可维护一份 golden JSON fixture:成功与各类 error 的样例 body。TestClient 测关键 path 的 response 是否仍包含必需字段。不必 snapshot 整个 body——字段增删用 schema 校验工具(如 jsonschema)更稳。Service 层单测保证规则;契约测试保证对外形状未意外破坏。

何时值得写 E2E

真 E2E 起 TCP、连 Docker Compose 全套,慢且脆,留几条 smoke:部署后 health、登录、下单主路径。日常开发靠单元 + TestClient + Repository 集成。回归发布前 nightly 跑 E2E 即可,不要每次 commit 都跑。

命名与可读性

测试名应描述行为:test_place_order_raises_when_stock_emptytest_order_3 好。Arrange-Act-Assert 三段空行分开,读 case 像读 spec。数据准备用 factory 而非 50 行 inline dict,除非测的就是复杂 payload 校验。

回归与变更策略

改 error envelope 时,先改 handler 单测,再批量改 API 测试断言。改 Service 规则时,只跑 services 目录下 pytest 目标路径,反馈更快。CI 应用 --durations=10 找最慢十个用例,优化集成 setup 而非砍必要覆盖。

记录决策 ADR 式说明

当团队选择「Repository 全 integration、Service 全 unit」时,写短 ADR 说明原因,新人不会误改成全 Mock。测试策略也是架构的一部分,随项目阶段调整—— MVP 可少 E2E,支付上线前加沙箱集成。

常见误解

「集成测试 = TestClient」。 TestClient 是 HTTP 层;Repository 直接测 Session 也是集成,往往更 pinpoint。

Mock 越细越好。 只 Mock 边界(邮件、支付),不要 Mock 被测对象自己的 collaborator 的内部私有方法。

与生产共库。 测试库独立 schema 或 SQLite 文件;并行 CI job 共库会 flaky。

不测异常路径。 InsufficientStockError 映射 409 应在 Service 单测 + 可选一条 API 测试各覆盖一次。

维护测试像维护产品

测试代码也会腐化:复制粘贴 fixture、sleep 等待异步、依赖执行顺序。review 测试 PR 与生产 PR 同样严格。删掉测实现细节而非行为的用例。定期跑全量套件, flaky test 要么修要么删——留着的 flaky 会让团队忽略红 build。

测什么想清楚之后,代码结构也要能支撑——下一篇把 API、Service、Repository 三层怎么拆讲清楚。

衡量测试价值问一句:这条用例红了能否指向具体层?若 Mock 过深导致红在 mock 期望而非业务,就重写。Stub Repository 让 Service 测试红在规则本身,价值最高。集成测试红在 SQL 或约束,也清晰。只有 E2E 全绿而上线仍挂时,才怀疑 Mock 边界或环境差异——回头补 Repository 集成或 staging 沙箱。

测试方案没有银弹,只有与分层架构对齐的默认选择:单元测规则,集成测 IO,TestClient 测契约。随团队成熟再调比例,但别用「没时间」砍掉 Service 单测——那层最便宜、最防回归。

小结

单元测试保护 Service 规则,集成测试保护 Repository 与 SQL,TestClient 保护 HTTP 契约。优先 dependency_overrides 而非 patch;Mock 用于验证调用次数,Stub/Fake 用于替换 IO。按金字塔分配用例,flaky 测试及时修或删。

新功能开发顺序可以是:先写 Service 单测定义规则,再实现 Repository 集成测,最后补一条 TestClient happy path。TDD 不必教条,但「至少一层 automated test 先于 merge」应成团队底线。Code review 看到无测试的 Service 变更,要求补 Stub 单测或说明为何不可测——不可测往往是设计问题,例如逻辑写死在路由里。

对外 API 版本升级时,旧版 error schema 的测试可保留一版直到客户端迁移完成,避免 silent break。

测试策略随产品阶段演进:MVP 偏 TestClient,成长期补 Service 单测与 Repository 集成,规模化再加 contract 与 nightly E2E。

Review 时问:这条测试若删除,哪类 bug 会溜进生产?答不上来就可以删或重写。测试是为了信心,不是为了行数。

默认选择:能 Stub 就不 Mock,能单测就不全靠 E2E。