主题
界面
主题
从 《JUnit 5:测试结构与断言》 到 《JaCoCo:覆盖率指标与报告》 都在讲"怎么写好一个测试"。本节换个视角:一个项目该有多少测试、分别放在哪一层。
这个决策决定了你的 CI 是"两分钟出结果"还是"四十分钟还在跑",也决定了团队会不会因为"太慢"而逐渐绕过测试。
完成本节后,你应该能够:
看两种极端的测试策略,以及它们的后果:
极端一:只有单元测试(因为快)
✓ 所有单元测试通过
✗ 上线后发现:订单表和用户表的关联字段写错了(数据库层面没测)
✗ 上线后发现:接口返回的时间格式前端解析不了(契约层面没测)单元测试再全,也测不到"组件拼起来之后"的问题——SQL 写错、序列化不兼容、事务边界错误,这些都属于"集成"。
极端二:只有 E2E / 手工测试(因为"真实")
✓ 端到端流程验证过了
✗ 每次跑完整套要 40 分钟,开发改成"提交前不跑,等 CI"
✗ CI 经常因为环境问题随机失败,团队开始忽略红色
✗ 一个边界条件要写 5 分钟的 UI 操作,最后没人写慢的测试等于没有测试——因为它会被绕过。
本节的结论就是这两种极端之间的比例问题。
| 层 | 名称 | 测什么 | 典型数量占比 | 单次耗时 |
|---|---|---|---|---|
| L1 | 单元测试 | 一个类/方法的逻辑 | 约 70% | 毫秒级 |
| L2 | 集成测试 | 组件之间的协作(数据库、HTTP、消息) | 约 20% | 秒级到十秒级 |
| L3 | 端到端测试 | 完整业务流程(通过 UI 或 API) | 约 10% | 十秒级到分钟级 |
三层的本质差别不只是"范围",还有"反馈质量":
| 维度 | 单元测试 | 集成测试 | 端到端测试 |
|---|---|---|---|
| 速度 | 毫秒 | 秒 | 分钟 |
| 稳定性 | 高(无外部依赖) | 中(依赖环境) | 低(UI、网络、时序) |
| 定位失败 | 精确到方法 | 到交互点 | 只能到"某个环节" |
| 写起来的成本 | 低 | 中 | 高 |
| 能抓到的 bug | 逻辑错误、边界 | 契约、SQL、事务 | 流程断点、环境问题 |
关键认知:越往上的测试,越慢、越脆、失败时越难定位,但它能验证的东西是下面两层做不到的。 所以三层是互补的,不是"越下面的越好"。
冰淇淋甜筒反模式(Ice Cream Cone):手工测试与 E2E 占大头,单元测试很少。
△△△△△△△△△△ 手工测试(最多、最慢、最贵)
△△△△△△△△ E2E
△△△△ 集成
△ 单元(几乎没有)它的问题:
金字塔的收益正好相反:大部分问题在最便宜的层被拦住。
放回本项目的真实数字:86 个单元测试,Maven 全量执行(含编译)10.8 秒,其中测试本身累计约 4 秒。
[INFO] Tests run: 86, Failures: 0, Errors: 0, Skipped: 0
[INFO] Total time: 10.763 s如果把它们改写成"连真实数据库 + HTTP 调用"的测试,单次执行会从 10 秒变成分钟级——而它们验证的逻辑一点没变多。
缺陷成本曲线:同一个 bug,发现得越晚,修复成本越高。
| 发现阶段 | 修复成本 | 原因 |
|---|---|---|
| 编码时 | 1× | 立刻改,改完继续 |
| 单元测试 | 2× | 定位到方法,改几行 |
| 集成/CI | 5× | 要复现环境、可能要动接口 |
| 测试/预发 | 20× | 要走流程、要回归 |
| 生产 | 100×+ | 用户已受影响、要发版、要写事故报告 |
"测试左移"就是:把质量活动尽量往左挪。三个可落地的做法:
| 做法 | 说明 | 在本项目对应 |
|---|---|---|
| 写测试与写代码同步 | 不是"开发完再补测试" | T1-007 FIRST 的 Timely |
| 本地能跑的坚决不放到 CI | 单元测试在提交前跑完 | -DexcludedGroups=slow(T1-014) |
| 把评审与校验自动化 | 静态检查、覆盖率门禁、口径校验 | T1-018 覆盖率门禁、本项目的 validate.py |
顺带一个真实例子:这个知识库项目自己就有一条"左移"机制——未映射的路线条目、悬挂的先修 ID 会让构建失败(scripts/validate.py)。它把"内容有没有遗漏"这个原本要到人工验收才发现的问题,提前到了每次构建。
三层测试不该在同一时刻跑,而应该按"反馈速度"分层触发:
| 时机 | 跑什么 | 目标耗时 | 命令 |
|---|---|---|---|
| 保存/编译 | 相关类的单元测试 | 秒级 | -Dtest=<被改的类> |
| 提交前 | 全部单元测试 | 十几秒 | mvnw test -DexcludedGroups=slow |
| 推送后(CI) | 单元 + 集成 + 覆盖率门禁 | 几分钟 | mvnw verify |
| 发布前 | 端到端 + 冒烟 | 十几分钟 | 独立的流水线阶段 |
核心原则:"越靠左的执行,越要快;越靠右的执行,可以慢但要全。"
这也是 CI 分层执行的基础(T2-017 CI 分层执行与报告归档)。
近年来有人提出"测试奖杯(Testing Trophy)""测试蜂巢"等变体,主张"集成测试的性价比最高"。它们有合理的场景(比如前端、微服务)。但争论的焦点其实是"集成测试的成本能压到多低":
所以不必纠结形状,记住那条不变的原则:
用尽可能快的测试,覆盖尽可能多样的失败模式。
"快"和"多样"冲突时,用分层解决——多数问题在快速层拦住,少数只在真实环境暴露的问题交给慢速层。
| 层 | 状态 | 覆盖内容 |
|---|---|---|
| L1 单元测试 | ✅ 86 个 | 领域逻辑、边界、异常、交互 |
| L2 集成测试 | ⏳ 待产出 | Spring 切片测试(T1-016)、集成测试与 Testcontainers(T1-017) |
| L3 端到端 / 接口测试 | ⏳ 待产出 | HTTP 接口测试(T2-001)、UI 自动化(T2-002)、压力测试(T2-003) |
这个顺序本身就是"由内到外":先把领域逻辑的测试打牢(大部分 bug 在这里),再往外扩。
需求:"用户改名后要收到通知"。
// 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。
# 提交前:全部单元测试(10.8 秒)
cd labs && ./mvnw -pl testing-unit test
# 只跑被改动的类的测试(0.1 秒)
./mvnw -pl testing-unit test -Dtest=OrderCalculatorTest注意那个数量级差异:单类 0.1 秒 vs 全量 10.8 秒——这就是"改一行代码立刻验证"和"提交前整体确认"的分工。
@Test
void rename_user_end_to_end() {
// 启动应用、连数据库、调用真实接口、等邮件发送……
// 一个测试 30 秒,写 50 个 → 25 分钟
}问题:50 个 E2E 只能覆盖 50 个场景——而单元测试 10 秒能跑几百个。用最贵的资源覆盖最少的场景,这是资源错配。
给一次提交设计执行策略(可直接照抄):
| 时机 | 命令 | 耗时预期 | 拦什么 |
|---|---|---|---|
| 保存后 | -Dtest=被改的测试类 | < 1 s | 刚刚写的逻辑错 |
| 提交前 | mvnw -pl <模块> test -DexcludedGroups=slow | ~10 s | 逻辑回归、破坏其他测试 |
| 推送后 | mvnw -B -ntp verify | 分钟级 | 集成问题、覆盖率门禁 |
| 合并前 | 完整流水线(含 L3) | 十几分钟 | 端到端流程 |
注意"提交前"这一档是关键:它是开发者的最后一道自助检查,也是金字塔能不能立住的实际分界线。如果这一档超过 1 分钟,团队就会跳过它。
单元测试覆盖率 100%但如果所有测试都是 E2E(连数据库),覆盖率再高也解决不了"反馈慢、定位难"。层与比例是"策略",覆盖率只是"某一层的进度"。
class UserServiceTest {
private final UserRepository repository = new JdbcUserRepository("jdbc:mysql://localhost/test");
}后果:① 测试变慢(每次真连库);② 依赖环境(本地没库就红);③ 无法并行(数据互相干扰);④ 难测异常分支("数据库超时"怎么造?)。
修复:单元测试用替身(T1-010),真实数据库交给集成测试。
Excel 里 200 条手工用例,每次发布都要人点一遍——这不是测试策略,这是人力消耗。
修复:识别其中"高频、稳定、可自动化"的流程,转成 L2/L3 自动化;手工测试专注于探索性测试与用户体验。
E2E 的价值在"验证关键链路是通的",而不是"覆盖所有分支"。 分支覆盖交给单元测试。
判断标准:E2E 应该覆盖用户能感知的核心流程(注册、下单、支付、退款),数量控制在十几个以内。
测试代码同样需要可读性、可维护性、抽象。几百个复制粘贴的测试,改一次接口要改 50 个文件——这种测试会在两次迭代内被废弃。
目标:用量化数据说服自己"分层"的价值。
步骤:
labs 里跑一次完整测试,记录 Maven 的 Total time 与 Tests run 数字;-Dtest=OrderCalculatorTest 只跑一个类,记录耗时——算出两者的倍数差;验收方式:能说出第 2 步的倍数,以及"为什么这个倍数决定了团队会不会跑测试"。
扩展任务:为本项目设计一条"未来"的 CI 流水线,包含 L1/L2/L3 三个阶段,标明每阶段的触发条件与预计耗时——这张图在 T2-017 会被真正实现出来。
基础题
代码阅读题
项目 A:单元 400 个(跑 30 秒) + E2E 20 个(跑 15 分钟)
项目 B:单元 30 个(跑 2 秒) + E2E 300 个(跑 45 分钟)编程题
综合任务
提示 1(方向):第 4 题的关键不是"测试数量",而是"每次提交能不能跑完"。
提示 2(关键点):第 6 题的 L2 场景集中在"要真实环境才成立"的东西:唯一索引冲突、外键、事务隔离、序列化。
提示 3(接近答案):第 7 题的"下沉"判断标准是:这个测试验证的逻辑,能不能在隔离依赖的情况下验证? 能,就说明它不该待在 L3。
第 4 题:项目 A 的保护更好。
理由:
| 维度 | 项目 A | 项目 B |
|---|---|---|
| 提交前能否跑完 | ✅ 30 秒,可以 | ❌ 45 分钟,不会跑 |
| 实际执行频率 | 每次提交 | 每天一两次 |
| 失败定位 | 精确到方法 | 需要人工排查 |
| 稳定性 | 高 | 低(E2E 常见抖动) |
项目 B 的 300 个 E2E 大概率处于"红色也不当回事"的状态——一旦团队开始说"这个测试又不稳了,重跑一下",测试的权威性就消失了。
第 5 题:可以这样回应:
数量不是关键,关键是有多少测试能在提交前跑完。你们的 300 个 E2E 需要 45 分钟——这意味着日常提交时它们根本没跑,只有 CI 上跑一次。所以我更关心的是:你们有多少个能在 10 秒内给反馈的测试?
顺带的建议:把 E2E 中"其实在验证逻辑"的部分下沉成单元测试——同样一条业务规则,用单元测试写只要 5 毫秒,把它留在 E2E 里等于用 30 秒验证一个 5 毫秒能验证的事。
L1 单元测试(4 个场景):
IllegalArgumentException;save 且参数正确(id 来自 IdGenerator、密码已加密),并发出欢迎邮件(用 ArgumentCaptor 断言)。L2 集成测试(3 个场景):
L3 端到端(1-2 个核心链路):
哪些场景"只在 L2/L3 才测得到":
| 场景 | 为什么单元测试测不到 |
|---|---|
| 唯一索引冲突 | 单元测试里"已存在"是你告诉它的,不是数据库告诉它的 |
| 事务回滚 | 事务是框架 + 数据库的行为,单元测试里没有事务 |
| SQL / ORM 映射正确性 | 单元测试里 Repository 是替身,SQL 一行都没执行 |
| 序列化与契约 | 只有真实 HTTP + JSON 才验证得到 |
一句话总结:单元测试验证"你的逻辑对不对",集成测试验证"你以为的依赖,是不是真的那样"。
体检报告模板(用本项目举例):
【当前状态】
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 类型 |
|---|---|
| L2 | SQL 错误、唯一约束缺失、事务边界、序列化 |
| L3 | 链路断点、配置错误、环境差异 |
| 都不会新增 | 纯逻辑错误(L1 已经覆盖了) |
所以一个成熟的判断是:如果 L1 已经很扎实,那么补 L2/L3 的边际收益在于"那类只在集成时暴露的 bug",而不是"更全面"。测试分层的目的不是"越多越好",而是"不让某一类 bug 完全没人管"。
《可测试性与 Arrange-Act-Assert》:把"测试写不写得出来"这件事,拉回到设计层面。
无,可直接开始