Skip to content

测试金字塔与测试左移 ​

T1-005必学入门约 45 分钟JDK 21测试专项 A:单元测试 · 测试思维

1. 本节定位 ​

从 《JUnit 5:测试结构与断言》 到 《JaCoCo:覆盖率指标与报告》 都在讲"怎么写好一个测试"。本节换个视角:一个项目该有多少测试、分别放在哪一层。

这个决策决定了你的 CI 是"两分钟出结果"还是"四十分钟还在跑",也决定了团队会不会因为"太慢"而逐渐绕过测试。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出测试金字塔的三层,以及每层的速度、稳定性、成本特征;
  • 解释为什么"大量 E2E + 手工测试"是反模式;
  • 说出测试左移的含义,以及三个可落地的做法;
  • 为一次代码提交设计"提交前 / CI / 发布前"三档测试执行策略;
  • 说清"形状不重要,比例重要"。

3. 前置知识检查 ​

  • 会写并运行 JUnit 5 测试吗?(T1-001)
  • 知道 Maven 测试生命周期吗?(T1-014)

4. 为什么需要这个知识 ​

看两种极端的测试策略,以及它们的后果:

极端一:只有单元测试(因为快)

text
✓ 所有单元测试通过
✗ 上线后发现:订单表和用户表的关联字段写错了(数据库层面没测)
✗ 上线后发现:接口返回的时间格式前端解析不了(契约层面没测)

单元测试再全,也测不到"组件拼起来之后"的问题——SQL 写错、序列化不兼容、事务边界错误,这些都属于"集成"。

极端二:只有 E2E / 手工测试(因为"真实")

text
✓ 端到端流程验证过了
✗ 每次跑完整套要 40 分钟,开发改成"提交前不跑,等 CI"
✗ CI 经常因为环境问题随机失败,团队开始忽略红色
✗ 一个边界条件要写 5 分钟的 UI 操作,最后没人写

慢的测试等于没有测试——因为它会被绕过。

本节的结论就是这两种极端之间的比例问题。

5. 核心概念 ​

5.1 三层金字塔 ​

层名称测什么典型数量占比单次耗时
L1单元测试一个类/方法的逻辑约 70%毫秒级
L2集成测试组件之间的协作(数据库、HTTP、消息)约 20%秒级到十秒级
L3端到端测试完整业务流程(通过 UI 或 API)约 10%十秒级到分钟级

三层的本质差别不只是"范围",还有"反馈质量":

维度单元测试集成测试端到端测试
速度毫秒秒分钟
稳定性高(无外部依赖)中(依赖环境)低(UI、网络、时序)
定位失败精确到方法到交互点只能到"某个环节"
写起来的成本低中高
能抓到的 bug逻辑错误、边界契约、SQL、事务流程断点、环境问题

关键认知:越往上的测试,越慢、越脆、失败时越难定位,但它能验证的东西是下面两层做不到的。 所以三层是互补的,不是"越下面的越好"。

5.2 为什么是金字塔(而不是倒三角) ​

冰淇淋甜筒反模式(Ice Cream Cone):手工测试与 E2E 占大头,单元测试很少。

text
   △△△△△△△△△△     手工测试(最多、最慢、最贵)
    △△△△△△△△     E2E
      △△△△       集成
        △         单元(几乎没有)

它的问题:

  1. 反馈慢:改一行代码要等 40 分钟才知道有没有坏;
  2. 定位难:测试红了,你得手工排查是哪个环节;
  3. 维护贵:UI 一改,几十个 E2E 全红;
  4. 覆盖反而是虚的:因为太慢,实际上"每次提交都跑"做不到,于是覆盖率取决于有没有人记得跑。

金字塔的收益正好相反:大部分问题在最便宜的层被拦住。

放回本项目的真实数字:86 个单元测试,Maven 全量执行(含编译)10.8 秒,其中测试本身累计约 4 秒。

text
[INFO] Tests run: 86, Failures: 0, Errors: 0, Skipped: 0
[INFO] Total time:  10.763 s

如果把它们改写成"连真实数据库 + HTTP 调用"的测试,单次执行会从 10 秒变成分钟级——而它们验证的逻辑一点没变多。

5.3 测试左移(Shift Left) ​

缺陷成本曲线:同一个 bug,发现得越晚,修复成本越高。

发现阶段修复成本原因
编码时1×立刻改,改完继续
单元测试2×定位到方法,改几行
集成/CI5×要复现环境、可能要动接口
测试/预发20×要走流程、要回归
生产100×+用户已受影响、要发版、要写事故报告

"测试左移"就是:把质量活动尽量往左挪。三个可落地的做法:

做法说明在本项目对应
写测试与写代码同步不是"开发完再补测试"T1-007 FIRST 的 Timely
本地能跑的坚决不放到 CI单元测试在提交前跑完-DexcludedGroups=slow(T1-014)
把评审与校验自动化静态检查、覆盖率门禁、口径校验T1-018 覆盖率门禁、本项目的 validate.py

顺带一个真实例子:这个知识库项目自己就有一条"左移"机制——未映射的路线条目、悬挂的先修 ID 会让构建失败(scripts/validate.py)。它把"内容有没有遗漏"这个原本要到人工验收才发现的问题,提前到了每次构建。

5.4 分层执行策略 ​

三层测试不该在同一时刻跑,而应该按"反馈速度"分层触发:

时机跑什么目标耗时命令
保存/编译相关类的单元测试秒级-Dtest=<被改的类>
提交前全部单元测试十几秒mvnw test -DexcludedGroups=slow
推送后(CI)单元 + 集成 + 覆盖率门禁几分钟mvnw verify
发布前端到端 + 冒烟十几分钟独立的流水线阶段

核心原则:"越靠左的执行,越要快;越靠右的执行,可以慢但要全。"

这也是 CI 分层执行的基础(T2-017 CI 分层执行与报告归档)。

5.5 关于"金字塔形状"的补充 ​

近年来有人提出"测试奖杯(Testing Trophy)""测试蜂巢"等变体,主张"集成测试的性价比最高"。它们有合理的场景(比如前端、微服务)。但争论的焦点其实是"集成测试的成本能压到多低":

  • 如果容器化让"起一个真实数据库"只要 2 秒 → 集成测试的性价比确实上升;
  • 如果每次都要连共享的测试环境 → 集成测试就会变慢变脆,金字塔依然成立。

所以不必纠结形状,记住那条不变的原则:

用尽可能快的测试,覆盖尽可能多样的失败模式。

"快"和"多样"冲突时,用分层解决——多数问题在快速层拦住,少数只在真实环境暴露的问题交给慢速层。

5.6 本项目当前的分层状态 ​

层状态覆盖内容
L1 单元测试✅ 86 个领域逻辑、边界、异常、交互
L2 集成测试⏳ 待产出Spring 切片测试(T1-016)、集成测试与 Testcontainers(T1-017)
L3 端到端 / 接口测试⏳ 待产出HTTP 接口测试(T2-001)、UI 自动化(T2-002)、压力测试(T2-003)

这个顺序本身就是"由内到外":先把领域逻辑的测试打牢(大部分 bug 在这里),再往外扩。

6. 递进示例 ​

6.1 同一个需求在三层的测试 ​

需求:"用户改名后要收到通知"。

java
// L1 单元测试:验证逻辑与协作(毫秒级)
@Test
void rename_notifies_user() {
    when(repository.findById("u1")).thenReturn(Optional.of(user));
    service.rename("u1", "新名");
    verify(notifier).send("u1", "您的名字已更改为 新名");
}

// L2 集成测试:验证真的写进了数据库(秒级)
@Test
void rename_persists_to_database() {
    service.rename("u1", "新名");
    assertThat(repository.findById("u1").orElseThrow().name()).isEqualTo("新名");
}

// L3 端到端测试:验证用户真的收到了邮件(分钟级)
// 通过 HTTP 调用接口 → 检查消息队列里真的有一条通知

三层不是重复:L1 证明"逻辑对",L2 证明"存下来了",L3 证明"整条链路通了"。它们抓的是不同种类的 bug。

6.2 分层执行的实测 ​

powershell
# 提交前:全部单元测试(10.8 秒)
cd labs && ./mvnw -pl testing-unit test

# 只跑被改动的类的测试(0.1 秒)
./mvnw -pl testing-unit test -Dtest=OrderCalculatorTest

注意那个数量级差异:单类 0.1 秒 vs 全量 10.8 秒——这就是"改一行代码立刻验证"和"提交前整体确认"的分工。

6.3 反例:把所有测试都写成 E2E ​

java
@Test
void rename_user_end_to_end() {
    // 启动应用、连数据库、调用真实接口、等邮件发送……
    // 一个测试 30 秒,写 50 个 → 25 分钟
}

问题:50 个 E2E 只能覆盖 50 个场景——而单元测试 10 秒能跑几百个。用最贵的资源覆盖最少的场景,这是资源错配。

7. 实践清单 ​

给一次提交设计执行策略(可直接照抄):

时机命令耗时预期拦什么
保存后-Dtest=被改的测试类< 1 s刚刚写的逻辑错
提交前mvnw -pl <模块> test -DexcludedGroups=slow~10 s逻辑回归、破坏其他测试
推送后mvnw -B -ntp verify分钟级集成问题、覆盖率门禁
合并前完整流水线(含 L3)十几分钟端到端流程

注意"提交前"这一档是关键:它是开发者的最后一道自助检查,也是金字塔能不能立住的实际分界线。如果这一档超过 1 分钟,团队就会跳过它。

8. 常见错误 ​

错误 1:用"覆盖率"代替"分层" ​

text
单元测试覆盖率 100%

但如果所有测试都是 E2E(连数据库),覆盖率再高也解决不了"反馈慢、定位难"。层与比例是"策略",覆盖率只是"某一层的进度"。

错误 2:在单元测试里连数据库 ​

java
class UserServiceTest {
    private final UserRepository repository = new JdbcUserRepository("jdbc:mysql://localhost/test");
}

后果:① 测试变慢(每次真连库);② 依赖环境(本地没库就红);③ 无法并行(数据互相干扰);④ 难测异常分支("数据库超时"怎么造?)。

修复:单元测试用替身(T1-010),真实数据库交给集成测试。

错误 3:把"手工测试用例"当自动化测试 ​

Excel 里 200 条手工用例,每次发布都要人点一遍——这不是测试策略,这是人力消耗。

修复:识别其中"高频、稳定、可自动化"的流程,转成 L2/L3 自动化;手工测试专注于探索性测试与用户体验。

错误 4:追求"全流程 E2E 覆盖一切" ​

E2E 的价值在"验证关键链路是通的",而不是"覆盖所有分支"。 分支覆盖交给单元测试。

判断标准:E2E 应该覆盖用户能感知的核心流程(注册、下单、支付、退款),数量控制在十几个以内。

错误 5:忽略"测试也是代码" ​

测试代码同样需要可读性、可维护性、抽象。几百个复制粘贴的测试,改一次接口要改 50 个文件——这种测试会在两次迭代内被废弃。

9. 动手实验 ​

目标:用量化数据说服自己"分层"的价值。

步骤:

  1. 在 labs 里跑一次完整测试,记录 Maven 的 Total time 与 Tests run 数字;
  2. 用 -Dtest=OrderCalculatorTest 只跑一个类,记录耗时——算出两者的倍数差;
  3. 假设你有 500 个测试要维护:按第 2 步的倍数估算,全量执行会是多少时间?
  4. 给现在的 86 个测试分层:哪些是 L1?如果把它们改写成"连真实数据库的集成测试",你觉得会慢多少倍?
  5. 写一份"你项目的分层执行策略"(可以直接改第 7 节的表格):填上你自己的命令、耗时、拦什么。

验收方式:能说出第 2 步的倍数,以及"为什么这个倍数决定了团队会不会跑测试"。

扩展任务:为本项目设计一条"未来"的 CI 流水线,包含 L1/L2/L3 三个阶段,标明每阶段的触发条件与预计耗时——这张图在 T2-017 会被真正实现出来。

10. 练习题 ​

基础题

  1. 判断:端到端测试比单元测试更"真实",所以应该优先写。(对 / 错)
  2. 填空:测试金字塔的三层从下到上是 、、______,大致比例为 ______。
  3. 简答:为什么"慢的测试等于没有测试"?

代码阅读题

text
项目 A:单元 400 个(跑 30 秒) + E2E 20 个(跑 15 分钟)
项目 B:单元 30 个(跑 2 秒) + E2E 300 个(跑 45 分钟)
  1. 哪个项目的实际质量保护更好?为什么?
  2. 如果项目 B 的团队说"我们有 330 个测试,比 A 多",你会怎么回应?

编程题

  1. 为一个"用户注册"流程设计三层测试:
    • 写出 L1(单元测试)覆盖的 4 个场景;
    • 写出 L2(集成测试)应覆盖的 3 个场景(提示:数据库唯一约束、事务回滚、真实 SQL);
    • 写出 L3(端到端)应覆盖的 1-2 个核心链路;
    • 说明:哪些场景"只在 L2/L3 才测得到"?为什么?

综合任务

  1. 为一个真实项目(可以用你手上的工作项目)做一次"测试策略体检":
    • 统计各层测试的数量与执行耗时;
    • 算出三层比例,判断它接近金字塔还是冰淇淋甜筒;
    • 找出"最贵但覆盖最少"的那几个测试,分析它们是否可以下沉到更低层;
    • 设计改进方案:未来 3 个月把比例调到多少,具体怎么做;
    • 说明:你预期改进后"缺陷逃逸率"会不会下降?为什么?
分级提示

提示 1(方向):第 4 题的关键不是"测试数量",而是"每次提交能不能跑完"。

提示 2(关键点):第 6 题的 L2 场景集中在"要真实环境才成立"的东西:唯一索引冲突、外键、事务隔离、序列化。

提示 3(接近答案):第 7 题的"下沉"判断标准是:这个测试验证的逻辑,能不能在隔离依赖的情况下验证? 能,就说明它不该待在 L3。

11. 参考答案 ​

基础题
  1. 错。E2E 更"真实",但更慢、更脆、更难定位。它应该只覆盖核心链路(约 10%),大量逻辑验证交给快速层。"优先写"的应该是能最快给出反馈的那一层。
  2. 单元测试、集成测试、端到端测试;约 70% / 20% / 10%。
  3. 因为测试的实际价值取决于"它被执行了多少次"。一个 45 分钟的测试套件,开发者在日常提交时不会跑、CI 排队时会有人忽略、偶发失败时会被重跑掩盖——它名义上存在,实际上很少真正拦到东西。能被执行 50 次的快测试,价值远高于只被跑 1 次的慢测试。
代码阅读题(第 4、5 题)

第 4 题:项目 A 的保护更好。

理由:

维度项目 A项目 B
提交前能否跑完✅ 30 秒,可以❌ 45 分钟,不会跑
实际执行频率每次提交每天一两次
失败定位精确到方法需要人工排查
稳定性高低(E2E 常见抖动)

项目 B 的 300 个 E2E 大概率处于"红色也不当回事"的状态——一旦团队开始说"这个测试又不稳了,重跑一下",测试的权威性就消失了。

第 5 题:可以这样回应:

数量不是关键,关键是有多少测试能在提交前跑完。你们的 300 个 E2E 需要 45 分钟——这意味着日常提交时它们根本没跑,只有 CI 上跑一次。所以我更关心的是:你们有多少个能在 10 秒内给反馈的测试?

顺带的建议:把 E2E 中"其实在验证逻辑"的部分下沉成单元测试——同样一条业务规则,用单元测试写只要 5 毫秒,把它留在 E2E 里等于用 30 秒验证一个 5 毫秒能验证的事。

编程题(第 6 题)

L1 单元测试(4 个场景):

  1. 邮箱格式非法 → 抛 IllegalArgumentException;
  2. 密码强度不足 → 抛异常,且不保存、不发邮件;
  3. 邮箱已存在 → 抛异常,且不保存、不发邮件;
  4. 正常注册 → 调用 save 且参数正确(id 来自 IdGenerator、密码已加密),并发出欢迎邮件(用 ArgumentCaptor 断言)。

L2 集成测试(3 个场景):

  1. 数据库唯一约束真的生效:两个并发注册同一邮箱,一个成功一个因唯一索引冲突失败(单元测试里"邮箱已存在"是 mock 出来的,不等于数据库约束真的建了);
  2. 事务回滚:保存用户成功但发邮件失败时,用户记录不应留在库里(验证事务边界——这在单元测试里完全测不到);
  3. 真实 SQL 正确:插入后查询能读回,字段映射没有错位(列名写错、类型不匹配这类问题只在真实 SQL 执行时暴露)。

L3 端到端(1-2 个核心链路):

  1. 从 HTTP 接口完成注册 → 收到 200 → 用返回的凭证登录成功;
  2. (可选)注册后能查到用户详情,字段与提交的一致。

哪些场景"只在 L2/L3 才测得到":

场景为什么单元测试测不到
唯一索引冲突单元测试里"已存在"是你告诉它的,不是数据库告诉它的
事务回滚事务是框架 + 数据库的行为,单元测试里没有事务
SQL / ORM 映射正确性单元测试里 Repository 是替身,SQL 一行都没执行
序列化与契约只有真实 HTTP + JSON 才验证得到

一句话总结:单元测试验证"你的逻辑对不对",集成测试验证"你以为的依赖,是不是真的那样"。

综合任务(第 7 题)

体检报告模板(用本项目举例):

text
【当前状态】
L1 单元测试:86 个,执行约 4 秒(Maven 全量 10.8 秒)
L2 集成测试:0 个(待 T1-016/T1-017)
L3 端到端:0 个(待 T2 系列)
比例:100% / 0% / 0%

【诊断】
✓ L1 已经很扎实(领域逻辑、边界、异常、交互都覆盖了)
⚠ 缺少 L2:数据库约束、事务边界、SQL 正确性完全没有验证
⚠ 缺少 L3:还不知道"整条链路通不通"

【改进方案(3 个月)】
第 1 个月:补 L2 的"关键路径集成测试" 5-8 个
  - 真实数据库 + 事务回滚
  - 目标:L2 执行时间控制在 30 秒内
第 2 个月:补 L3 的"核心链路" 3-5 个
  - 注册、下单、退款三条链路
第 3 个月:把 L3 中"其实在测逻辑"的用例下沉到 L1

【目标比例】
L1 : L2 : L3 = 75% : 20% : 5%
执行策略:提交前跑 L1(10 秒)→ CI 跑 L1+L2(1 分钟)→ 发布前跑全量

【预期效果】
- 数据库与事务类 bug 的逃逸率应显著下降(这类问题原本只能靠手测发现)
- L3 数量克制,所以它的"红色"会被认真对待

"缺陷逃逸率会不会下降"的回答:

会下降,但主要来自"新增了 L2 覆盖的那类 bug"。

要诚实地区分收益来源:

新增覆盖能拦住的 bug 类型
L2SQL 错误、唯一约束缺失、事务边界、序列化
L3链路断点、配置错误、环境差异
都不会新增纯逻辑错误(L1 已经覆盖了)

所以一个成熟的判断是:如果 L1 已经很扎实,那么补 L2/L3 的边际收益在于"那类只在集成时暴露的 bug",而不是"更全面"。测试分层的目的不是"越多越好",而是"不让某一类 bug 完全没人管"。

12. 掌握标准 ​

  • [ ] 能说出三层的特征差别与大致比例;
  • [ ] 能解释为什么"大量 E2E"是反模式;
  • [ ] 能说出测试左移的三个可落地做法;
  • [ ] 能为自己的项目设计"提交前 / CI / 发布前"三档执行策略;
  • [ ] 记住那条不变的原则:用尽可能快的测试,覆盖尽可能多样的失败模式。

13. 扩展资源 ​

14. 下一步学习 ​

《可测试性与 Arrange-Act-Assert》:把"测试写不写得出来"这件事,拉回到设计层面。

前置知识

无,可直接开始

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引