两个测试都在测“创建计划”,为什么一个还不够¶
假设核心规则把 8 小时错误地分成了 9 小时。直接调用 build_plan 的测试会精确
告诉你规则错了。再假设核心规则正确,但 FastAPI exception handler 把
PlanConflict 变成了 500。直接调用 core 的测试看不到这个问题。
同一条用户路径需要两种证据,不是因为“测试越多越安心”,而是两种缺陷只有跨过 不同边界才可见。
概念|单元测试 在外部依赖受控的条件下,直接验证一个聚焦的行为边界。边界可以包含几个小型 协作对象,不由函数或文件数量决定。
概念|集成测试 让目标真实组件共同参与,验证它们之间的协作契约;不相关的数据库或公网服务 仍然可以保持受控。
上一章的验证清单最后一列已经填了“单元”和“集成”,但只说了两者各自的用途:单元 直接调用 core 且依赖受控,集成让真实 FastAPI 组件共同参与。本章补上判定依据—— 边界该划到多大、凭什么为一项风险选择层级,以及两层各自应该断言什么。
运行环境¶
示例已在 Python 3.12.3、pytest 9.1.1、FastAPI 0.115.14、Pydantic 2.13.4 与 HTTPX 0.28.1 验证。本章同时展示同步和异步的进程内 HTTP 测试入口,具体工具在 对应小节出现时说明。
1. 先问“这个边界能看见哪种错误”¶
概念|测试边界 为目标风险选择能够观察它的最小真实范围。边界越大不一定证据越强,关键是目标 缺陷是否可见,以及失败后能否定位。
考虑两项风险:
| 缺陷 | 直接调用 core | 通过 FastAPI 请求 |
|---|---|---|
| 计划时长规则错误 | 可见,定位精确 | 可见,但链路更长 |
| 业务错误被映射成 500 | 不可见 | 可见 |
这比“单元测试快,集成测试慢”更接近本质。速度重要,但测试层级首先决定被测 系统包含哪些真实组件、哪些依赖受控,以及目标缺陷是否能被观察。
2. 单元测试包住一个行为,不一定只包一个函数¶
上一章说单元是“直接调用 core、依赖受控”的位置,那是它的用途;这里补上它的 范围——单元不等于一个函数。假设 core 接收一个可控 adapter:
from dataclasses import dataclass
@dataclass(frozen=True)
class Plan:
topic: str
weekly_hours: int
class PlanConflict(Exception):
pass
def build_plan(topic: str, weekly_hours: int, existing_topics: set[str]) -> Plan:
if topic in existing_topics:
raise PlanConflict(topic)
return Plan(topic=topic, weekly_hours=weekly_hours)
一个聚焦的单元测试可以直接证明业务规则:
import pytest
def test_build_plan_rejects_existing_topic():
with pytest.raises(PlanConflict):
build_plan("pytest", 8, {"pytest"})
这里的“单元”是 build_plan 所代表的小型行为边界。未来它可能拆成两个 helper,
只要公开行为不变,测试不必跟着私有结构重写。单元大小由行为与诊断价值决定,
不是由文件数量决定。
3. 集成测试让真实 FastAPI 组件共同工作¶
上一章说集成是“让真实 FastAPI 组件共同参与”,具体到 HTTP 层,就是路由匹配、 请求校验、handler 和响应序列化都要真实参与:
概念|进程内 ASGI 测试客户端 不监听真实端口,但让请求经过真实 ASGI 应用的测试入口。同步场景可使用
TestClient;需要在测试中直接await时可使用ASGITransport与AsyncClient。
同步入口:让请求经过真实应用¶
from fastapi.testclient import TestClient
def test_create_plan_returns_conflict(client: TestClient):
client.post("/plans", json={"topic": "pytest", "weekly_hours": 8})
response = client.post(
"/plans", json={"topic": "pytest", "weekly_hours": 8}
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "plan_conflict"
测试签名里的 client 不是全局变量,也不是测试自己造的对象。这里先只需要知道:
pytest 会按参数名把已经准备好的对象交给测试,client 代表一个装好真实应用的
进程内 HTTP 客户端。准备函数怎样声明、对象能被多少测试共用、测试结束后怎样
清理,都留到 W01-L04-P04;下面 async 示例里的 app 参数同理。
TestClient 不需要真的监听端口,但请求仍经过 ASGI 应用。它能够发现:
- 路由是否注册;
- Pydantic 是否正确绑定并校验请求;
- 领域错误是否被 handler 转换;
- 响应模型是否按公开契约序列化。
直接调用 route 函数并手工传入模型会绕过部分框架行为;让一个 mock client 直接 返回预制 409,更是让替身提前给出了正确答案,不能证明真实集成。
异步入口:调用同一应用,但不代管 lifespan¶
如果测试本身必须是 async,可使用:
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.mark.anyio
async def test_create_plan_success(app):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/plans", json={"topic": "pytest", "weekly_hours": 8}
)
assert response.status_code == 201
一个重要边界:HTTPX 的 ASGITransport 不负责触发应用 lifespan。若测试依赖
startup/shutdown,需要显式管理 lifespan;不能因为路由测试通过就推断启动资源
已经验证。本课的最小示例不依赖 lifespan。
4. 两层不是把同一张卷子复印两遍¶
对冲突路径,可以这样分工:
| 层级 | 主要问题 | 主要 oracle |
|---|---|---|
| core 单元 | 已存在 topic 是否被拒绝 | 抛出具体 PlanConflict,状态不变 |
| HTTP 集成 | 领域错误是否正确公开 | 409,公开错误代码与响应结构 |
单元层不需要断言 HTTP 状态码;core 不认识 HTTP。集成层也不必复制 core 的每个 边界组合,它更关注组件契约。这样任一层失败时,信号更有诊断价值。
“最低充分层级”意味着:优先在能够发现目标缺陷的最小边界验证,同时保留必要的 真实集成。两个极端都不好:所有测试都 mock 会失去兼容性证据;所有测试都启动 完整系统会变慢、变脆,也更难定位。
常见误区¶
- 单元就是一个函数。 行为边界可能包含小型协作单元;关键是依赖是否受控、 失败是否聚焦。
- 集成就是使用了 client。 如果 client 或 route 被整体替身化,真实组件并未 协作。
- 两层要覆盖完全相同的输入。 这会制造重复而不是互补证据。
- 连接越多组件越真实。 本课需要真实 FastAPI 边界,不需要 PostgreSQL 或公网。
本章小结¶
测试层级由风险可见性决定。core 单元在受控依赖下精确证明业务规则;进程内 ASGI 集成让真实 FastAPI 组件协作,证明 HTTP 契约。两层应使用不同目的和 oracle, 共同覆盖同一路径的规则风险与兼容风险。
练习¶
请为以下风险选择测试位置,并说明另一层为什么不充分或不必重复:
weekly_hours=8被算法错误地分配为 9 小时。- 请求 JSON 中
weekly_hours="eight"被接受。 PlanConflict被 handler 返回为 500。- 响应意外泄露内部字段
debug_notes。
练习解析¶
- 主要放 core 单元层,直接断言时长不变量,定位最精确;HTTP 层只需代表性成功 案例,不必复制全部算法边界。
- 放 HTTP 集成层,因为字符串到整数的请求绑定与 Pydantic 校验属于框架边界。
- 放 HTTP 集成层;core 单元可以证明抛出
PlanConflict,却看不到 HTTP 映射。 - 放 HTTP 集成层,因为 response model/序列化是否过滤内部字段需要真实响应链。