第 09 章(节选) 核心结论与深度思考题

来自第 09 章「第 09 章 单元测试与可测试性:敢改别人的代码」。 返回:第 09 章索引

09.9 本章核心结论

  1. 测试难写,是代码的问题,不是测试的问题。 在方法里 new 依赖、调用 static 方法、一个方法干五件事 —— 这三种模式是”不可测试”的三大根源。

  2. 可测试性的四个原则:构造器注入依赖、只在边界处抽接口、纯函数优先(函数式核心 + 命令式外壳)、时间和随机数和 IO 都必须可替换(Clock / RandomGenerator / 接口)。

  3. “为了 mock 而抽接口”在 2020 年之后就不成立了 —— 现代 Mockito 用 ByteBuddy 生成子类,能 mock 具体类,Mockito 5 起连 final 和 static 都能 mock。抽接口的理由只能是”抽象和依赖方向治理”。

  4. Mock 的三个层次里,Fake 通常比 Mock 更好。 Mock 验证”调用发生了”(适合副作用),Fake 验证”结果是对的”(适合数据访问)。Mock 的成本是测试与实现耦合 —— 行为不变的重构会让 mock 测试变红。

  5. @SpringBootTest 是最后的选择,不是默认选择。 业务测试应该 new 出 service 传 mock 进去(毫秒级),容器测试只用于验证装配。

  6. @MockBean 会让 Spring 的 ApplicationContext 缓存失效并重建 —— 不同的 mock 组合 = 不同的上下文 = 每次完整启动。这是 Java 项目 CI 慢的头号原因,解法是共享 mock 组合的抽象基类 + 切片测试。

  7. H2 测出来的通过不等于生产能跑。 涉及锁、事务隔离级别、数据库特有语法、并发的地方,必须用 Testcontainers 起真实中间件,并且用单例容器模式共享。

  8. 给老代码补测试的起点是”接缝”,第一步是特征测试(记录当前行为,哪怕它是 bug),然后才是提取依赖、打破静态、拆分方法、加断言。

  9. 覆盖率只能用来找盲区,不能当 KPI。 分支覆盖率比行覆盖率重要得多;一个没有断言的测试也能有 100% 行覆盖。想知道测试有没有用,用 PIT 做突变测试。


09.10 深度思考题

题 1:为什么 Java 项目的测试普遍比 Go 项目跑得慢?除了 JVM 启动,还有什么?

参考答案

直接答案:JVM 启动只是零头(1-2 秒),真正的大头是”运行时的装配工作”——Spring 容器启动、ApplicationContext 缓存失效重建、反射和代理创建、classpath 扫描、ORM 初始化,以及测试之间共享状态导致的被迫串行化。

逐层拆:

第一层:JVM 启动 + 类加载(1-3 秒)。 这是固定成本,跟你的代码无关。JVM 要初始化堆、元空间、GC 线程、JIT 编译器线程,加载几千个 JDK 自身的类。Go 这一步是零(编译到机器码,mmap 进内存就跑)。

第二层:classpath 扫描(1-5 秒)。 Spring Boot 启动时要扫 classpath 里所有 jar 找 META-INF/spring.factories(2.x)或 AutoConfiguration.imports(3.x),还要扫你指定的包路径下所有类找 @Component/@Service/@Entity。一个典型项目 classpath 里有 50-150 个 jar、几万个类,这些都要被打开、读字节码、解析注解。Go 里不存在这一步 —— 编译期依赖图就已经确定了。

第三层(最贵的):ApplicationContext 装配(3-20 秒)。

  • 实例化所有单例 Bean,用反射调构造器
  • 处理 @Autowired,递归解析依赖图
  • 为 @Transactional、@Cacheable、@Async 创建动态代理(JDK Proxy 或 CGLIB),生成的类还要被 JVM 加载、验证
  • 处理 BeanPostProcessor、@ConfigurationProperties 绑定
  • 如果是 JPA:建 EntityManagerFactory,扫描所有 @Entity、建 metamodel、验证 schema —— 这一步单独就要 2-8 秒
  • 如果配了 webEnvironment:起内嵌 Tomcat,bind 端口

第四层(最隐蔽):ApplicationContext 缓存失效导致的重复启动。 见 09.4 —— 一个项目有 38 种不同的 @MockBean 组合,就意味着上面第三层的成本被付了 38 次。这一层往往是总耗时的 50% 以上,而且很少有人意识到。

第五层:测试之间共享状态导致无法并行。 如果两个测试都写同一张表、都改同一个 static 变量、都用同一个端口,你就没法开并行。于是 300 个测试只能串行跑,前面的每一层成本都被乘 300。

第六层:JIT 预热的影响(对短测试反而有利)。 这条比较反直觉:跑单个测试方法时,代码还在解释执行,所以单个测试偏慢;但跑整个套件时,跑了一百个之后热代码已经被 JIT 编译了,后面的会快很多。所以”300 个测试的总时间”往往小于”单个测试时间 × 300”。

优化清单(按收益排序):

手段大概收益成本
业务测试改成纯单元测试(不启容器)10-100 倍低(可能要造接缝)
统一 @MockBean 组合 / 删 @DirtiesContext2-10 倍低
@SpringBootTest → 切片测试5-20 倍低
Testcontainers 单例 + reuse2-5 倍低
并行执行(junit-platform.properties)2-4 倍(取决于核数)中(要确认测试独立性)
去掉 @DataJpaTest 的 schema 重建(spring.jpa.hibernate.ddl-auto=create-drop 只跑一次)中中

更深一层:这是第 00 章那条主线的又一次回响 —— Java 选择了运行时灵活性,代价要在每次启动时付一次。

Go 把”链接""装配""依赖解析”全部放在编译期,所以 go test 只是”跑一堆机器码”。Java 把这些放在运行时,所以每启动一个测试就要重新装配一遍。Spring 的上下文缓存机制本质上是在说”这个成本太贵了,我们缓存一下吧” —— 而缓存失效规则(@MockBean、profile、properties)就是这套机制的账单。

理解这一点,你就不会再把”Java 测试慢”当成”Java 不行”,而是知道该往哪个具体的地方下刀。


题 2:Mock 到底该不该用?给一个判断框架。

参考答案

直接答案:用 Mock 替换”不可控的外部依赖”,用 Fake 替换”数据访问”,不要 Mock 你自己写的简单值对象。核心判断是:Mock 的成本是”测试与实现耦合”,收益是”隔离和速度”。

判断框架 —— 三个问题,按顺序问:

问题一:这个依赖的”结果”我能查得到吗?

  • 能查到 → 用 Fake(内存实现、Testcontainers、H2)。断言”结果是对的”
  • 查不到(副作用:发消息、发邮件、调第三方、写日志)→ 用 Mock,断言”调用发生了,参数对”

问题二:这个东西可控吗?

  • 不可控(时间、随机数、外部网络、第三方 SDK)→ 必须替换掉。Mock 或者注入 Clock / RandomGenerator
  • 可控且便宜(你自己写的一个纯计算类)→ 不要 mock,直接用真的
// 错误:mock 一个你自己写的纯计算类,纯属自找麻烦
@Mock PriceCalculator calculator;
when(calculator.calc(any())).thenReturn(new BigDecimal("199"));
 
// 正确:直接用真的
PriceCalculator calculator = new PriceCalculator();

问题三:写 Fake 的成本划算吗?

  • 接口方法少(< 10)、会被多个测试复用 → 写 Fake,赚
  • 接口方法多、只被一两个测试用、你只关心其中一两个方法 → 用 Mock

不该 Mock 的东西(红线):

  1. 值对象、DTO、record —— mock(Order.class) 是纯粹的浪费,new Order(...) 更短更安全
  2. JDK 的核心类 —— mock(String.class)、mock(List.class) 干不出来(在 bootstrap classloader 里),而且毫无意义
  3. 为了”隔离”而 mock 的内部类 —— 每多一个 mock,你的测试就多一份跟实现的耦合
  4. 深层对象链 —— when(a.getB().getC().getD()) 出现时,问题在设计,不在 mock

代码锚点 —— 一个”恰到好处”的测试长什么样:

class CreateOrderTest {
    // 1. 数据访问:Fake,验证结果
    final InMemoryOrderRepository repo = new InMemoryOrderRepository();
    // 2. 时间:注入固定 Clock,不用 mock
    final Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), UTC);
    // 3. 纯计算:用真的,不 mock
    final PriceCalculator calculator = new PriceCalculator();
    // 4. 外部服务(不可控):stub 掉返回值,但不 verify
    @Mock InventoryClient inventory;
    // 5. 副作用(查不到结果):mock + verify
    @Mock OrderEventProducer producer;
 
    OrderServiceImpl service;
 
    @BeforeEach
    void setUp() {
        service = new OrderServiceImpl(repo, calculator, inventory, producer, clock);
        when(inventory.deduct(anyLong(), anyInt())).thenReturn(true);   // 让它成功,我们不关心它
    }
 
    @Test
    void 创建订单() {
        Order order = service.createOrder(cmd());
 
        // 断言结果(Fake + 真的计算器)
        assertThat(repo.findById(order.getId()).get().getAmount())
            .isEqualByComparingTo("199");
        // 断言副作用(Mock,这是唯一该 verify 的地方)
        verify(producer).send(any(OrderCreatedEvent.class));
    }
}

注意 inventory 只 stub 不 verify —— 因为”调了扣库存接口”是实现细节,而”订单被正确创建了 + 发了消息”才是业务结果。

更深一层:Mock 是一个”杠杆”,它同时放大了隔离性和耦合度。

每写一个 verify(...),你都在做一笔交易:

  • 收益:这个测试快、稳定、不依赖外部环境
  • 成本:这个测试现在知道了”实现会调用这个方法”

成熟的判断力体现在:只为你真正在乎的行为付这笔成本。 你在乎”用户收到了通知”(副作用,因为查不到)→ 付。你不在乎”实现是调 save 还是 saveAll” → 不付。

对照 Go:Go 社区其实更早就形成了这个共识,因为 gomock 的体验更痛 —— 你要跑 mockgen 生成代码、生成的文件要提交进仓库、接口改了要重新生成。这个摩擦成本让 Go 程序员在下手 mock 之前会多想一秒,而 Mockito 的零摩擦(一个 @Mock 注解就完事)反而容易导致过度使用。工具的便利程度会改变使用频率,哪怕它不该改变使用决策。


题 3:一个 @SpringBootTest 测试跑 40 秒,你会从哪些角度优化?

参考答案

直接答案:先测量再优化。40 秒的分布通常不是”启动慢”,而是”启动了太多次”或者”里面有个慢依赖”。按下面的清单从上往下查,八成能在第二条或第三条找到问题。

第 0 步:测量(别猜)

mvn test -Dtest=OrderServiceTest        # 单独跑这个测试,看多久
mvn test                                # 全量跑,看多久
# 关键:数一下日志里 "Starting ... using Java 17" 出现了几次
mvn test 2>&1 | grep -c "Starting .* using Java"

如果单独跑是 6 秒,全量跑是 40 秒 —— 那你优化的方向错了,问题不是”这个测试慢”,是”容器被启动了太多次”。

第 1 步:确认是不是真的需要容器

问自己:这个测试会因为”业务代码改了”变红,还是因为”配置改了”变红?

// 改前:40 秒里的大部分,是为了拿到一个 OrderService 实例
@SpringBootTest
class OrderServiceTest {
    @Autowired OrderService service;
}
 
// 改后:5 毫秒
class OrderServiceTest {
    OrderService service;
    @BeforeEach
    void setUp() {
        service = new OrderServiceImpl(new InMemoryOrderRepository(),
                                       new PriceCalculator(),
                                       mock(InventoryClient.class),
                                       mock(OrderEventProducer.class),
                                       Clock.fixed(FIXED_INSTANT, UTC));
    }
}

收益:40 秒 → 毫秒级。这是唯一一个量级级别的优化,永远先看它。

第 2 步:查 ApplicationContext 缓存命中率

# src/test/resources/application-test.properties(或者 logback-test.xml)
logging.level.org.springframework.test.context.cache=DEBUG

日志里会打 Spring test ApplicationContext cache statistics,看 missCount。或者更简单的判断:

# 数一下有多少个不同的 @MockBean 组合
grep -l "MockBean" -r src/test/java | xargs grep -h "MockBean" | sort | uniq -c

解法:抽象基类统一 mock 组合(见 09.4 的代码锚点)。

第 3 步:切片测试替代全量容器

@SpringBootTest            // 加载全部 Bean
    ↓
@WebMvcTest(OrderController.class)      // 只加载 Web 层
@DataJpaTest                            // 只加载 JPA 层

第 4 步:查慢依赖

逐个排查:

  • JPA 的 ddl-auto:每次启动都建表删表。可以设成 create-drop 但只跑一次,或者用 Flyway/Liquibase 的 schema 缓存
  • Testcontainers:是不是每个测试类都起了一个 MySQL?→ 单例容器模式
  • @DirtiesContext:全项目搜一遍,能删的都删
  • Redis/Kafka/ES 的连接:是不是在启动时要做健康检查、元数据拉取?
  • 网络调用:有没有 Bean 在 @PostConstruct 里调了外部 HTTP?
# 找出所有 @DirtiesContext
grep -rn "DirtiesContext" src/test/java

第 5 步:并行执行

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4

前提是你的测试是独立的。 开完并行先连跑三遍确认结果稳定 —— 如果时红时绿,说明有共享状态,先修那个。

第 6 步:拆分大测试类

一个测试类里有 30 个测试方法,其中 5 个需要容器、25 个不需要 —— 拆成两个类。那 25 个变成纯单元测试,跑起来是毫秒级。

OrderServiceTest        // 25 个纯单元测试,毫秒级
OrderServiceIT          // 5 个需要容器的测试

顺带一提:命名成 *IT 就不会被 surefire 匹配到(它默认匹配 *Test),需要靠 maven-failsafe-plugin 跑。这是 Java 项目区分”单元测试”和”集成测试”的标准做法。

优化清单速查:

顺序手段典型收益
1改成纯单元测试(手动 new)10-1000 倍
2切分成纯单测 + 少量 IT5-20 倍
3统一 @MockBean 组合(抽象基类)2-10 倍
4删掉不必要的 @DirtiesContext1.5-5 倍
5切片测试5-20 倍
6Testcontainers 单例 + reuse2-5 倍
7JPA schema 只建一次1.2-2 倍
8并行执行2-4 倍

更深一层:40 秒这个数字本身不是问题,“没人知道为什么是 40 秒”才是问题。

大部分团队的反应是”测试慢,那我们少跑点”或者”CI 上加个 -DskipTests”。这是在关掉烟雾报警器,不是灭火。

真正值钱的是建立可观测性:知道容器启动了多少次、知道缓存在 miss 什么、知道哪一步花了多少时间。有了这些,优化就是机械劳动。没有这些,你只能靠猜,而猜在性能优化上几乎总是错的。

对照 Go:Go 项目很少需要这种优化,因为 go test 默认就是并行的(不同包并行跑)、没有容器要启动、没有 classpath 要扫。Go 的 -race 会慢 5-10 倍,但那是个明确的、你主动开启的开关。 Java 的慢是”默认就慢”,所以你反过来需要主动去优化 —— 这又是”运行时灵活性”的账单。


题 4:单元测试该测私有方法吗?

参考答案

直接答案:不该。需要测私有方法,说明这个类承担了太多职责 —— 正确的做法是把那段逻辑提取成一个新类,然后测那个类。

为什么”不该”—— 三条理由:

一、私有方法是实现细节,实现细节应该可以自由改变。

测试的价值在于”保护行为,允许重构”。测私有方法等于说”这个内部实现不能改”—— 那你未来所有的重构都会被测试拖住。这跟 09.3 里”mock 把测试跟实现绑死”是同一个问题的两个面。

二、如果一段私有逻辑复杂到需要单独测,它几乎肯定应该是一个独立的类。

public class OrderServiceImpl {
    private BigDecimal applyCoupon(BigDecimal amount, Coupon c) {   // 复杂到想单测
        if (c == null) return amount;
        if (c.getType() == FULL_CUT) {
            return amount.compareTo(c.getFull()) >= 0
                ? amount.subtract(c.getCut()) : amount;
        }
        if (c.getType() == DISCOUNT) {
            BigDecimal discounted = amount.multiply(c.getRate()).setScale(2, HALF_UP);
            return discounted.min(c.getMaxCut() == null ? discounted : c.getMaxCut());
        }
        // ... 还有五种券
        return amount;
    }
}

这段逻辑有 5 个分支、有边界条件(门槛、上限、精度)、有业务规则。它不是一个”私有方法”,它是一个独立的领域概念:优惠券计算器。

// 提取出来,变成一个有名字的、可测的、可复用的类
public class CouponCalculator {
    public BigDecimal apply(BigDecimal amount, Coupon coupon) { /* ... */ }
}
class CouponCalculatorTest {
    @ParameterizedTest(name = "{0}")
    @MethodSource("cases")
    void 优惠券计算(String name, String amount, Coupon coupon, String expected) {
        assertThat(new CouponCalculator().apply(new BigDecimal(amount), coupon))
            .isEqualByComparingTo(expected);
    }
}

提取之后发生了三件好事:①这段逻辑可测了 ②它可被别的地方复用(结算、退款都要算券)③OrderServiceImpl 变短了。

三、通过公开方法测私有逻辑,通常完全够用。

如果私有方法真的只是个辅助(两三行),那么它一定会被某个公开方法调用到。设计好几个输入用例,让公开方法的执行路径覆盖到那个私有方法的所有分支 —— 这就是”通过公开接口测私有实现”,是正确做法。

唯一的例外(很少):

你在一个不能改的老代码里加测试,而这个老类的私有方法逻辑复杂、且你暂时不敢提取(怕破坏别的调用)。这种情况下:

// 用 Spring 的反射工具(或者纯反射)硬测 —— 但要当成技术债记下来
BigDecimal result = ReflectionTestUtils.invokeMethod(service, "applyCoupon", amount, coupon);

这是特征测试的一部分,不是常规做法。而且用字符串写方法名意味着重命名方法时编译器不会提醒你,测试会在运行时才炸。

代码锚点 —— 判断的”三问”:

问自己三个问题,任何一个答”是”,就该提取成类:

  1. 这段逻辑的分支超过 3 个吗?
  2. 它有独立的业务概念名字吗?(“优惠券计算""运费计算""库存检查” —— 能起名字就是独立概念)
  3. 别的地方将来会用到它吗?

更深一层:这个问题的本质是”职责边界在哪”。

“我想测私有方法”这个冲动,是你的大脑在告诉你:这里有一个没有被命名的概念,它应该有自己的名字和自己的家。 你有两个选择:听它的(提取成类),或者不听(用反射硬测,或者放弃测试)。

选后者,你会得到一堆”知道有这个问题但没有解决”的代码。选前者,你的代码库会慢慢长出一堆小的、有名字的、可测的类 —— 这就是”可测试性驱动设计”的实际运作方式。

有意思的是,这个思路在 Go 里几乎不需要刻意做 —— 因为 Go 的包级函数默认是”包内可见”的,而测试文件就在同一个包里,所以你随时可以测一个未导出的函数。Go 的可见性粒度(包)比 Java 的(类 + private/protected/包)粗,反而让”该不该提取”这个决定变得没那么痛苦。

这也提醒你一件事:Java 的 private 关键字本身就在给可测试性制造摩擦。 所以 Java 社区有一条经验法则:如果你拿不准一个方法该不该 public,先设成包级可见(什么都不写) —— 它给测试留了口子,又不会暴露给外部使用者。


题 5:TDD 在 Java 项目里可行吗?在老项目里呢?

参考答案

直接答案:新模块可行且值得;老项目用”修改前先写特征测试”的渐进方式,不要试图用教科书式的 TDD。

先说新模块:可行,而且 Java 生态对 TDD 的支持其实不错。

TDD 的红-绿-重构循环对工具链有两个要求:跑测试要快、反馈要清晰。Java 这两点都能满足:

  • 纯单元测试(不启 Spring)是毫秒级的,配 IDE 的自动重跑(IntelliJ 的 “auto-reload”)可以做到”保存即测试”
  • AssertJ 的失败信息足够清晰
  • @Nested + @DisplayName 让测试的组织和阅读都很顺
// TDD 循环在 Java 里的样子
@Test
void 优惠券不满足门槛时不生效() {          // ① 红:先写测试
    assertThat(new CouponCalculator().apply(bd("60"), fullCut100Minus20))
        .isEqualByComparingTo("60");
}
// ② 绿:写最少的代码让它过
// ③ 重构:把 if 改成策略模式

但有一个前提:你的代码得是”可测的设计”。 如果你在一个 @SpringBootTest 上跑 TDD —— 每次红绿循环 6 秒 —— 你会在第三轮就放弃。TDD 和可测试性是一体两面:TDD 逼你写出可测的代码,而可测的代码让 TDD 变快。这就是为什么 TDD 的实践者往往也是依赖注入和纯函数的拥护者 —— 不是巧合,是因果关系。

TDD 在 Java 里的两个真实障碍:

障碍一:类型系统带来的摩擦。 Java 里你要先定义类、接口、record,才能写测试。Go 里你可以直接写 calcAmount(items, coupon) 然后让编译器推导出参数类型。Java 的”先定义后使用”让 TDD 的”先写测试”这一步变得更重 —— 你往往得先想清楚类型,才能写测试。这不完全是坏事(它逼你想清楚设计),但它确实拖慢了循环。

障碍二:Spring 的文化惯性。 很多 Java 工程师的”写测试”就是”写 @SpringBootTest”。在这个心智模型下,TDD 的速度不可接受,于是”TDD 在 Java 里不实用”这个结论就自我实现了。问题不在 TDD,在”默认启容器”这个习惯。

再说老项目:教科书式的 TDD 不可行,但有个变体非常有效。

教科书 TDD 说”先写测试,再写实现”。但在 3 万行 0 测试的老项目里,你要改的那个方法已经存在了,而且它现在的输出是什么你都不知道(没有规范,只有行为)。你没法”先写一个期望的测试”,因为你不知道该期望什么。

老项目的正确做法是”先写特征测试”(09.6 第一步):

教科书 TDD:       写测试(红的)→ 写实现 → 重构
老项目变体:       写特征测试(记录现状)→ 重构 → 改行为(改测试期望 → 改实现)

特征测试和 TDD 的测试形式一样,只是意图不同:TDD 的测试表达”我要什么”,特征测试表达”现在是什么”。

而且你会惊讶地发现:写完特征测试之后的”重构”步骤,跟 TDD 的重构步骤体验一模一样 —— 你有了安全网,敢动了。

一个务实的采纳路径:

阶段做什么目的
1端到端冒烟测试 + CI建立底线
2改代码前先写特征测试养成”先保护后修改”的习惯
3新写的类/方法先写测试让新代码不欠债
4等团队习惯了,再在新模块上试完整 TDD体验红-绿-重构

直接从第 1 步跳到第 4 步,几乎一定失败 —— 因为团队的 muscle memory 还是”写完代码再补测试”,突然要求”先写测试”会遭遇巨大的心理阻力。

代码锚点 —— 特征测试长什么样(再说一次,因为它是老项目里最实用的一招):

@Test
@DisplayName("【特征】记录 createOrder 在库存服务超时时的当前行为")
void 记录_库存服务超时() {
    // 模拟:库存客户端抛超时异常
    doThrow(new TimeoutException("inventory timeout"))
        .when(inventory).deduct(anyLong(), anyInt());
 
    // 记录:当前代码把这个异常吞掉,返回一个 status=CREATED 的订单
    // (这个行为大概率是 bug,但我现在不动它)
    Order order = service.createOrder(cmd());
    assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
}

这个测试的价值不在于它断言了正确的行为,而在于它让你知道了”当前行为是什么”。 有了它,你下次决定”超时应该回滚订单”时,就能明确地改这个断言,然后改实现 —— 而这个过程就是 TDD 的红-绿循环,只不过起点是”红”从”记录错误行为”开始。

更深一层:TDD 的真正价值不是”测试覆盖率”,是”设计压力”。

“先写测试”这个动作的本质是:强迫你在使用者视角上,先把接口设计出来。你还没写实现,就得调用它 —— 于是你被迫想清楚”这个方法的参数应该是什么""返回值应该是什么""边界情况怎么表达”。这才是 TDD 提高代码质量的主要机制,测试本身只是副产品。

对照 Go:Go 社区对 TDD 的态度比 Java 更务实 —— 标准库自带 testing、没有框架仪式、表驱动测试写起来快,所以 TDD 在 Go 里的摩擦更小。但 Go 社区同样不教条:没人会为了一个 func add(a, b int) int 写 TDD。

两个社区真正的共识是:TDD 是一种设计工具,不是一种测试策略。把它当成设计工具用(新代码、复杂逻辑、边界多的地方),它是个好东西;把它当成”所有代码都必须先写测试”的教条,它就会变成阻力。


下一章预告

这一章解决的是”怎么敢改代码”。但有个前提我们一直在回避:如果你的代码里有并发 bug,测试是抓不住它的。

一个 @RepeatedTest(100) 全绿的并发方法,上线后第 100 万次调用时数据错了。因为并发 bug 的本质不是”逻辑分支没走到”,而是**“线程交织的顺序”** —— 而测试跑一百次,能覆盖的交织顺序可能只有几种。

第 10 章开始进入第三部分(并发与性能),讲 Java 并发模型:JMM(Java 内存模型)到底是什么、volatile 解决了什么问题、synchronized 的锁升级过程、AQS 的原理。

跟本章的承接在于:Go 程序员对并发的直觉是 goroutine + channel + “不要通过共享内存来通信”。这套直觉在 Java 里有一半是错的 —— 因为 Java 的内存模型给了你一套完全不同的保证(happens-before),而这套保证的边界,正是测试覆盖不到的地方。

在那之前,做一件事:打开你手边的 Java 项目,跑一遍 mvn test,然后数一下日志里 Starting ... using Java 17 出现了几次。那个数字除以你的 @SpringBootTest 类数量,就是你的 ApplicationContext 缓存命中率。