Skip to content

项目 A:测试方案与分层测试 ​

S5-005必学高级约 90 分钟JDK 21项目实战 · 项目 A:测试管理平台

1. 本节定位 ​

功能写完了,怎么证明它真的对?

★ 本项目的测试分四层,共 30 个,全部通过 ✓✓:

层测试类数量时间
① 领域对象BorrowRecordTest60.16 s ✓✓
② 接口层BookControllerTest95.0 s
③ 集成(真实库)BorrowFlowIntegrationTest120.7 s ✓✓
④ 并发BorrowConcurrencyTest321.6 s ⚠️

★★ 最有价值的结论:"并发下会不会超借"只有第 ④ 层能回答 ✓✓

2. 学习目标 ​

  • 能说出四层测试各自测什么、以及各自的盲区 ★★★;
  • 知道并发测试里 @Transactional 是陷阱 ★★★;
  • 能说清"断言恰好 N 个成功"为什么比"至少 N 个"强 ★★★;
  • 知道 Mockito 在 JDK 25 上跑不了(真实踩坑) ★★★;
  • 能列出测试数据准备的常见坑 ★★★。

3. 前置知识检查 ​

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

★ 这个项目里,真正值钱的断言只有一条 ✓✓:

text
10 个线程抢 1 本库存 → 恰好 1 个成功,库存恰好 0

★ 换成"先查再改"的写法,这个测试必然失败 ✗✗ —— 这就是测试的价值:它把"并发安全"从"我觉得没问题"变成了"可验证" ✓✓

5. 核心概念 ​

5.1 四层分工与各自的盲区 ★★★ ​

层测什么测不出什么(盲区)
① 领域对象 ★★★计算规则、边界值数据库相关的任何问题 ✗
② 接口层 ★★状态码、JSON 结构、参数默认值业务规则(它只关心协议)✗
③ 集成 ★★★SQL 对不对、事务回不回滚、约束生效没并发安全 ✗✗
④ 并发 ★★★并发下会不会超借 / 重复归还单线程的正确性(由前三层覆盖)✓

★★★ 关键认识:Mock 的盲区 ✓✓:

Mock 掉的 Repository 是"我说它返回什么就返回什么" ✗

所以用 Mock 测不出: · SQL 写错了 · 事务没生效 · 并发下会超借

这些必须靠真实数据库 ✓✓

5.2 并发测试:三个"必须" ★★★ ​

① 不能加 @Transactional ✗✗:

text
事务里的改动,其他线程看不到 ✗
→ 10 个线程会"各借各的" → 测试假通过 ✗✗
→ 这比不测还危险:你以为验证了,其实什么都没验证

② 要自己准备与清理数据(不能靠事务回滚)✓

③ 要断言"恰好 N 个成功",而不是"至少 N 个" ✓✓

java
assertEquals(1, success.get(), "★ 只能有 1 个线程借到(多一个就是超借)");
assertEquals(9, noCopy.get(), "★ 其余 9 个都应该是「库存不足」这一种明确结果");
assertEquals(0, otherError.get(), "不该出现意料之外的错误");
assertEquals(0, stock, "库存应该恰好扣到 0,不能为负");

★★ 第三条最重要 ✓✓:"至少 1 个成功"在超借的情况下也会通过 ✗✗ —— 那等于没测 ✓

5.3 本项目在测试里踩到的四个坑 ★★★ ​

#坑现象修复
①Mockito 在 JDK 25 上无法 mock ✗✗9 个 Service 测试全 Error改用真实数据库测规则 ✓
②KeyHolder 收到多列 ✗✗12 个集成测试 9 个 ErrorprepareStatement(sql, new String[]{"id"}) ✓
③测试数据被自己的准备工作清掉 ✗并发测试成功数 = 0(不报错,只是结果不对)⚠️调整顺序:先建书、再建读者 ✓
④MERGE 语法不生效 ✗读者没插进去,全部"读者不存在"改成"先删后插" ✓

坑 ① 的现状注(2026-09 复核):实录环境为 JDK 25(基线 21,差异说明见 《JDK 与 Java 版本》)。 该坑的最终修复是在 labs/testing-unit/pom.xml 显式升级 byte-buddy(Mockito 自带版本只支持到 Java 24)—— 升级后 Mockito 用例已在 JDK 25 下正常运行(88 个用例全绿)✓。 「当时先绕开(用真实库测规则)、回头再根治」正是踩坑修复的推荐节奏 ✓✓。

★★★ 第 ① 个坑最值得记住 ✓✓:

text
org.mockito.exceptions.base.MockitoException:
Mockito cannot mock this class: class ...BookRepository.
Java : 25

教训:测试方案要先验证"在当前环境能不能跑起来" ✓ —— 而不是照抄文档里的写法 ✗

本项目改成了"真实数据库 + @Transactional",代价只是慢一点, 换来的是断言真实数据、不会出现假通过 ✓✓

★★ 第 ③ 个坑最隐蔽 ⚠️:

java
prepareReaders(threads);   // 插入 CT100~CT109
prepareBook(1);            // ← 内部先调用 cleanup(),把刚插的读者删了!

测试不报错,只是结果不对 ✗ —— 这类问题比崩溃更难发现 ✓✓

5.4 幂等怎么测 ★★★ ​

★ 归还的幂等测试 ✓✓:

java
@Test
void concurrentReturnAddsStockOnce() {
    // 借一本
    BorrowRecord record = borrowService.borrow(TEST_BOOK_ID, "CT100");
    // 10 个线程同时归还
    // 期望:恰好 1 次成功,库存只 +1
    assertEquals(1, success.get());
    assertEquals(5, stock, "★ 库存只能 +1(加多次就是重复归还的 bug)");
}

★ 关键在于"标记归还"的 SQL 带条件 ✓:

sql
UPDATE borrow_record SET status = 'RETURNED', return_at = ?
WHERE id = ? AND status = 'BORROWED'      -- ★ 第二次影响 0 行

★ 而且"先改记录、成功才加库存" ✓✓ —— 顺序反了就会出现"库存多了但书没还"✗

5.5 测试数据的三条纪律 ★★★ ​

#纪律不做会怎样
①每个测试自己准备数据(不依赖其它测试的执行顺序)✓✓换个执行顺序就挂 ✗✗
②用完清理(尤其是并发测试)✓污染后续测试 ⚠️
③准备动作不要反过来删掉别人的数据 ✓✓结果不对但不报错(本项目踩过)⚠️

5.6 一份可抄的测试清单 ★★ ​

★ 每个业务动作至少要覆盖这四类 ✓:

类别例子
正常流程 ✓借书成功:库存 -1、记录状态正确、到期日正确
业务拒绝 ★★★停用读者、超限、重复借、库存不足
幂等 ★★★重复归还:只加一次库存
并发 ★★★抢库存:恰好 N 个成功

★ 本项目的覆盖情况 ✓:

动作正常拒绝幂等并发
借书✓✓(4 种)—✓✓
还书✓✓✓✓✓✓
续借✓✓(超期/超限)——

6. 递进示例 ​

6.1 最小示例:纯领域对象测试(最快) ​

java
@Test
void oneDayOverdue() {
    LocalDateTime dueAt = LocalDateTime.now().minusDays(1).toLocalDate().atStartOfDay();
    assertEquals(1, record(dueAt, null, "BORROWED").overdueDays());
}

★ 0.16 秒跑 6 个 ✓✓ —— 每次保存都能跑 ✓

6.2 业务示例:并发测试(最值钱) ​

见 5.2 ✓

6.3 反例:用 Mock 验证并发 ​

java
// ✗ Mock 的 Repository 不会真的并发扣减 —— 测不出超借 ✗✗
when(bookRepository.tryBorrowOne(1L)).thenReturn(true);   // 永远成功
// → 10 个线程全部"成功" → 假通过

7. 完整可运行代码与实测 ​

代码:

text
labs/project-a/src/test/java/cn/kb/learn/library/
├── domain/       BorrowRecordTest              (6 个,0.16 s)
├── web/          BookControllerTest            (9 个,5.0 s)
└── integration/  BorrowFlowIntegrationTest     (12 个,0.7 s)
                  BorrowConcurrencyTest         (3 个,21.6 s)

运行:

powershell
cd labs
.\mvnw.cmd -pl project-a test

★ 实测结果 ✓:

text
Tests run: 30, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

★ 并发测试的实际输出 ✓:

text
  10 个线程抢 1 本:成功 1 / 库存不足 9 / 其他错误 0
  20 个线程抢 5 本:成功 5 / 剩余库存 0
  并发归还 10 次:成功 1 次 / 最终库存 5

8. Debug 观察点 ​

  1. 把 tryBorrowOne 改成"先查再改":跑并发测试,看它失败 ★★★;
  2. 给并发测试加上 @Transactional:观察它变成假通过 ⚠️;
  3. 把断言改成"至少 1 个成功":思考超借时它会不会也通过 ✓✓;
  4. 去掉 finally 里的清理:观察下一个测试是否受影响 ✓;
  5. 在 JDK 25 上试一次 Mockito:复现本项目的踩坑 ✓。

9. 常见错误 ​

#错误说明
1并发测试加 @Transactional ✗✗假通过(最危险)✓✓✓
2断言"至少 N 个成功" ✗✗超借也能通过 ✓✓
3只用 Mock 测,不用真实库 ✗✗测不出 SQL/事务/并发问题 ✓✓
4测试数据准备顺序错误 ✗结果不对但不报错 ⚠️
5测试之间互相依赖 ✗✗换个顺序就挂 ✓
6不做幂等测试 ✗✗重复提交会出脏数据 ✓
7测试数据不清理 ✗污染后续测试 ⚠️

10. 动手实验 ​

目标:亲手证明"并发安全"是真的。

步骤:

  1. 跑全部测试,确认 30 个全绿 ✓;
  2. 把 tryBorrowOne 改成"先查再改",跑并发测试看它失败 ★★★;
  3. 改回后,给并发测试加 @Transactional,观察假通过 ✓✓;
  4. 加一个自己的测试:"续借的并发幂等"(两个线程同时续借,只该成功一次)★★;
  5. 统计你的测试耗时分布:找出该被并行化的部分 ✓。

验收方式:能说出"你的哪个功能最需要并发测试、为什么"。

扩展任务:给项目加契约测试(《接口管理平台》):

  • 把当前接口响应存成快照,CI 里比对 ✓;
  • 让"改了响应结构"在提交时就失败 ✓✓;
  • 说明:为什么契约测试比"接口测试"更适合防"改坏了别人" ★★

11. 练习题 ​

  1. 判断:并发测试可以加 @Transactional 来保证数据干净。(对 / 错)
  2. 填空:并发测试要断言「______ N 个成功」,而不是「至少 N 个」。
  3. 简答:为什么"用 Mock 测 Service"测不出并发问题?
  4. (判断)
java
assertEquals(1, success.get(), "只能有 1 个借到");

如果改成 assertTrue(success.get() >= 1) 会怎样? 5. (设计)给"入库(增加副本)"设计测试:要覆盖正常、异常、并发。

参考答案(要点)
  1. 错 ✗✗。事务里的改动其他线程看不到 → 10 个线程各借各的 → 假通过 ✓✓★
  2. 恰好 ✓
  3. 因为 Mock 的 Repository 是"你说返回什么就返回什么" ✗ —— 它不会真的并发扣减,所以"超借"这种问题测不出来 ✓✓★
  4. 超借时它也会通过 ✗✗ —— 比如 3 个线程都借到了(超借 2 本),>= 1 依然成立 ✓ ——等于没测 ✓✓
  5. 要点:正常:库存与总量都 +N ✓; 异常:delta <= 0 拒绝、书不存在拒绝 ✓; 并发:10 个线程各加 1 本 → 最终总量恰好 +10(不能少)✓✓; 关键:"增加副本"也要用原子 UPDATE(SET total = total + ? 本身就是原子的 ✓)

12. 掌握标准 ​

  • [ ] 能说出四层测试的分工与盲区 ★★★;
  • [ ] 知道并发测试不能用 @Transactional ★★★;
  • [ ] 能写出"恰好 N 个成功"的断言 ★★★;
  • [ ] 知道 Mockito 在 JDK 25 上的限制 ★★★;
  • [ ] 能列出测试数据准备的常见坑 ★★★;
  • [ ] 知道每个业务动作要覆盖的测试类别 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《项目 A:部署与运维》:怎么把它跑起来并保持健康。

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

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