测试层级与测试边界
用三条互不混淆的轴理解测试:层级(Unit/Integration/E2E)、目的(Regression/Characterization/Contract)与手段(Fixture/Fake/Stub/Mock),并建立选择测试边界的最小心智模型。
[!info] related notes
- 所属 MOC: testing-moc
- 相关概念:
- 易混淆概念:
- 相关资源:
测试层级与测试边界
这篇笔记解决什么问题
刚开始学测试时,最容易把不同维度混成一团:
- Unit / Integration / E2E 像是在说“测试类型”
- Regression / Characterization / Contract 也像是在说“测试类型”
- Fixture / Fake / Stub / Mock 又经常和它们一起出现
其实它们回答的是三类不同问题:
[!abstract] 最小心智模型
- 层级:一次测试覆盖多少真实系统?
- 目的:为什么要写这个测试?
- 手段:为了稳定、快速地测试,如何准备或替换依赖?
只要先把这三条轴分开,后面的 pytest、Go testing、Vitest、JUnit 都只是不同语言里的具体实现。
第一条轴:测试层级
测试层级关心的是:一次测试穿过多少真实组件。
| 层级 | 主要验证 | 常见特点 |
|---|---|---|
| Unit | 一个小单元的行为 | 快、依赖少、定位清楚 |
| Integration | 多个真实组件能否协作 | 更接近真实边界,成本更高 |
| E2E | 用户关键路径能否从头走到尾 | 最真实,也最慢、最脆弱 |
Unit Test
Unit Test 关注一个足够小、边界清楚的行为,例如:
输入
↓
DiagnosisReadinessPolicy
↓
ready / not ready
它不要求“一定只能测一个函数”。真正重要的是:测试边界小、失败原因清楚、外部依赖尽量少。
Integration Test
Integration Test 关注真实组件之间的协作,例如:
DiagnosisRepository
↓
PostgreSQL
或者:
FastAPI Route
↓
Pydantic Request Model
↓
Service Adapter
它的价值是发现单个组件各自正确、组合后却出错的问题。
E2E Test
E2E 从系统最外层验证关键用户旅程,例如:
React
↓
Go API
↓
Python AI Service
↓
Database
↓
React 展示结果
E2E 很有价值,但不适合承担所有规则验证。能在更低层稳定验证的业务规则,通常不要全部推到 E2E。
第二条轴:测试目的
测试目的回答的是:为什么要写这条测试。
Regression Test
Regression Test 用来防止已经正确的行为被后续改动破坏。
历史 bug 修复后补一条测试,就是最典型的 regression protection。
Characterization Test
Characterization Test 用来记录并保护当前系统已经存在的行为,尤其适合重构旧代码。
它不先判断当前设计是不是最优,而是先回答:
系统今天到底怎么工作?
例如迁移 Diagnosis 到 PydanticAI 之前,可以先锁住:
DiagnosisService 抛 ValueError
↓
FastAPI
↓
HTTP 422
这样重构后测试失败时,才能区分:
- 我们有意修改了 contract
- 还是重构不小心破坏了旧行为
[!important] Characterization 不是第四个测试层级 它描述的是“测试目的”。一条 characterization test 本身可以是 unit、service、HTTP adapter,甚至 integration test。
Contract Test
Contract Test 保护两个组件之间稳定的交互约定,例如:
Go
↕ HTTP JSON
Python
它关心请求字段、响应结构、状态码、事件 schema 等调用方真正依赖的边界,而不是 provider 内部怎么实现。
第三条轴:测试手段
为了让测试更快、更稳定,我们经常需要准备数据或替换依赖。
Fixture
Fixture 负责准备测试运行需要的上下文,例如:
- HTTP test client
- 测试用户
- 临时数据库
- 初始化后的 service
它更像“测试环境与依赖的准备方式”。
Stub
Stub 预先给出简单返回值,用来控制某个依赖的输入输出。
调用 knowledge service
↓
固定返回两条结果
重点是“给测试提供确定数据”。
Fake
Fake 是一个能工作的简化实现,例如:
Real DiagnosisService
↓ 测试中替换
Fake DiagnosisService
Fake 往往比纯 Mock 更接近真实对象,但不会访问昂贵或不稳定的外部系统。
Mock
Mock 更强调交互验证,例如:
是否调用了 repository.save
调用了几次
参数是什么
如果测试大量依赖内部调用次数,很容易和实现细节耦合,因此应克制使用。
monkeypatch 是工具,不是测试类别
pytest 的 monkeypatch 只是帮助你在测试期间临时替换对象:
monkeypatch.setattr(
"src.api.routes.diagnosis.get_diagnosis_service",
lambda: fake,
)
这里真正的设计是:
我只想测试 FastAPI Route
↓
把真实 DiagnosisService 换成 Fake
monkeypatch 只是实现这个替换动作的 Python 工具。
同一个测试可以同时落在三条轴上
以 Diagnosis HTTP 测试为例:
client.post("/api/diagnosis/analyze")
↓
FastAPI Route
↓
Fake DiagnosisService
可以同时描述为:
- 层级:HTTP adapter / 小范围 integration
- 目的:characterization + contract protection
- 手段:Fake + monkeypatch
所以不要问:
Characterization Test 和 Integration Test 哪个更高级?
它们根本不在同一条轴上。
怎么选择测试边界
写测试前先问四个问题:
- 我真正想保护的可观察行为是什么?
- 这个行为能不能在更低、更快的层验证?
- 哪些真实依赖必须保留,哪些可以替换?
- 失败时,我能不能快速知道是哪一层出了问题?
通常更好的测试不是“最接近真实系统”,而是在足够真实的前提下,仍然保持快速、确定、容易定位。
不要重复测试框架本身
如果 Zod / Pydantic 已经定义:
confidence ∈ {高, 中, 低}
通常没必要逐字段证明“Zod/Pydantic 会不会做 enum validation”。
更值得测试的是:
- 你的 schema 是否表达了正确的业务约束
- validation 失败后系统如何处理
- HTTP / service contract 是否保持稳定
测试应该保护你的业务和边界,而不是替第三方框架重复它自己的测试套件。
从 0 到 1 的最短路径
测试为什么存在
↓
Unit / Integration / E2E
↓
亲手写一个最小测试
↓
Fixture / Fake / Mock
↓
Characterization / Contract
↓
测试设计与 TDD
语言只是外壳:
- Python → pytest
- Go → testing
- TypeScript → Vitest / Jest
- Java → JUnit
底层思维基本相同。
最短记忆方式
层级决定测多大,目的决定为什么测,替身决定怎么隔离。
把这三件事分开,测试体系就不会乱。