本文目录
测试不是越多越好,而是在合适的层用合适的替身。全 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);关心交互而非只关心结果。
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 == 3Service 业务规则用 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_db、get_mail_service 的实现。
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-mock 的 mocker.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
| 依赖 | 单测 | 集成 |
|---|---|---|
| 业务 DB | Fake Repository / 内存 | 真 test DB |
| Redis | fakeredis 或 dict | 可选 test 实例 |
| 第三方支付 | Stub 固定响应 | 沙箱环境偶测 |
| 对象存储 | 本地 temp 目录 Fake | MinIO 容器可选 |
文件上传(系列第 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_empty 比 test_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。