测试层级与测试边界

用三条互不混淆的轴理解测试:层级(Unit/Integration/E2E)、目的(Regression/Characterization/Contract)与手段(Fixture/Fake/Stub/Mock),并建立选择测试边界的最小心智模型。

#type / synthesis #status / growing #tech / dev / test

[!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 哪个更高级?

它们根本不在同一条轴上。

怎么选择测试边界

写测试前先问四个问题:

  1. 我真正想保护的可观察行为是什么?
  2. 这个行为能不能在更低、更快的层验证?
  3. 哪些真实依赖必须保留,哪些可以替换?
  4. 失败时,我能不能快速知道是哪一层出了问题?

通常更好的测试不是“最接近真实系统”,而是在足够真实的前提下,仍然保持快速、确定、容易定位。

不要重复测试框架本身

如果 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

底层思维基本相同。

最短记忆方式

层级决定测多大,目的决定为什么测,替身决定怎么隔离。

把这三件事分开,测试体系就不会乱。

创建于 2026/8/14 更新于 2026/8/14