第八章 测试的力量

一

王志远第一次被半夜叫起来,是在入职第三个月的一个周四凌晨。

手机在枕头边疯狂震动。他迷迷糊糊地摸过来一看——钉钉群里一片红色的告警消息,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 的方式重写了优惠券模块。

他先列出了所有需要覆盖的测试场景:

  1. 折扣类型:正常折扣(8 折)
  2. 折扣类型:折扣为 1(不打折)
  3. 折扣类型:折扣为 0(免费,应该拒绝)
  4. 满减类型:满 100 减 20,订单金额 100
  5. 满减类型:满 100 减 20,订单金额 80(不满足条件)
  6. 满减类型:满 100 减 120(减后为负数,应该为 0)
  7. 优惠券过期
  8. 优惠券为 null
  9. 订单金额为 0
  10. 订单金额为负数(异常情况)

十个场景。他一个一个地写测试,然后一个一个地实现。

写到第六个场景的时候,他发现了一个之前完全没想到的问题——如果满减金额大于订单金额,结果应该是 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 小时内 无责复盘,找根本原因,定改进措施

:::