第 09 章(节选) 核心结论与深度思考题
来自第 09 章「第 09 章 单元测试与可测试性:敢改别人的代码」。 返回:第 09 章索引
09.9 本章核心结论
-
测试难写,是代码的问题,不是测试的问题。 在方法里 new 依赖、调用 static 方法、一个方法干五件事 —— 这三种模式是”不可测试”的三大根源。
-
可测试性的四个原则:构造器注入依赖、只在边界处抽接口、纯函数优先(函数式核心 + 命令式外壳)、时间和随机数和 IO 都必须可替换(
Clock/RandomGenerator/ 接口)。 -
“为了 mock 而抽接口”在 2020 年之后就不成立了 —— 现代 Mockito 用 ByteBuddy 生成子类,能 mock 具体类,Mockito 5 起连 final 和 static 都能 mock。抽接口的理由只能是”抽象和依赖方向治理”。
-
Mock 的三个层次里,Fake 通常比 Mock 更好。 Mock 验证”调用发生了”(适合副作用),Fake 验证”结果是对的”(适合数据访问)。Mock 的成本是测试与实现耦合 —— 行为不变的重构会让 mock 测试变红。
-
@SpringBootTest是最后的选择,不是默认选择。 业务测试应该new出 service 传 mock 进去(毫秒级),容器测试只用于验证装配。 -
@MockBean会让 Spring 的 ApplicationContext 缓存失效并重建 —— 不同的 mock 组合 = 不同的上下文 = 每次完整启动。这是 Java 项目 CI 慢的头号原因,解法是共享 mock 组合的抽象基类 + 切片测试。 -
H2 测出来的通过不等于生产能跑。 涉及锁、事务隔离级别、数据库特有语法、并发的地方,必须用 Testcontainers 起真实中间件,并且用单例容器模式共享。
-
给老代码补测试的起点是”接缝”,第一步是特征测试(记录当前行为,哪怕它是 bug),然后才是提取依赖、打破静态、拆分方法、加断言。
-
覆盖率只能用来找盲区,不能当 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 组合 / 删 @DirtiesContext | 2-10 倍 | 低 |
@SpringBootTest → 切片测试 | 5-20 倍 | 低 |
| Testcontainers 单例 + reuse | 2-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 的东西(红线):
- 值对象、DTO、record ——
mock(Order.class)是纯粹的浪费,new Order(...)更短更安全 - JDK 的核心类 ——
mock(String.class)、mock(List.class)干不出来(在 bootstrap classloader 里),而且毫无意义 - 为了”隔离”而 mock 的内部类 —— 每多一个 mock,你的测试就多一份跟实现的耦合
- 深层对象链 ——
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 | 切分成纯单测 + 少量 IT | 5-20 倍 |
| 3 | 统一 @MockBean 组合(抽象基类) | 2-10 倍 |
| 4 | 删掉不必要的 @DirtiesContext | 1.5-5 倍 |
| 5 | 切片测试 | 5-20 倍 |
| 6 | Testcontainers 单例 + reuse | 2-5 倍 |
| 7 | JPA 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);这是特征测试的一部分,不是常规做法。而且用字符串写方法名意味着重命名方法时编译器不会提醒你,测试会在运行时才炸。
代码锚点 —— 判断的”三问”:
问自己三个问题,任何一个答”是”,就该提取成类:
- 这段逻辑的分支超过 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 缓存命中率。