测试金字塔:一笔关于成本、速度与信心的账
「测试金字塔」这张图你大概见过:底下一大层单元测试,中间一层集成测试,顶上一小撮端到端。很多人把它当成一种等级制 —— 好像端到端更「高级」。完全反了。这张图不讲高低,它讲的是预算:每一层的测试,成本、速度、能给你的信心都不一样,而你手里的「反馈时间」是有限的。金字塔是把这笔账花得最划算的那个形状。这一章用一个计算器,把这笔账算给你看 —— 顺便把单元、集成、端到端这三个名字,一次钉死。
三层,分别是什么
先把三个名字对齐。区分它们的,是「一个测试覆盖多大范围」 —— 范围越大,越慢、越贵、越脆,但也越接近「用户真实体验到的东西」。
- 单元测试(unit):测一小块逻辑,把它和外界(数据库、网络、其他模块)隔离开。范围最小,毫秒级。一个纯函数、一个 ViewModel 的状态计算。
- 集成测试(integration):测几个部件接在一起能不能协同 —— 你的代码 + 真实的数据库、你的代码 + 真实的 JSON 解析。范围中等,百毫秒级。测的是接缝(第 6 章)。
- 端到端(end-to-end / e2e):像用户一样,从 UI 点进去,穿过整个系统,看结果。范围最大,秒级甚至分钟级。测的是关键旅程真的能走通(第 7 章)。
注意这是一条连续谱,不是三个抽屉。中间还有各种叫法(组件测试、契约测试、功能测试),不同团队边界画得不一样。别纠结名词,抓住那根轴:范围越大 → 越慢越贵越脆,但越像真实。
一笔账:成本、速度、信心
为什么是「底大顶小」?因为三层的单价差着数量级。下面这个计算器,给每层一个真实量级的单测耗时(单元 6ms、集成 180ms、端到端 3.2s),拖动数量,看整套测试跑一遍要多久。
用默认值(800 单元 / 120 集成 / 15 端到端)算一下,结论很扎眼:
端到端测试只占测试数量的 1.6%,却占了整套跑一遍时间的 65%。反过来,800 个单元测试占了 85% 的数量,却只花几秒。
这就是金字塔底大顶小的全部理由:把大部分验证放在又快又便宜的那一层,只在最关键的几条路径上,花那个昂贵的端到端。
为什么速度是一切
你可能会想:慢一点怕什么,让 CI 慢慢跑呗。问题是,一套慢测试会死掉。
把计算器里的数字往上堆,当整套时间越过 1 分钟,面板会变红并提醒你一句话 —— 这句话是整章的关键:
测试的价值,来自你频繁地跑它 —— 最好每改一点就跑。如果它要跑 10 分钟,你就不会在本地跑,只会推上去等 CI;等 CI 的时候你去干别的了,回来上下文全丢了;久而久之,红了你也懒得看。一套没人跑的测试,等于没有测试。
所以「快」不是锦上添花,是测试能不能活下去的生死线。而快的唯一办法,就是把绝大多数验证压到单元层。
这也是为什么单元测试要「多」:它足够快,快到你可以写几百上千个而不心疼时间。你在这一层可以穷尽边界(空、零、负、溢出、每一个分支),因为每个只花几毫秒。
反过来会怎样:冰淇淋蛋筒
把计算器的滑块反着拖:端到端拖到很多、单元拖到很少。面板会给你标一个红色的形状名 —— 冰淇淋蛋筒(ice-cream cone),倒过来的金字塔。这是测试世界最常见的反模式。
团队为什么会滑向它?因为端到端测试写起来最"有安全感" —— 它测的是「用户真能用」,谁能反对?于是大家拼命堆 e2e,单元测试反而没人写。
后果是慢性中毒:测试套越来越慢(几十分钟)、越来越脆(UI 一动就一片红)、越来越 flaky(第 17 章)。最后没人信任它,红了第一反应是「又抽风了,重跑」。你有了一大堆测试,却失去了测试本该给你的东西:快速、可信的反馈。
金字塔不是唯一的形状:测试奖杯
金字塔是 2009 年 Mike Cohn 提的,经典但有争议。前端大牛 Kent C. Dodds 后来提了个变体,叫测试奖杯(testing trophy),把集成测试放大成主体:
| 形状 | 主力在哪层 | 适合 |
|---|---|---|
| 金字塔 | 单元 | 逻辑密集的系统(算法、业务规则、后端)。单元层能覆盖大部分风险。 |
| 奖杯 | 集成 | 「胶水」密集的系统(现代前端、很多 Android 页面)—— 代码本身没多少逻辑,风险全在「部件接起来对不对」。 |
| 冰淇淋蛋筒 | 端到端 | 没有谁「适合」。它是缺乏单元/集成测试时的自然坍缩。 |
奖杯的道理是:如果你的代码大部分是把库和框架黏起来(比如一个 Compose 页面调 ViewModel 调 Repository),那么单独测每一块的价值有限 —— 风险在接缝上,所以该在集成层多投。这不和金字塔矛盾,它俩共享同一个内核:把测试放在风险最集中、而单价又能承受的那一层。
金字塔也好奖杯也好,都是同一句话的不同画法:又快又便宜的测试,尽量多写;又慢又贵的测试,只留给最关键的路径。至于「多」的那层到底是单元还是集成,看你的代码风险长在哪。唯一确定要躲开的,是把端到端堆成主力。
AI 让这笔账更要紧
回到本书的主线。AI 会让你的代码量暴涨,也就意味着要验证的东西暴涨。这时候金字塔的经济学变得更关键:
- 你需要海量的、快到可以每次都跑的验证 —— 这只能是单元层。让 AI 写代码,同时让它(在你把关下)补大量单元测试,把金字塔的底盘铺厚。
- AI 有个隐蔽的坏倾向:你让它「加个测试」,它常常爱写端到端式的大测试(看起来更全面、更能"证明能用")。如果不加约束,AI 会把你的金字塔推成冰淇淋蛋筒。你得明确要求它「在单元层测这个逻辑」。
- 底盘厚,你才敢让 AI 大刀阔斧地重构(第 3 章)—— 因为快速的单元测试能在几秒内告诉你「行为有没有变」。慢测试给不了这种即时反馈,也就给不了这种胆量。
冰淇淋蛋筒是怎么长出来的
没有人主动决定「我们来搞个倒金字塔吧」。它是一步步滑进去的,而且每一步在当时都显得合理:
- 项目早期赶功能,没时间写单元测试 —— 「先上线,测试以后补」。
- 上线后出了几个 bug,QA 或开发补了一批端到端测试去堵 —— 因为它们最像「用户真实操作」,写起来也最"有安全感"。
- 端到端越堆越多,套件越来越慢(十几分钟起)、越来越 flaky(第 17 章)。
- 因为太慢太抖,没人在本地跑了,红了也习惯性重跑。那个「以后补」的单元测试,永远没补。
每一步都是局部最优:当下最快堵住 bug 的办法,确实是加个端到端。但这些决定复利式地把测试套推向又慢又脆又不可信。等你意识到时,重构成本已经很高 —— 上千个端到端测试盘根错节,谁也不敢动。预防远比治疗便宜:从第一天起就把逻辑往单元层放,别让「以后补单元测试」成为那个永远不来的以后。
为什么单元能穷尽,端到端不能
金字塔底大顶小,还有一个「能力」上的理由,而不只是成本:
| 单元(几毫秒/个) | 端到端(几秒/个) | |
|---|---|---|
| 测一个边界 | 再加一个测试,几乎免费 | 又是几秒 + 一次可能的抖动 |
| 测 20 个边界 | 120ms,随便测 | 1 分钟 + 铁定有 flaky |
| 诊断失败 | 红了就知道是哪块 | 红了不知道是 UI/接口/数据库/测试自己 |
所以边界穷举(第 9 章)这种事,只能在单元层做 —— 在端到端层穷举边界,又慢又抖又难诊断,根本跑不起来。端到端天生只适合「几条粗的主干旅程」,细节的对错必须下沉。这不是偏好,是物理约束。
落到 Android:每层放什么
- 单元(大量):价格计算、优惠叠加规则、表单校验、ViewModel 的状态归约。纯逻辑,JVM 上跑,穷尽边界(第 5 章)。
- 集成(适量):OrderDao 的查询、下单接口的 JSON 解析、Room 迁移。测接缝(第 6 章)。
- 端到端(极少):「加购物车 → 结算 → 支付成功」这一条主干旅程走通。别为每种优惠券组合都写一条 e2e —— 那些组合在单元层已经测过了。
一个健康的比例大概是几百个单元、几十个集成、个位数端到端。如果你的 e2e 有几十上百个,基本可以断定:大量本该在单元层测的逻辑,被错误地堆到了顶层。
1. 回到计算器,把它拖成你项目现在的大致形状。整套跑一遍多久?超过 1 分钟了吗?
2. 把它拖成冰淇淋蛋筒(比如 50 单元 / 30 集成 / 150 端到端),看整套时间和形状标签怎么变。
3. 估一下你项目里最慢的那个测试属于哪一层。如果一堆慢测试其实测的是纯逻辑,它们本该被拆到单元层。
金字塔画好了框架,接下来四章逐层填实。先从底盘那块最大、最快、最该多写的开始 —— 下一章:单元测试,「单元」到底多大,以及为什么它该在 JVM 上、离 Android 框架远远的地方跑。