第八章 测试的力量
一
王志远第一次被半夜叫起来,是在入职第三个月的一个周四凌晨。
手机在枕头边疯狂震动。他迷迷糊糊地摸过来一看——钉钉群里一片红色的告警消息,On-Call 机器人在 @ 他:
🚨 P1 告警:订单服务异常率飙升至 15%,影响线上交易
当前 On-Call:王志远
请立即响应。
他一下子清醒了。
这周轮到他 On-Call。赵北辰之前跟他解释过 On-Call 制度——团队成员轮流值班,值班期间如果线上出了问题,值班人是第一响应者,需要在十五分钟内确认告警并开始排查。
他之前一直觉得 On-Call 不过是个形式——线上能出什么大问题?系统不是有监控吗?
现在他知道了。
二
他打开电脑,登录监控平台。
订单服务的错误率曲线像一根竖起来的针——从正常的 0.1% 直接飙到了 15%。错误日志里全是同一个异常:
java.lang.NullPointerException
at com.cloudwise.order.service.CouponService.applyCoupon(CouponService.java:87)
at com.cloudwise.order.service.OrderService.createOrder(OrderService.java:156)
CouponService.java 第 87 行,空指针异常。
他打开代码一看——这是他昨天改的。
昨天下午,他接了一个小需求:优惠券模块加一个新的折扣类型。他觉得改动很小,就是在 applyCoupon 方法里加了一个 if-else 分支。改完之后他手动测了一下,新的折扣类型能正常使用,就提交了代码。
但他没有写单元测试。
他也没有跑已有的测试用例——因为他觉得改动太小了,不至于影响其他逻辑。
现在他盯着那行报错的代码,发现了问题:他在新加的分支里调用了 coupon.getDiscount().getValue(),但当优惠券类型是"满减"而不是"折扣"的时候,getDiscount() 返回的是 null。
他改了新的折扣类型,但没有考虑到旧的优惠券类型在走到新分支时会出什么问题。
一个空指针,就这么简单。
三
他赶紧写了一个 hotfix:在调用 getDiscount() 之前加了空值检查,对"满减"类型走另一个计算逻辑。
改完之后他犹豫了一下——要不要直接推上去?
他想起赵北辰说过的话:"hotfix 也要走流程。紧急情况下可以简化 Review,但不能跳过。"
他在钉钉上 @ 了赵北辰。
赵北辰三分钟后回复了——凌晨两点,他居然还醒着。
"我看看。"
五分钟后,赵北辰在 PR 上批了一个 Approve:"改动没问题,合入吧。但你先跑一下现有的单元测试,确认没有引入新问题。"
王志远跑了一下测试——全绿。他合入代码,触发了部署流水线。十分钟后,新版本上线,错误率开始下降。
凌晨两点四十分,错误率恢复到正常水平。
他瘫在椅子上,后背全是冷汗。
四
第二天上午,赵北辰把他叫到了会议室。
"昨晚的事故,根本原因是什么?"
"我改了优惠券模块,没有考虑到旧类型的兼容性。"
"对。但说白了,更根本的原因是——你没写测试。"
赵北辰在白板上画了一条时间线:
你的改动 ──→ 手动测试(只测了新功能)──→ 提交 ──→ 上线 ──→ 💥 线上故障
如果有单元测试:
你的改动 ──→ 跑单元测试 ──→ ❌ 测试失败(旧类型不兼容)──→ 修复 ──→ 提交 ──→ 上线 ──→ ✅ 正常
"如果你写了单元测试,覆盖了'满减'类型的场景,那个空指针在你本地就会被发现。根本不会上线,根本不会有凌晨两点的告警,根本不会影响用户。"
王志远低着头。
"你知道昨晚那两个小时的故障,影响了多少订单吗?"赵北辰问。
"……不知道。"
"大约三千笔。其中有一部分是付了款但订单没创建成功的,需要人工核对和退款。客服那边今天一早就炸了。"
三千笔订单。王志远的手微微发抖。
"我不是要吓你。"赵北辰的语气缓和了一些。"线上故障谁没遇过?我自己也出过。但你想啊——能在自己电脑上拦住的 bug,干嘛非要等它跑到线上去炸?"
五
"那我应该怎么写测试?"王志远问。
赵北辰在白板上画了一个三角形:
/\
/ \ ← E2E 测试(端到端):少量,慢,贵
/ \
/──────\
/ \ ← 集成测试:适量,中速
/──────────\
/ \ ← 单元测试:大量,快,便宜
/──────────────\
"这叫测试金字塔。底层是单元测试,数量最多,速度最快,成本最低。中间是集成测试,测试模块之间的交互。顶层是端到端测试,模拟用户的完整操作流程,数量最少,速度最慢,成本最高。"
"大部分 bug 应该在单元测试层就被发现。你昨晚那个空指针,就是一个典型的单元测试能拦住的 bug。"
他写了一个例子:
@Test
void applyCoupon_withDiscountType_shouldCalculateCorrectly() {
Coupon coupon = new Coupon(CouponType.DISCOUNT, new Discount(0.8));
Order order = new Order(BigDecimal.valueOf(100));
CouponService service = new CouponService();
BigDecimal result = service.applyCoupon(order, coupon);
assertEquals(BigDecimal.valueOf(80), result);
}
@Test
void applyCoupon_withFullReductionType_shouldCalculateCorrectly() {
Coupon coupon = new Coupon(CouponType.FULL_REDUCTION, null,
BigDecimal.valueOf(20)); // 满100减20
Order order = new Order(BigDecimal.valueOf(100));
CouponService service = new CouponService();
BigDecimal result = service.applyCoupon(order, coupon);
assertEquals(BigDecimal.valueOf(80), result);
}
@Test
void applyCoupon_withFullReductionType_discountIsNull_shouldNotThrowNPE() {
Coupon coupon = new Coupon(CouponType.FULL_REDUCTION, null,
BigDecimal.valueOf(20));
Order order = new Order(BigDecimal.valueOf(100));
CouponService service = new CouponService();
// 不应该抛出 NullPointerException
assertDoesNotThrow(() -> service.applyCoupon(order, coupon));
}
"第三个测试用例,就是你昨晚那个 bug 的场景——满减类型的优惠券,discount 字段是 null。如果你写了这个测试,跑一下就会发现空指针。"
王志远盯着白板上的代码,心里在想:这三个测试用例加起来不到三十行,写起来大概十分钟。而昨晚的故障,从发现到修复花了两个小时,影响了三千笔订单。
十分钟 vs 两个小时 + 三千笔订单。
这笔账太好算了。
六
"还有一个更狠的玩法。"赵北辰说。"叫 TDD——测试驱动开发。"
"说白了就是:先写测试,再写代码。反着来。"
他在白板上画了一个循环:
┌─────────────────────────────┐
│ │
▼ │
Red(写一个失败的测试) │
│ │
▼ │
Green(写最少的代码让测试通过) │
│ │
▼ │
Refactor(重构代码,保持测试通过)│
│ │
└─────────────────────────────┘
"Red-Green-Refactor,三步循环。先写一个测试,跑一下,红的——因为功能还没实现。然后写最少的代码让测试变绿。最后重构代码,让它更干净,但测试必须一直是绿的。"
"这样做有什么好处?"王志远问。
"好处嘛,你慢慢体会。第一个,你还没写代码呢,就已经想明白了'这功能到底该怎么用'——测试用例本身就是说明书。第二个,你写的每一行代码都有测试兜着,不会出现'改了这头,那头塌了'的破事。第三个,你可以放心大胆地重构——测试全绿,你就踏实。"
王志远想了想:"但先写测试感觉很别扭。功能都没有,怎么写测试?"
"你别把测试当成'写完代码之后的验收'。TDD 里头,测试是帮你想清楚的工具。你先琢磨'我到底要什么',用测试把它写出来,然后再去实现。这就逼着你站在用的人那边想问题,而不是闷头写实现。"
七
那个周末,王志远尝试用 TDD 的方式重写了优惠券模块。
他先列出了所有需要覆盖的测试场景:
- 折扣类型:正常折扣(8 折)
- 折扣类型:折扣为 1(不打折)
- 折扣类型:折扣为 0(免费,应该拒绝)
- 满减类型:满 100 减 20,订单金额 100
- 满减类型:满 100 减 20,订单金额 80(不满足条件)
- 满减类型:满 100 减 120(减后为负数,应该为 0)
- 优惠券过期
- 优惠券为 null
- 订单金额为 0
- 订单金额为负数(异常情况)
十个场景。他一个一个地写测试,然后一个一个地实现。
写到第六个场景的时候,他发现了一个之前完全没想到的问题——如果满减金额大于订单金额,结果应该是 0 还是负数?他之前的代码没有处理这种情况,直接返回了负数。
如果不是先写测试,他可能永远不会想到这个边界情况。
写完所有测试后,他跑了一下——全绿。十个测试用例,覆盖了正常流程、边界条件、异常情况。
他看着终端上那一排绿色的对勾,心里有一种踏实感——这种踏实感是"手动测了一下,好像没问题"完全给不了的。
八
周一上班,他把重写后的代码提交了 PR。苏薇 Review 的时候,第一条评论是:
"测试覆盖率 92%,不错。但有两个建议。"
第一个建议:"你的测试用例命名可以更规范。用 方法名_场景_预期结果 的格式,比如 applyCoupon_withExpiredCoupon_shouldThrowException。这样看测试报告的时候,不用读代码就知道每个测试在验证什么。"
第二个建议:"你可以试试用 AI 辅助生成一些重复性的测试代码。比如你已经想好了十个测试场景,每个场景的结构都差不多——Given-When-Then。你可以先写两三个,然后让 Copilot 根据模式补全剩下的。但记住,AI 生成的测试用例一定要人工审查——AI 可能会生成看起来对但实际上断言错误的测试。"
王志远试了一下。他先手写了三个测试用例,然后让 Copilot 根据模式生成了剩下的。Copilot 确实很快——几秒钟就生成了七个测试用例。
但他逐一检查的时候,发现了两个问题:
第一个,Copilot 生成的一个测试用例里,断言的预期值算错了——满 100 减 20,订单金额 150,Copilot 写的预期结果是 110,实际应该是 130。
第二个,Copilot 漏掉了一个边界情况——折扣为 0 的场景。它生成了折扣为 0.1、0.5、1.0 的测试,但没有生成折扣为 0 的。
他把这两个问题修复了,然后在 PR 的评论里写了一段总结:
AI 辅助生成测试用例的工作流: 1. 自己先想清楚所有测试场景(正常、边界、异常) 2. 手写 2-3 个测试用例,建立模式 3. 让 AI 根据模式补全剩余的测试用例 4. 逐一审查 AI 生成的每一条断言——AI 会犯计算错误 5. 检查 AI 是否遗漏了边界场景——AI 倾向于生成"正常"的用例 6. 跑一遍所有测试,确认全绿
赵北辰看到这段总结后,在评论里回复了一句:
"这就对了——你拿主意,AI 跑腿,但活儿干得对不对,你得自己验。"
九
从那以后,王志远养成了一个习惯:每次写代码之前,先写测试。
一开始很不适应——总觉得"功能都没有,写什么测试"。但坚持了两周之后,他发现 TDD 有一个意想不到的好处:它让你在写代码之前就想清楚了要做什么。
以前他拿到需求就开始写代码,写着写着发现方向不对,推倒重来。现在他先写测试用例,把所有场景都列出来,相当于在写代码之前就做了一次"纸上推演"。等到真正写代码的时候,方向已经很清楚了。
他的代码质量也明显提高了。因为每次改动都有测试保护,他可以放心大胆地重构——改完之后跑一下测试,全绿就说明没有引入新问题。
更重要的是,他再也没有因为"改了这里,那里坏了"而出过线上故障。
有一次站会上,苏薇冷不丁说了一句:"别把测试当成写完代码之后的'附赠品'。没有测试的代码,就跟不系安全带开车一样——平时看不出区别,出事的时候你就知道了。"
十
那天晚上,王志远在笔记本上写下:
Day 90
凌晨两点的告警教会我一件事:偷懒省下的十分钟,会在最不合适的时候加倍还回来。
十分钟的测试 vs 两小时的故障 + 三千笔订单。这笔账,傻子都会算。
没有测试的代码就是没有安全带的汽车。你可以说"我开车技术好",但出了事故,物理定律不跟你讲道理。
他看着最后一行,想起那个凌晨两点的自己——手忙脚乱、后背冷汗、手指发抖。
他再也不想经历那种感觉了。
从今以后,每一行代码都要有测试。这不是规矩,是教训。
:::{admonition} 小胖的笔记 :class: tip
本章知识卡片:测试与 TDD
1. 测试金字塔
/\ E2E 测试:模拟用户操作,数量少,速度慢
/ \ 示例:Selenium、Cypress
/ \
/──────\ 集成测试:测试模块间交互,数量适中
/ \ 示例:Spring Boot Test、TestContainers
/──────────\
/ \ 单元测试:测试单个方法/类,数量多,速度快
/──────────────\ 示例:JUnit、Mockito
原则:大部分 bug 应该在单元测试层被发现。
2. TDD(测试驱动开发)
三步循环: 1. 🔴 Red:写一个失败的测试(功能还没实现) 2. 🟢 Green:写最少的代码让测试通过 3. 🔵 Refactor:重构代码,保持测试通过
TDD 的好处: - 写代码前就想清楚了"要做什么" - 每行代码都有测试覆盖 - 可以放心重构(测试是安全网) - 测试即文档(测试用例描述了功能的使用方式)
3. 测试用例设计
命名规范:方法名_场景_预期结果
applyCoupon_withValidDiscount_shouldReturnDiscountedPrice()
applyCoupon_withExpiredCoupon_shouldThrowException()
applyCoupon_withNullCoupon_shouldThrowIllegalArgumentException()
覆盖场景: | 类型 | 示例 | |------|------| | 正常流程 | 有效优惠券 + 正常订单 | | 边界条件 | 金额为 0、折扣为 1、刚好满足满减条件 | | 异常情况 | null 输入、过期优惠券、负数金额 | | 并发场景 | 多线程同时使用同一张优惠券 |
4. AI 辅助生成测试用例的工作流
1. 自己想清楚所有测试场景 ← 这一步不能交给 AI
2. 手写 2-3 个测试用例,建立模式
3. 让 AI 根据模式补全剩余用例
4. 逐一审查 AI 生成的断言 ← AI 会犯计算错误
5. 检查 AI 是否遗漏边界场景 ← AI 倾向于生成"正常"用例
6. 跑一遍所有测试,确认全绿
核心原则:你定方向,AI 跑腿,验收永远是你的事。
5. On-Call 流程
| 步骤 | 时间要求 | 动作 |
|---|---|---|
| 1. 确认告警 | 15 分钟内 | 查看监控,确认是否为真实故障 |
| 2. 评估影响 | 5 分钟 | 影响范围、用户数、严重程度 |
| 3. 通知相关人 | 立即 | 通知 Tech Lead 和相关模块负责人 |
| 4. 排查定位 | 尽快 | 查日志、查监控、查最近的代码变更 |
| 5. 修复上线 | 尽快 | hotfix 分支,简化 Review,快速部署 |
| 6. 事后复盘 | 48 小时内 | 无责复盘,找根本原因,定改进措施 |
:::