10.可测试性实战设计

10.可测试性实战设计

目录介绍
  • 01.95%覆盖率仍崩溃
    • [1.1 上线就出事的悲剧](#1.1 上线就出事的悲剧 "#11-%E4%B8%8A%E7%BA%BF%E5%B0%B1%E5%87%BA%E4%BA%8B%E7%9A%84%E6%82%B2%E5%89%A7")
    • [1.2 测试为何失效](#1.2 测试为何失效 "#12-%E6%B5%8B%E8%AF%95%E4%B8%BA%E4%BD%95%E5%A4%B1%E6%95%88")
    • [1.3 灵魂五连问](#1.3 灵魂五连问 "#13-%E7%81%B5%E9%AD%82%E4%BA%94%E8%BF%9E%E9%97%AE")
  • 02.可测试性是什么
    • [2.1 三个层次的定义](#2.1 三个层次的定义 "#21-%E4%B8%89%E4%B8%AA%E5%B1%82%E6%AC%A1%E7%9A%84%E5%AE%9A%E4%B9%89")
    • [2.2 与设计耦合的本质](#2.2 与设计耦合的本质 "#22-%E4%B8%8E%E8%AE%BE%E8%AE%A1%E8%80%A6%E5%90%88%E7%9A%84%E6%9C%AC%E8%B4%A8")
    • [2.3 OOP的最硬验收](#2.3 OOP的最硬验收 "#23-oop%E7%9A%84%E6%9C%80%E7%A1%AC%E9%AA%8C%E6%94%B6")
  • 03.不可测代码画像
    • [3.1 静态方法陷阱](#3.1 静态方法陷阱 "#31-%E9%9D%99%E6%80%81%E6%96%B9%E6%B3%95%E9%99%B7%E9%98%B1")
    • [3.2 单例的传染性](#3.2 单例的传染性 "#32-%E5%8D%95%E4%BE%8B%E7%9A%84%E4%BC%A0%E6%9F%93%E6%80%A7")
    • [3.3 全局时间获取](#3.3 全局时间获取 "#33-%E5%85%A8%E5%B1%80%E6%97%B6%E9%97%B4%E8%8E%B7%E5%8F%96")
    • [3.4 私有方法依恋](#3.4 私有方法依恋 "#34-%E7%A7%81%E6%9C%89%E6%96%B9%E6%B3%95%E4%BE%9D%E6%81%8B")
    • [3.5 隐式IO访问](#3.5 隐式IO访问 "#35-%E9%9A%90%E5%BC%8Fio%E8%AE%BF%E9%97%AE")
  • 04.可测设计五原则
    • [4.1 依赖注入](#4.1 依赖注入 "#41-%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5")
    • [4.2 时间抽象化](#4.2 时间抽象化 "#42-%E6%97%B6%E9%97%B4%E6%8A%BD%E8%B1%A1%E5%8C%96")
    • [4.3 IO边界化](#4.3 IO边界化 "#43-io%E8%BE%B9%E7%95%8C%E5%8C%96")
    • [4.4 副作用集中化](#4.4 副作用集中化 "#44-%E5%89%AF%E4%BD%9C%E7%94%A8%E9%9B%86%E4%B8%AD%E5%8C%96")
    • [4.5 纯函数优先](#4.5 纯函数优先 "#45-%E7%BA%AF%E5%87%BD%E6%95%B0%E4%BC%98%E5%85%88")
  • 05.测试金字塔
    • [5.1 单元/集成/E2E](#5.1 单元/集成/E2E "#51-%E5%8D%95%E5%85%83%E9%9B%86%E6%88%90e2e")
    • [5.2 比例与成本](#5.2 比例与成本 "#52-%E6%AF%94%E4%BE%8B%E4%B8%8E%E6%88%90%E6%9C%AC")
    • [5.3 倒金字塔反模式](#5.3 倒金字塔反模式 "#53-%E5%80%92%E9%87%91%E5%AD%97%E5%A1%94%E5%8F%8D%E6%A8%A1%E5%BC%8F")
  • 06.Mock与Stub
    • [6.1 五种测试替身](#6.1 五种测试替身 "#61-%E4%BA%94%E7%A7%8D%E6%B5%8B%E8%AF%95%E6%9B%BF%E8%BA%AB")
    • [6.2 Mock滥用症](#6.2 Mock滥用症 "#62-mock%E6%BB%A5%E7%94%A8%E7%97%87")
    • [6.3 接口与可替换性](#6.3 接口与可替换性 "#63-%E6%8E%A5%E5%8F%A3%E4%B8%8E%E5%8F%AF%E6%9B%BF%E6%8D%A2%E6%80%A7")
  • 07.TDD与设计反馈
    • [7.1 红绿重构循环](#7.1 红绿重构循环 "#71-%E7%BA%A2%E7%BB%BF%E9%87%8D%E6%9E%84%E5%BE%AA%E7%8E%AF")
    • [7.2 测试先行的设计驱动](#7.2 测试先行的设计驱动 "#72-%E6%B5%8B%E8%AF%95%E5%85%88%E8%A1%8C%E7%9A%84%E8%AE%BE%E8%AE%A1%E9%A9%B1%E5%8A%A8")
    • [7.3 何时不要TDD](#7.3 何时不要TDD "#73-%E4%BD%95%E6%97%B6%E4%B8%8D%E8%A6%81tdd")
  • 08.覆盖率的真相
    • [8.1 行/分支/路径覆盖](#8.1 行/分支/路径覆盖 "#81-%E8%A1%8C%E5%88%86%E6%94%AF%E8%B7%AF%E5%BE%84%E8%A6%86%E7%9B%96")
    • [8.2 100%覆盖的陷阱](#8.2 100%覆盖的陷阱 "#82-100%E8%A6%86%E7%9B%96%E7%9A%84%E9%99%B7%E9%98%B1")
    • [8.3 变异测试](#8.3 变异测试 "#83-%E5%8F%98%E5%BC%82%E6%B5%8B%E8%AF%95")
  • 09.综合案例实战
    • [9.1 不可测的订单系统](#9.1 不可测的订单系统 "#91-%E4%B8%8D%E5%8F%AF%E6%B5%8B%E7%9A%84%E8%AE%A2%E5%8D%95%E7%B3%BB%E7%BB%9F")
    • [9.2 五步可测改造](#9.2 五步可测改造 "#92-%E4%BA%94%E6%AD%A5%E5%8F%AF%E6%B5%8B%E6%94%B9%E9%80%A0")
    • [9.3 测试套件演化](#9.3 测试套件演化 "#93-%E6%B5%8B%E8%AF%95%E5%A5%97%E4%BB%B6%E6%BC%94%E5%8C%96")
    • [9.4 留下三道思考题](#9.4 留下三道思考题 "#94-%E7%95%99%E4%B8%8B%E4%B8%89%E9%81%93%E6%80%9D%E8%80%83%E9%A2%98")
  • 10.总结与下一篇

# 章节 主题 关键案例 关键图表
01 面向对象设计思想 范式之别 双 11 雪崩订单系统 网状 vs 线性流程图
02 面向对象的特性 封装/抽象/继承/多态 钱包余额对不上排查 vtable 内存布局图
03 接口vs抽象类比较 何时选哪个 日志器 47 次改动复盘 决策树、模板时序图
04 接口而非实现编程 依赖抽象不依赖具体 云迁移 6 万行连锁修改 三步重构演化图
05 多用组合和少继承 继承陷阱与组合优雅 企鹅会飞继承翻车 类爆炸 vs 正交组合
06 设计原则全景图 SOLID + 23 模式 8000 行上帝类失控 SOLID 关系图
07 SOLID 原则案例汇 SOLID 在战场上 团队三年 SOLID 之争 滥用现场扫描图
08 反模式与坏味道 30+ 嗅觉清单 PR 被驳回 17 次 坏味道地图
09 重构十二式实战 12 个最常用手法 1200 行结算函数救援 重构节奏循环
10 可测试性设计 OOP 的硬验收 95% 覆盖率上线即崩 测试金字塔
11 DDD 与战术建模 从代码到业务 需求文档 70% 失真 限界上下文图
12 综合实战图片框架 完整系统实现 图片处理系统实战 完整架构图

01.95%覆盖率仍崩溃

1.1 上线就出事的悲剧

2024 年 11 月,某交易系统大版本上线。CI 全绿、覆盖率 94.7% ,团队开庆功 PR 通过。周一上线,周二崩了

事故时间线

makefile 复制代码
00:00  灰度 5%        监控全绿
01:30  扩量到 30%     P99 延时翻倍
02:10  扩量到 80%     大量「订单状态错乱」工单
03:45  紧急回滚       告警雪崩,最高峰 1200 条/分钟
05:00  事故定级 P0

事故根因,一段「新增的优惠券满减规则」:

java 复制代码
public BigDecimal applyDiscount(Order order, Coupon coupon) {
    if (order.amount().compareTo(coupon.threshold()) >= 0) {
        return order.amount().subtract(coupon.discount());
    }
    return order.amount();
}

代码看似无错,测试也通过

java 复制代码
@Test void shouldApplyDiscount() {
    Order order = new Order(BigDecimal.valueOf(100));
    Coupon coupon = new Coupon(BigDecimal.valueOf(50), BigDecimal.valueOf(10));
    BigDecimal result = applyDiscount(order, coupon);
    assertEquals(BigDecimal.valueOf(90), result);    // ✓ 通过
}

但事故现场出现的真实订单order.amount = ¥4.99coupon = 满 5 元减 1 元

scss 复制代码
applyDiscount 返回 ¥4.99   ← 没满 5 元,不打折(对的)

业务规则 是:「满 5 元的"满"指的是 ≥ 5 元 」,而代码 order.amount.compareTo(coupon.threshold()) >= 04.99 vs 5返回 -1,即不满足,这一支也是对的。

flowchart LR 实际原价[订单原价 ¥10] --> 减运费[减运费 ¥5] --> 传入函数[传入 ¥4.99] 传入函数 --> 函数内[函数内判 4.99 >= 5: 不满足] 业务期望[业务期望: 原价 ¥10 满足] -.冲突.-> 函数内

测试为什么没发现? 因为 95% 覆盖率全部覆盖在「函数内部行为 」上,applyDiscount 自己的输入输出完全对 。问题在于「调用方传入的输入语义跟函数预期的语义不一致 」。这个语义鸿沟 没有任何一个单测能发现,因为单测是在 mock 数据上跑的

覆盖率测的是"代码被跑过",不是"业务规则被验证"。 这是 95% 覆盖率仍崩溃的根本机制。

1.2 测试为何失效

把这次事故当作放大镜,可以看到三层失效

flowchart TD Cov[95% 覆盖率] --> L1[行为对应错误<br/>测了代码,没测意图] Cov --> L2[Mock 错位<br/>Mock 的输入跟生产不一样] Cov --> L3[隔离不足<br/>单测无法暴露集成时的语义冲突] L1 & L2 & L3 --> Fail[上线即崩]
层次 失效形式
形似神不似 测试在调函数,验证返回值。但没有任何一个测试在断言"业务规则"
Mock 制造温室 单测里 new Order(¥100) 这种"教科书数据"把现实里的 ¥4.99 + 运费 ¥5 隐藏了
缺少契约边界 applyDiscount 没有用类型/文档明确「amount 是原价还是减运费后」,任何调用方都可以猜错

真正的问题不在测试 ,而在测试测了不该测的对象。本篇要重新定义"什么是值得测的"。

1.3 灵魂五连问

ruby 复制代码
Q1 ── 测试到底在测什么?是代码,还是意图?
       └─→ §01.1 三个层次
Q2 ── 为什么有的代码"无从下手测"?
       └─→ §02 不可测代码画像
Q3 ── 可测性与设计的关系是什么?是相关,还是因果?
       └─→ §01.2 充要条件
Q4 ── 是先有可测设计,还是先有测试驱动设计?
       └─→ §06.2 TDD 与设计反馈
Q5 ── 高覆盖率为何解决不了崩溃?
       └─→ §07.2 + §07.3 变异测试

02.可测试性是什么

2.1 三个层次的定义

学术上,可测试性是三个独立 能力的合成(Robert Binder 1994 Design for Testability):

层次 定义 工程含义
可观测性(Observability) 测试代码能看到被测代码的所有相关状态 内部状态有 getter / 事件能被监听
可控制性(Controllability) 测试代码能精确驱动被测代码到任意状态 依赖能被替换,初始状态能被构造
可隔离性(Isolability) 被测代码能与外部世界断开运行 文件/网络/数据库都能 mock
flowchart TB Test[一个好的单测] --> O[可观测<br/>看得见结果] Test --> C[可控制<br/>能造出任意输入] Test --> I[可隔离<br/>跑得不慢,跑得稳定] O & C & I -.三者缺一就难测.-> Fail[要么慢,要么 flaky,要么测不到关键点]

任何一层缺失,单测都会变味。比如 §0.1 事故的代码:

  • 可观测:✓ 返回值能看到
  • 可控制:⚠️ 只能传入「函数自己接受的 amount」,控制不到上游
  • 可隔离:✓ 函数本身没有 IO

控制力被一层隔板挡住,这就是 95% 覆盖率仍漏的根源。

2.2 与设计耦合的本质

「不可测 = 设计错」是充要条件,这是 OOP 的最硬验收。

证明思路:

flowchart LR Untestable[不可测] -->|<==>| BadDesign[设计违反 SOLID] Untestable -.充分.-> Bd1[必然违反 DIP / SRP / ISP 之一] BadDesign -.必要.-> Ut1[违反 SOLID 的代码必然难测]
  • 不可测 → 设计错:不可测意味着无法 mock、无法注入、无法隔离,这必然指向 DIP 或 SRP 违反。
  • 设计错 → 不可测 :违反 DIP 的代码 new 了具体类,无法替换;违反 SRP 的代码一个方法干 5 件事,要 mock 全部副作用,不可测

测试不是"另一项工作",而是"设计的镜子" 。一段代码能否单测,直接等价于它的设计水平。

2.3 OOP的最硬验收

02 篇讲了封装、抽象、继承、多态。06 篇讲了 SOLID。但这些都是「主观判断」,同一段代码不同人会有不同评价。

单测是唯一的客观验收

主观验收 客观验收
"这段代码遵守 SOLID 吗?" "这段代码能写单测吗?"
5 个工程师 5 个答案 答案是 yes 或 no
评审 30 分钟争吵 30 秒尝试就有结果

能写单测 = 设计达标。这是 OOP 走过 30 年得到的最硬验收标准。


03.不可测代码画像

3.1 静态方法陷阱

java 复制代码
// 反例
public class OrderUtil {
    public static String genOrderId(String userId) {
        return userId + "-" + System.currentTimeMillis() + "-" + new Random().nextInt(1000);
    }
}

为什么不可测

  • 调用 OrderUtil.genOrderId(...) 无法 mock,静态调用钉死类型
  • 内部用了 currentTimeMillis()Random ,返回值每次都不同

修复 :把"工具函数"变成实例方法 + 接口

java 复制代码
public interface OrderIdGenerator {
    String generate(String userId);
}
public class DefaultOrderIdGenerator implements OrderIdGenerator {
    private final Clock clock;
    private final Supplier<Integer> randomSource;
    public DefaultOrderIdGenerator(Clock clock, Supplier<Integer> randomSource) { ... }
    public String generate(String userId) {
        return userId + "-" + clock.millis() + "-" + randomSource.get();
    }
}
// 测试时
var gen = new DefaultOrderIdGenerator(Clock.fixed(...), () -> 42);
assertEquals("u1-1000000-42", gen.generate("u1"));

静态方法是 OOP 的"逃生舱口",但每用一次,都在牺牲可测性

3.2 单例的传染性

java 复制代码
public class OrderManager {
    private static OrderManager INSTANCE = new OrderManager();
    public static OrderManager getInstance() { return INSTANCE; }
}

public class OrderService {
    public void place(Order o) {
        OrderManager.getInstance().save(o);   // ← 钉死
    }
}

**「传染」**怎么发生:

  • OrderService 内部硬编码 OrderManager.getInstance()
  • 测试 OrderService无法替换 OrderManager
  • 要么不测,要么"曲线救国"用 PowerMock 改字节码

修复:构造器注入(与 07 篇 DIP 同一道理)。

3.3 全局时间获取

测试不稳定(flaky test)的头号源头 就是直接调 System.currentTimeMillis()LocalDateTime.now()

java 复制代码
// 反例
public class CouponService {
    public boolean isValid(Coupon c) {
        return LocalDateTime.now().isBefore(c.expireAt());
    }
}

@Test void shouldBeValidIfNotExpired() {
    Coupon c = new Coupon(LocalDateTime.now().plusDays(1));   // 测试当下生效
    assertTrue(service.isValid(c));
    // ↑ 跑得过一年后吗? 不,因为 plusDays(1) 是相对当下,但生产可能是绝对时间
}

修复 :抽出 Clock 接口,永远不在业务代码里直接读"现在"

java 复制代码
public class CouponService {
    private final Clock clock;
    public CouponService(Clock clock) { this.clock = clock; }
    public boolean isValid(Coupon c) {
        return clock.now().isBefore(c.expireAt());
    }
}
@Test void shouldBeValidIfNotExpired() {
    var fixed = Clock.fixed(LocalDateTime.of(2025,1,1,0,0));
    var service = new CouponService(fixed);
    assertTrue(service.isValid(new Coupon(LocalDateTime.of(2025,12,31,0,0))));
}

3.4 私有方法依恋

java 复制代码
class PriceCalculator {
    public BigDecimal calculate(Order o) {
        BigDecimal base = computeBase(o);
        return applyTax(base);
    }
    private BigDecimal computeBase(Order o) { ... }   // 30 行核心算法
    private BigDecimal applyTax(BigDecimal b)  { ... }
}

测试想直接测 computeBase,但它是 private。要测它,只能通过反射或改 visibility

信号 :私有方法复杂到值得单独测 ,意味着它应该是另一个类的公共方法

java 复制代码
class PriceBaseCalculator { public BigDecimal compute(Order o) { ... } }   // 提炼出来
class TaxApplier          { public BigDecimal apply(BigDecimal b) { ... } }

class PriceCalculator {
    public PriceCalculator(PriceBaseCalculator b, TaxApplier t) { ... }
    public BigDecimal calculate(Order o) { return tax.apply(base.compute(o)); }
}

"想反射测私有方法 "≡"设计漏掉了一个类"。

3.5 隐式IO访问

java 复制代码
public class ReportService {
    public Report generate(Date d) {
        String sql = "SELECT ...";
        try (Connection c = DriverManager.getConnection("jdbc:...")) {  // ← 隐式 IO
            ...
        }
        File template = new File("/etc/templates/report.html");          // ← 隐式 IO
        ...
        URL slack = new URL("https://hooks.slack.com/...");              // ← 隐式 IO
        ...
    }
}

测一次要起数据库、读文件系统、连 Slack,最终团队放弃,只做集成测试,于是金字塔倒置(§04.3)。

修复 :让所有 IO 走注入的接口,见 §03.3 IO 边界化。


04.可测设计五原则

4.1 依赖注入

最基本:任何外部依赖都通过构造方法传入

java 复制代码
// 不要这样
class OrderService {
    private OrderRepo repo = new MysqlOrderRepo();    // ← 钉死
}
// 要这样
class OrderService {
    private final OrderRepo repo;
    public OrderService(OrderRepo repo) { this.repo = repo; }   // ← 可注入
}

这与 07 篇 §05 DIP 是同一件事的两个视角。DIP 是设计原则,DI 是落地手段

4.2 时间抽象化

java 复制代码
public interface Clock {
    Instant now();
}
public class SystemClock implements Clock {
    public Instant now() { return Instant.now(); }
}
public class FakeClock implements Clock {
    private Instant fixed;
    public void set(Instant t) { this.fixed = t; }
    public Instant now() { return fixed; }
}

所有业务代码注入 Clock,永远不直接调 Instant.now()

各语言对照:

语言 抽象方式
Java java.time.Clock
Kotlin 自定义 Clock 接口 + 协程 TestDispatcher
Go clock.Clock 接口(jonboulle/clockwork)
C# IClock + SystemClock/FakeClock

4.3 IO边界化

Hexagonal Architecture(六边形架构),核心思想:

flowchart TB subgraph 内核[纯业务核心] D[Domain Logic] end subgraph 端口[端口接口] P1[OrderRepo] P2[PaymentGateway] P3[Clock] P4[FileStore] end subgraph 适配器[适配器实现] A1[MysqlOrderRepo] A2[StripePaymentGateway] A3[SystemClock] A4[S3FileStore] end D --> P1 & P2 & P3 & P4 P1 -.实现.- A1 P2 -.实现.- A2 P3 -.实现.- A3 P4 -.实现.- A4

好处

  • 内核不知道 MySQL/Stripe/S3 的存在,可以用 InMemoryOrderRepo 跑全套单测
  • 适配器单独测试(用集成测试)
  • 切换 Stripe 到 PayPal = 加一个适配器

4.4 副作用集中化

「副作用」 = 改外部状态(写库、发消息、改文件)。

java 复制代码
// 反例:副作用散在各处
class OrderService {
    public void process(Order o) {
        repo.save(o);                        // 副作用 1
        kafka.send("order-paid", o);          // 副作用 2
        emailer.notify(o.user());             // 副作用 3
        bi.report(o);                         // 副作用 4
        log.info("processed " + o.id());      // 副作用 5
    }
}

测试要 mock 5 个外部依赖。

修复返回"决定",不直接执行,CQRS 思想:

java 复制代码
class OrderProcessor {
    /** 纯函数:返回应当发生的事件,不做任何副作用 */
    public List<Event> decide(Order o) {
        return List.of(
            new OrderSaved(o),
            new EventEmitted("order-paid", o),
            new EmailDispatched(o.user(), ...),
            new BiReportPushed(o),
            new AuditLogged(o));
    }
}
class OrderHandler {
    public void apply(List<Event> events) { /* 这里执行副作用 */ }
}

单测 :直接测 decide 返回的事件列表,全程纯函数 。集成测试再测 apply

4.5 纯函数优先

java 复制代码
// 纯函数:一切被测代码的天堂
public BigDecimal price(Order order, Discount discount) { ... }

特征:

  • 同样输入永远同样输出 (没有 I/O、没有 now()、没有随机)
  • 不改外部状态

测纯函数 :写两行就行,assertEquals(expected, fn(input))

OOP 的设计目标,应该是把「纯函数」的占比尽量做高。这与 §03.4 副作用集中化是同一道理的两面。


05.测试金字塔

5.1 单元/集成/E2E

flowchart TB subgraph 金字塔 U[单元测试 Unit<br/>≤ 10ms / 个] I[集成测试 Integration<br/>10-1000ms] E[E2E 测试<br/>1-30s] end U --> I --> E
范围 速度 数量 修复成本
Unit 单类/单函数 < 10 ms 数千
Integration 多模块/真实数据库 100-1000 ms 数百
E2E 全链路浏览器/全 API 1-30 s 数十

5.2 比例与成本

经典 70 / 20 / 10 的来源(Mike Cohn《Succeeding with Agile》2009):

数量比 成本比 反馈延时
70% Unit 10% 秒级
20% Integ 30% 分钟级
10% E2E 60% 小时级

单元测试是最高 ROI:写得快、跑得快、修得快。

5.3 倒金字塔反模式

flowchart TB subgraph 倒置反模式 E2[E2E 测试 80%] I2[Integration 15%] U2[Unit 5%] end E2 --> I2 --> U2

为什么团队会倒置?

  • Unit 写不动(代码不可测)
  • 团队感觉 E2E "更真实"
  • 不知道 Unit 的力量

后果

  • CI 跑 1 小时,开发者推 PR 后吃完午饭才知道挂了
  • 一个改动经常 100 个 E2E 同时挂,根因是哪一行? 谁也不知道
  • 测试维护成本超过业务代码

倒金字塔的根因是 §02 不可测代码画像,先治根因,再谈金字塔


06.Mock与Stub

6.1 五种测试替身

Gerard Meszaros《xUnit Test Patterns》定义的五种「Test Double」:

类型 行为 用途
Dummy 占位,不会被调用 满足参数列表
Fake 简化的真实实现 InMemoryRepo 替代 MySQL
Stub 返回预设值 让被测代码走特定分支
Spy Stub + 记录调用 验证「被调过吗?」
Mock Spy + 预期断言 验证「按预期调了吗?」
java 复制代码
// Stub:简单返回
when(repo.find(any())).thenReturn(...);
when(gateway.pay(any())).thenReturn(...);
when(emailer.send(any(), any())).thenReturn(...);
when(clock.now()).thenReturn(...);
when(bi.report(any())).thenReturn(...);
// ↑ 50 行 mock 设置,真正的"测试"只有 2 行

6.2 Mock滥用症

症状 1:所有依赖都 mock。

java 复制代码
@Test void test() {
    when(repo.find(any())).thenReturn(...);
    when(gateway.pay(any())).thenReturn(...);
    when(emailer.send(any(), any())).thenReturn(...);
    when(clock.now()).thenReturn(...);
    when(bi.report(any())).thenReturn(...);
    // ↑ 50 行 mock 设置,真正的"测试"只有 2 行
}

问题这测的是"我设置的 mock 对不对",不是真实代码 。如果 mock 设置错了,测试对真实代码错误完全无感。

症状 2:用 Mock 验证内部实现。

java 复制代码
verify(repo, times(3)).find(any());   // ← 测了"内部调了 3 次"

如果将来重构成 1 次批量查找,业务正确,但测试挂 ,这是被测代码与测试代码过度耦合的反面教材。

修复方法

  • Fake 优于 Mock ,用 InMemoryRepo 这种小型真实实现,不验证调用次数
  • 只 mock 边界(DB、HTTP、外部 API),不 mock 自己代码内部

6.3 接口与可替换性

Mock 能不能注入 = 是不是依赖了接口

java 复制代码
// 不可 mock
class OrderService {
    private final MysqlOrderRepo repo;     // ← 具体类
}
// 可 mock
class OrderService {
    private final OrderRepo repo;          // ← 接口
}

与 04 篇「面向接口编程」首尾呼应,面向接口编程的真正回报,要在写测试时才兑现


07.TDD与设计反馈

7.1 红绿重构循环

Kent Beck 的三步舞:

flowchart LR Red[1. 红<br/>写一个失败的测试] --> Green[2. 绿<br/>用最简代码让它过] Green --> Refactor[3. 重构<br/>清理代码,测试仍过] Refactor --> Red

关键纪律

  • 每一步都很小,5-15 分钟一个循环
  • 绿之前不要重构
  • 绿之后必须重构(否则积累技术债)

7.2 测试先行的设计驱动

TDD 不是"测试方法",而是"设计方法 "。先写测试强迫你回答:

调用方期望这个 API 是什么样?

java 复制代码
// 测试先写
@Test void shouldChargeWithRetry() {
    var gateway = new RetryingPaymentGateway(realGateway, 3);
    var result = gateway.charge(order);
    assertEquals(SUCCESS, result.status());
}
// ↑ 这一段就把 RetryingPaymentGateway 的接口、构造参数、返回值全设计了

没写测试的设计 容易过度,你猜未来需要 12 个方法,结果用 3 个。写测试的设计精确,只有调用方真要的方法才会出现。

7.3 何时不要TDD

TDD 不是万能。以下场景反而是负担

场景 为何不适合
探索性原型 需求都没定,测试是浪费
UI 实验 视觉效果难以测试断言
算法尝试(先调通再说) 解空间不明确
学习新框架 重点是熟悉 API

TDD 适合:业务规则清晰、接口稳定、需要长期演进的代码。


08.覆盖率的真相

8.1 行/分支/路径覆盖

三种强度递增:

类型 含义 强度
行覆盖 代码行被执行过
分支覆盖 if/switch 的每个分支都走过
路径覆盖 所有可能的执行路径组合
java 复制代码
public int f(int a, int b) {
    int x = 0;
    if (a > 0) x = 1;       // 分支 1
    if (b > 0) x = x + 2;   // 分支 2
    return x;
}
用例 行覆盖 分支覆盖 路径覆盖
f(1, 1) 100% 50% (只走了 true/true) 25% (只走 1/4)
f(1, 1) + f(0, 0) 100% 100% 50% (2/4)
全部 4 组 100% 100% 100%

行覆盖会骗人,它满足时分支可能只走了一半。

8.2 100%覆盖的陷阱

java 复制代码
@Test void shouldNotCrash() {
    service.run();   // 不抛异常
}

100% 覆盖率,但什么也没断言 。这种「虚假覆盖」在 §0.1 的事故里就是凶手,业务规则被绕过,但代码"被走过"了。

真相 :覆盖率是必要不充分 条件。低覆盖率 = 一定有未测到的代码;高覆盖率 ≠ 一定测得好

8.3 变异测试

变异测试(Mutation Testing):故意改坏代码,看测试是否能"杀死变异体"。

java 复制代码
原代码:  if (a > 0) ...
变异体:  if (a >= 0) ...    ← 工具自动改
变异体:  if (a < 0) ...     ← 工具自动改

如果你的测试在变异体下还能通过**,说明测试不够灵敏**。

工具:

  • Java:PIT (PITest)
  • JS:Stryker
  • Python:mutmut
bash 复制代码
mvn org.pitest:pitest-maven:mutationCoverage
# 输出: Killed Mutants: 87/120 (72.5%)   ← 这才是有意义的指标

变异测试覆盖率 比传统覆盖率更接近"测试质量",它逼测试真的去断言行为,而不是"代码被走过"。


09.综合案例实战

主线接力,09 篇我们重构了 RefundService,但没写测试。如果有人问「你怎么证明重构没引入 Bug」?本节回答这个问题。

9.1 不可测的订单系统

09 篇遗留的"半成品"代码,OrderProcessor.process(Order o)

java 复制代码
public class OrderProcessor {
    public OrderResult process(Order o) throws Exception {
        // 1. 时间判断
        if (LocalDateTime.now().getHour() < 6) {                    // 隐式时间
            throw new ServiceClosedException();
        }
        // 2. 风控
        if (RiskService.getInstance().isRisky(o)) {                 // 单例
            ...
        }
        // 3. 数据库
        try (Connection c = DriverManager.getConnection(...)) {     // 隐式 IO
            ...
        }
        // 4. HTTP 通知
        new RestTemplate().postForObject(                           // 内部 new
            "https://api.notify.com", o.toJson(), String.class);
        // 5. 写文件
        Files.writeString(Path.of("/var/log/order.log"), o.toString());  // 隐式 IO
        return ...;
    }
}

尝试写第一个单测,立刻发现 5 处障碍:

java 复制代码
@Test void shouldReturnSuccess() {
    var processor = new OrderProcessor();
    var result = processor.process(new Order(...));
    assertEquals(SUCCESS, result.status());
}

报错:

bash 复制代码
✗ ServiceClosedException (凌晨跑测试就过不了)
✗ 静态单例无法 mock
✗ JDBC 找不到驱动
✗ HTTPS 网络超时
✗ 写 /var/log 没权限

5 个单测 0 个能跑,典型不可测代码。

9.2 五步可测改造

Step 1·依赖注入,所有外部依赖参数化:

java 复制代码
public class OrderProcessor {
    public OrderProcessor(Clock clock, RiskService risk, OrderRepo repo,
                          NotificationGateway notify, AuditLog audit) { ... }
}

Step 2·时间抽象化

java 复制代码
if (clock.now().atZone(ZoneId.systemDefault()).getHour() < 6) ...

Step 3·IO 边界化 ,给 DB / HTTP / 文件系统接口

java 复制代码
public interface OrderRepo            { void save(Order o); }
public interface NotificationGateway  { void notify(Order o); }
public interface AuditLog             { void log(String message); }

Step 4·副作用集中化 ,把 5 步变成「计算事件 → 应用事件」:

java 复制代码
public class OrderProcessor {
    public List<Event> decide(Order o) {
        if (clock.now().getHour() < 6) return List.of(new Rejected("closed"));
        if (risk.isRisky(o)) return List.of(new Rejected("risk"));
        return List.of(new Saved(o), new Notified(o), new Audited(o));
    }
}

Step 5·纯函数优先decide纯函数:相同输入永远相同输出,没有任何副作用。

9.3 测试套件演化

可测改造完成后,测试可以这么写:

第 1 类:纯函数单测(30 个)

java 复制代码
@Test void shouldRejectAfterMidnight() {
    var fixed = Clock.fixed(LocalDateTime.of(2025,1,1,3,0).toInstant(...));
    var p = new OrderProcessor(fixed, alwaysSafeRisk, ...);
    var events = p.decide(new Order(...));
    assertEquals(List.of(new Rejected("closed")), events);
}

@Test void shouldRejectIfRisky() { ... }
@Test void shouldEmitFiveEventsForNormalOrder() { ... }
... 30 个用例,每个 < 5ms,全部秒级跑完

第 2 类:集成测试(5 个) ,验证适配器(数据库、HTTP)真的能存能发

java 复制代码
@Test void mysqlRepoCanPersist() { ... }
@Test void httpGatewayCanNotify() { ... }
... 5 个用例,跑 30 秒

第 3 类:E2E(1 个),验证全链路:

java 复制代码
@Test void e2eHappyPath() { ... }
... 1 个用例,跑 1 分钟

金字塔比例:30 / 5 / 1 ≈ 83% / 14% / 3%,比经典 70/20/10 还更陡。

flowchart TB Before[改造前: 0 个单测<br/>不能上 CI] --> After[改造后: 30 单测+5 集成+1 E2E<br/>2 分钟全跑完] Before -.事故.-> Crash[上线即崩 §0.1] After -.防御.-> Stable[变更敢推]

9.4 留下三道思考题

答案在第 11 篇 DDD 开头揭晓。

  • 🟢 易 :§8.2 我把 decide 写成返回 List<Event> 的纯函数。业界还有一种叫 "事件溯源(Event Sourcing)" 的架构,把状态通过事件累积出来 。这两种设计有什么共同点
  • 🟡 中 :§8.3 测试套件里"30 个纯函数单测"看起来很美好。但是**「业务规则只规定了订单凌晨 6 点前不接单」,你写了 30 个用例,是不是过测了**?另一面,凌晨 6 点这个边界值 呢?写测试时应该如何精确地只覆盖业务规则、不多不少?
  • 🔴 难 :本节最大的进步是把"控制流"从代码中剥离成"事件"。但这种剥离是有上限的 ,比如 clock.now() 我们抽象了,但**「读取当前用户的余额」呢?这个值依赖 外部状态**而非函数参数。如何把这种"带外部状态依赖 "的纯函数也变得可测?提示:这道题就是 11 篇 DDD「聚合根 + 仓储 」要回答的问题,用聚合根把外部状态"装进对象",让方法看起来仍是纯函数。

10.总结与下一篇

一句话总结

不可测的代码,是设计在向你求救。每一次"测不动"都是 SOLID 在喊救命,你越早听见,付出的代价越小。

与全系列关系,

flowchart LR P02[02 OOP 特性] --> P06[06 SOLID] P06 --> P07[07 SOLID 案例汇] P07 --> P08[08 坏味道大全] P08 --> P09[09 重构十二式] P09 --> P10[10 可测试性<br/>本篇] P10 --> P11[11 DDD 战术建模] style P10 fill:#fef3c7

01-09 解决「代码层 」的设计,类怎么写、接口怎么用、错代码怎么救、测试怎么保 。但代码层之上还有「业务层」,

当业务规则本身复杂到一定程度,"代码层的好"也救不了"业务建模的错"。这就是 DDD 要登场的舞台。

下一篇 11.DDD与战术建模,把 SOLID 推到限界上下文层级,用聚合根、领域事件、仓储等战术工具,让业务的"形"决定代码的"型"。本篇 §8.4 三道思考题答案,也将在下一篇 §0 揭晓。

相关推荐
杨充1 小时前
9.重构十二式的实战
设计模式·开源·代码规范
OpenAnolis小助手1 小时前
龙蜥 AI Infra 新力量:T-Head SAIL 软件栈正式开源
开源·龙蜥社区·ai infra·t-head sail
杨充1 小时前
6.设计原则的全景图
设计模式·开源·全栈
杨充1 小时前
2.面向对象的特性
设计模式
杨充1 小时前
7.SOLID原则案例汇
设计模式·开源·全栈
杨充1 小时前
8.反模式与坏味道
设计模式·开源·代码规范
梦梦代码精1 小时前
基于ThinkPHP6 + Vue3的家政预约系统全解析:从LBS定位到自动派单的完整实现
java·docker·开源·php·代码规范
杨充1 小时前
3.接口vs抽象类比较
设计模式
yunwei372 小时前
eBPF 教程:检查 exec 后真正安装的可执行镜像
linux·开源