"测试写了一千多个,线上还是出事"——这通常不是写得不够,而是写得不是地方。单测一千个覆盖的是你自己写的分支,集成测试缺位意味着依赖之间的假设没人验,端到端又慢又脆所以没人跑,逃逸缺陷恰好从这三层的缝里漏过去。测试分层的本质是预算分配:每一层的反馈速度、稳定性、能证明什么,差着数量级。钱花错层,写多少都不够。
1. 四层的成本曲线
| 层 | 单次反馈时间(典型量级) | 稳定性 | 能证明什么 | 单次维护成本 |
|---|---|---|---|---|
| 单元测试 | 毫秒级 | 高,无外部状态 | 分支与边界逻辑正确 | 低,改实现才动 |
| 集成测试 | 秒级到十秒级 | 中,受容器/网络影响 | 组件间的协议与事务真实成立 | 中,依赖版本变动即动 |
| 契约测试 | 十秒级 | 高,双方各自跑 | 提供方与消费方对接口的理解一致 | 中,接口变更是触发器 |
| 端到端 | 分钟级 | 低,最易 flaky | 关键路径在真实拓扑下走得通 | 高,环境一变就修 |
这张表的方向性结论很稳定:越往上,能证明的事越接近用户,但反馈越慢、越脆、越贵。所以分层不是"每层都写一点",而是"每一类风险交给最便宜的那一层去证明"。
2. 比例不能套公式
流传的"70/20/10"只是某类单体 Web 应用的形态快照,不是定律。分布应该由系统形态和变更热点推出来:
判断顺序
1. 变更热点在哪 git 历史里改动最频繁的目录 → 该处单测密度最高
2. 哪些假设最脆 跨进程、跨团队、跨存储的假设 → 集成/契约覆盖
3. 哪些路径不可逆 支付、删除、发信、发布 → 端到端兜一条
4. 剩下的 不写。没有风险的代码不配预算
| 系统形态 | 应加重的层 | 理由 |
|---|---|---|
| 纯计算/算法库 | 单元测试 | 无依赖,纯函数最便宜 |
| 单体 + 单库 | 单元 + 少量集成 | 事务边界在进程内可测 |
| 多服务 + 多团队 | 契约测试 | 接口演进是主要故障源 |
| 强前端交互 | 组件测试 + 少量端到端 | DOM 断言比全链路快一个量级 |
| 数据管道 | 集成 + 数据校验 | 逻辑常藏在 SQL 与调度里 |
3. 测试能保护什么,保护不了什么
| 能保护 | 机制 | 保护不了 | 为什么 |
|---|---|---|---|
| 回归 | 已有行为被改动时报警 | 需求是否正确 | 测试只验证"按写的跑" |
| 设计反馈 | 难测=耦合,逼你拆边界 | 性能与容量 | 单次执行证明不了分布 |
| 活文档 | 用例即行为说明 | UI 感受与可用性 | 断言不了"顺手" |
| 重构安全网 | 结构改动后行为不变 | 数据迁移正确性 | 需真实数据才有意义 |
把测试当"质量证明"是分层策略里最贵的误解:它证明的是你写下的假设仍然成立,不是产品是对的。需求错了,测试会稳定地保护这个错误。
4. 集成测试:真实依赖还是替身
| 选择 | 得到 | 失去 | 适用 |
|---|---|---|---|
| 真实依赖(容器起库/中间件) | 方言、事务、序列化真实 | 启动慢、环境漂移、端口冲突 | 事务边界、SQL、消息语义 |
| 替身(内存实现/mock) | 快、可控、易造异常 | 真实行为差异被隐藏 | 分支与错误处理路径 |
| 录制回放 | 真实流量形态 | 过期流量会误导 | 外部第三方接口 |
关键判据只有一句:被替身替换掉的那部分,正是这个测试想证明的东西吗? 如果测试目的是"SQL 写对了",用内存库替身等于什么都没测;如果目的是"超时分支怎么处理",真实库反而是噪声。
5. 契约测试解决的是接口演进
# consumer 侧声明自己真正用到的东西
consumer: order-service
provider: inventory-service
interactions:
- request: { method: GET, path: /stock/SKU-1 }
response:
status: 200
body: { available: true, reserved: 0 }
契约测试的价值不在断言,而在把"消费方实际依赖的字段"变成可执行文件。提供方改字段时,CI 立刻知道谁会被打断。它解决不了的是:契约没写的隐式约定(时区、编码、空值语义),这些仍需集成测试兜。
实施成本主要在组织侧:契约要有人维护、要能定位归属、要有版本对应关系。跨团队推不动时,往往不是工具问题,而是没人愿意为"别人的构建"负责。
6. 端到端只该覆盖不可逆与最关键
端到端的合理范围很小:
| 该覆盖 | 不该覆盖 |
|---|---|
| 支付/扣款/退款 | 每一个表单校验分支 |
| 注册 → 首单 → 收货 | 各种错误提示文案 |
| 发布/部署后的冒烟 | 分页、排序、筛选组合 |
| 权限越界的少数关键组合 | 全部角色 × 全部资源矩阵 |
判据是出错后能否用钱或时间修回来。可逆的错误交给下层;不可逆的才值得付端到端的分钟级代价。端到端套件超过二十条,通常已经在替集成测试干活,且干得更差。
7. 测试数据的三种策略
# 工厂:显式构造,只声明本用例关心的字段
order = OrderFactory.create(status="PAID", amount=Decimal("99.00"))
# 其余字段由默认值兜底,读用例时不必理解十个无关参数
| 策略 | 优点 | 坑 |
|---|---|---|
| 固定夹具(fixture 文件) | 稳定、可 review | 与 schema 脱节后变成假数据 |
| 工厂/构造器 | 用例意图清晰、易变体 | 默认值漂移会让断言变脆 |
| 生产快照脱敏 | 分布真实,能撞出脏数据问题 | 脱敏规则漏项、快照过期、体积大 |
生产快照最大的风险不是隐私,是你以为它代表现在。快照一旦变成"上次导出的那份",它测的就是历史。用它时必须配一个"快照年龄"检查。
8. 不稳定测试:隔离,而不是重试
# CI 里重试只允许作为观测手段,不能作为通过手段
test:
retries: 0
quarantine:
label: flaky
action: exclude_from_gate # 移出准入门禁,单独跑并统计
owner_required: true
| 根因类别 | 表现 | 处理 |
|---|---|---|
| 时间依赖 | 跨零点、时区、sleep 断言 |
注入时钟,禁用真实等待 |
| 顺序依赖 | 单跑过、全跑挂 | 每例自建状态,禁止共享可变夹具 |
| 并发/端口 | 随机失败、本机不复现 | 动态端口、串行化共享资源 |
| 外部服务 | 网络抖动 | 该层改用替身或录制回放 |
重试是通过率的美容术:它把 3% 的真实缺陷藏成 0.1% 的日志噪声。治理顺序应该是先分类,再决定留不留——分类不了的 flaky 测试,价值通常低于它的维护成本。
9. 覆盖率的正确用法
覆盖率是发现盲区的探照灯,不是质量指标。行覆盖高但断言为空,等于执行过没验证过;分支覆盖能暴露"异常分支从未走过",这才是它真正有用的地方。
| 用法 | 有效 | 失效 |
|---|---|---|
| 找从未执行的代码 | 有效,指向死代码或漏测 | — |
| 当团队 KPI | — | 会催生无断言测试与 assertNotNull |
| 卡合并门禁 | 对新代码有效 | 全量阈值会被历史代码拖死 |
| 比较两个模块 | 参考 | 业务复杂度不同,不可比 |
一旦覆盖率进考核,它衡量的就是"测试代码行数",而不是风险覆盖。这个转变通常在一个季度内发生。
10. 与技术债、发布节奏的关系
变更热点优先,是补测试债唯一划算的顺序:
# 近半年改动最多的文件,测试债该按这个顺序还
git log --since="6 months ago" --name-only --pretty=format: \
| grep -v '^$' | sort | uniq -c | sort -rn | head -20
| 发布节奏 | 测试投入的回收方式 |
|---|---|
| 日更多次 | 单测与契约先行,端到端只做冒烟 |
| 周更 | 集成测试可在合并前跑全量 |
| 月更/季度 | 端到端可以更重,人工回归仍占一部分 |
| 强监管发布 | 证据链优先,测试即审计材料 |
快速迭代下,"补全测试"往往永远排不上;而"改这个文件前先补它的测试"能排上。差别在于后者绑定了一次真实变更。
11. 可度量
| 指标 | 口径 | 用途 |
|---|---|---|
| 逃逸缺陷率 | 上线后发现 ÷ 总缺陷 | 分层是否有洞 |
| 测试套件时长 | PR 门禁 P50/P95 | 超阈值就开始被绕过 |
| 缺陷平均修复时间 | 从报警到恢复 | 可观测与测试的共同结果 |
| flaky 比例 | 非确定性失败 ÷ 总运行 | 高于个位数就该治理 |
套件时长有一个隐性阈值:超过开发者愿意等的时长,他们就开始本地跳过、CI 拆分、合并时抢跑。这个阈值因团队而异,但被绕过之后,覆盖率再高都没意义。
12. 排错表
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 单测全绿、联调就挂 | 依赖用替身,真实行为没验 | 关键边界补集成测试 |
| 契约测试通过但线上 500 | 契约只覆盖显式字段 | 补隐式约定(空值、时区) |
| 端到端经常红,重跑就绿 | 时序/共享环境 | 隔离数据、动态端口、去 sleep |
| 覆盖率 85% 仍频繁出事 | 断言稀薄、只覆盖主路径 | 改看分支覆盖与逃逸缺陷 |
| 没人愿意写测试 | 套件太慢、夹具难用 | 先砍时长,再谈覆盖率 |
13. 边界
| 情形 | 判断 | 理由 |
|---|---|---|
| 一次性脚本/迁移工具 | 不写单测,写校验脚本 | 生命周期短于测试维护期 |
| 原型/探索阶段 | 只写关键断言 | 需求还在变,测试会先死 |
| 强依赖外部系统 | 录制回放 + 人工验证 | 无法控制对端行为 |
| 纯 UI 观感 | 截图比对 + 人看 | 断言不了"好不好看" |
| 遗留系统无测试 | 只在新改动处补 | 全量补测的回收期过长 |
分层策略最终要回答一个问题:这次改动最可能错在哪,哪一层能用最小代价发现它。答不上来时,多写测试只是把不确定性往后推。
14. 自测
- 你的端到端套件里,有几条是"不可逆操作"?其余的在替谁干活?
- 集成测试用替身的那部分,是否正好是它想证明的东西?
- 契约里没写的隐式约定(空值、时区、编码),现在由哪一层兜着?
- 套件时长超过多久,你的团队就开始绕过门禁?
- 覆盖率如果明天变成 KPI,你的测试会怎么变形?
- 上一次逃逸缺陷,本该被哪一层拦住?为什么没拦住?