第 09 章(节选) 补充话题与Go对照
本篇来自《Go 程序员的 Java 修炼之路》第 09 章「第 09 章 单元测试与可测试性:敢改别人的代码」。 返回:第 09 章索引
09.7 测试的其他重要话题
测试数据管理
测试的痛点往往不是断言,是”怎么造出一个合法的 Order 对象”。三种模式:
模式一:测试数据构建器(推荐)
public class OrderBuilder {
private Long id = 1L;
private String orderNo = "NO-001";
private BigDecimal amount = new BigDecimal("199");
private OrderStatus status = OrderStatus.CREATED;
private LocalDateTime createdAt = LocalDateTime.of(2026, 1, 1, 0, 0);
public static OrderBuilder anOrder() { return new OrderBuilder(); }
public OrderBuilder withAmount(String amt) { this.amount = new BigDecimal(amt); return this; }
public OrderBuilder withStatus(OrderStatus s) { this.status = s; return this; }
public OrderBuilder cancelled() { this.status = OrderStatus.CANCELLED; return this; }
public Order build() { return new Order(id, orderNo, amount, status, createdAt); }
}// 用法:只声明"这个用例关心的字段",其余用默认值
Order vip = anOrder().withAmount("9999").build();
Order cancelled = anOrder().cancelled().build();这个模式的价值在于:新增一个字段时,你只改 Builder 一处,而不是改 200 个测试。 这是它对 ObjectMother 和裸构造函数的主要优势。
模式二:ObjectMother —— 一组静态工厂方法(Orders.vipOrder()、Orders.cancelledOrder())。简单,但组合爆炸时不好用。
模式三:Fixture —— 从 JSON/YAML 文件加载测试数据。@JsonTest 的 @JsonFileSource 或者 Spring 的 ResourceLoader 都能做。适合测试数据很复杂、或者要跟产品/测试同学共享的场景。
Go 对照:
// Go 用 struct 字面量 + 函数式选项
order := NewOrder(
WithAmount(dec(9999)),
WithStatus(Cancelled),
)
// 或者更简单:直接字面量 + 只填关心的字段(零值兜底)
order := &Order{Amount: dec(9999), Status: Cancelled}Go 因为零值语义天然合理,很多时候连 Builder 都不需要 —— &Order{Amount: x} 剩下的字段就是零值。Java 没有这个便宜(null、0、BigDecimal.ZERO 哪个是”默认”得你自己定义),所以 Builder 在 Java 里是刚需。这是又一个”语言设计决定生态工具”的例子。
异步测试
CompletableFuture 的测试有一个经典陷阱:
@Test
void 异步任务的坑() {
CompletableFuture.runAsync(() -> {
// 这个任务跑在 ForkJoinPool.commonPool() 上
service.doSomething();
});
// 测试方法到这就结束了,异步任务可能还没跑完
// 更糟:JUnit 可能直接结束 JVM,任务被丢弃
}三种解法,按推荐度排序:
// 解法一:拿到 future 然后 join(最简单)
CompletableFuture<Void> f = CompletableFuture.runAsync(...);
f.join(); // 阻塞等它完成
// 解法二:Awaitility —— 轮询等待条件成立,最适合"最终一致"的场景
await().atMost(Duration.ofSeconds(5))
.pollInterval(Duration.ofMillis(100))
.untilAsserted(() -> assertThat(repo.findById(1L).get().getStatus()).isEqualTo(PAID));
// 解法三:注入自定义 Executor,测试里用同步的
Executor direct = Runnable::run; // 直接同步执行,不做异步
var service = new OrderService(direct);推荐解法三(能同步就别异步),次之 Awaitility。轮询(解法二)比固定 sleep 好得多 —— Thread.sleep(2000) 是 flaky 测试的主要来源之一:在 CI 慢机器上 2 秒不够,在本地快机器上浪费 2 秒。
Spring 的 @Async 测试要注意:@Async 靠 AOP 代理生效,所以同一个类内部调用 @Async 方法不会异步(这是经典坑,第 14 章细讲)。测试 @Async 通常用 Awaitility 轮询,或者用 AsyncResult/CompletableFuture 作为返回类型然后 join()。
并发测试
// 重复跑 100 次,抓偶发问题
@RepeatedTest(100)
void 并发扣库存不超卖() {
// ...
}真正的并发压力测试用 jcstress(Java Concurrency Stress tests,OpenJDK 官方项目):
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>0.16</version>
<scope>test</scope>
</dependency>@JCStressTest
@Outcome(id = {"0", "1"}, expect = Expect.ACCEPTABLE, desc = "正常")
@Outcome(id = "2", expect = Expect.FORBIDDEN, desc = "两个线程都读到旧值 → 数据竞争")
@State
public class VisibilityTest {
int x;
volatile boolean ready;
@Actor public void writer() { x = 1; ready = true; }
@Actor public void reader(I_Result r) { if (ready) r.r1 = x; }
}jcstress 会用尽各种方式调度这几个 actor(不同的线程交织顺序、加/不加屏障、JIT 编译前后),跑几百万次,统计所有可能的结果。这是验证 JMM(Java 内存模型)行为的唯一严谨手段 —— 第 10 章讲 volatile 和内存语义时会用到它。
性能测试
JMH(Java Microbenchmark Harness,同样是 OpenJDK 官方项目)。它解决的是”JVM 上微基准测试几乎必然测错”的问题:预热、JIT 编译、死代码消除、常量折叠、GC 干扰。
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>provided</scope>
</dependency>第 13 章详讲。这里只强调一句:不要手写 System.currentTimeMillis() 循环测性能,在 JVM 上那样测出来的数字毫无意义。
契约测试
微服务之间:A 服务的测试依赖 B 服务的接口,但 B 也在变。契约测试用一份”契约文件”把双方钉住。
Spring Cloud Contract 的做法:服务提供方定义契约(Groovy DSL 或 YAML),生成 stub jar 和验收测试;消费方拿 stub 跑自己的测试。
这个话题属于分布式,第 19 章会展开。
突变测试(Mutation Testing)
这是本章最酷的概念,值得花两分钟理解。
思路:故意往你的代码里注入 bug,然后跑测试。如果测试还是绿的 —— 说明你的测试没用。
// 原始代码
if (amount.compareTo(BigDecimal.ZERO) > 0) { ... }
// PIT 注入的突变体 1:> 改成 >=
if (amount.compareTo(BigDecimal.ZERO) >= 0) { ... }
// 突变体 2:删掉这个条件,body 永远执行
if (true) { ... }
// 突变体 3:返回值改成 nullPIT 会生成几十上百个这样的突变体,每个都跑一遍你的测试套件。结果分两类:
- KILLED(被杀):测试红了 → 说明你的测试抓到了这个 bug → 好
- SURVIVED(存活):测试绿了 → 说明你的测试对这个改动完全不敏感 → 你的测试有洞
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.17.0</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<configuration>
<targetClasses><param>com.example.order.core.*</param></targetClasses>
<targetTests><param>com.example.order.*Test</param></targetTests>
</configuration>
</plugin>mvn org.pitest:pitest-maven:mutationCoverage
open target/pit-reports/index.html突变分数(mutation coverage)比行覆盖率有意义得多,因为它测的是”你的测试对代码变化的敏感度”,而不只是”代码被执行过没有”。
代价:慢。每个突变体都要跑一遍(或者至少相关测试),所以 PIT 会跑很久。实践建议:不要全量跑,只对你最核心的模块(金额、状态机、权限)跑,而且放在 nightly build 里,不要放在 PR 的 CI 里。
09.8 Go ↔ Java 测试生态对照表
| 维度 | Go | Java |
|---|---|---|
| 测试框架 | 标准库 testing(无第三方依赖,无断言库) | JUnit 5(Platform + Jupiter + Vintage 三层架构),需要构建插件(surefire) |
| 断言风格 | 手写 if got != want { t.Errorf(...) },或 testify 的 assert.Equal | JUnit 自带 Assertions,社区普遍用 AssertJ 的流式断言 |
| 表驱动 / 参数化 | []struct{...} 切片 + t.Run() 子测试(普通代码,无 DSL 限制) | @ParameterizedTest + @ValueSource/@CsvSource/@MethodSource(注解元数据,参数必须是常量) |
| 子测试组织 | t.Run(name, func(t *testing.T){}),运行时一等对象,可 t.Parallel() | @Nested 内部类,编译期类结构,IDE 可折叠可单跑 |
| mock 工具及原理 | gomock(编译前代码生成,只能 mock 接口)/ testify mock / monkey(运行时改机器码,危险) | Mockito(ByteBuddy 运行时生成子类;Mockito 5 起 inline maker 默认,能 mock final 和 static) |
| fake 的流行度 | 很高(sqlite 内存库、httptest.NewServer、内存实现) | 中等(历史上偏 mock,Testcontainers 之后 fake 派在兴起) |
| HTTP 测试 | httptest.NewRecorder() + httptest.NewServer() | MockMvc(不走网络)/ MockRestServiceServer / @SpringBootTest(webEnvironment=RANDOM_PORT) + TestRestTemplate |
| 集成测试(真实依赖) | testcontainers-go、ory/dockertest,但用得少(sqlite 更常见) | Testcontainers 是事实标准(MySQL/Redis/Kafka/ES 全套编排) |
| 数据库测试 | sqlmock(driver 层拦 SQL)/ sqlite :memory: | H2(快但不保真)/ @DataJpaTest / Testcontainers 真实 MySQL |
| 覆盖率工具 | go test -coverprofile + go tool cover -html(标准库自带,零配置) | JaCoCo(需配插件,支持分支覆盖和覆盖率门槛 check) |
| 测试有效性验证 | 无主流工具 | PIT 突变测试(注入 bug 检验测试敏感度) |
| 并发测试 | -race 竞争检测(一行 flag,好用得不像话) | jcstress(JMM 压力测试)+ @RepeatedTest,没有等价的 race detector |
| 基准测试 | go test -bench(标准库自带) | JMH(需要独立框架,因为 JVM 需要预热和防死代码消除) |
| 测试文件组织 | 同目录 _test.go(可以测包内私有东西) | src/test/java,同包名不同目录(同样能访问包级可见的东西) |
| 测试速度 | 快。一个中型项目全量测试秒级到十几秒 | 慢。Spring 容器启动、上下文缓存失效、反射和代理创建是三大元凶 |
| ”慢”的根因 | 无(编译到机器码,无运行时装配) | 编译期弱、运行时强:classpath 扫描、Bean 装配、动态代理、ORM 初始化都要在测试启动时做一遍 |
| 测试依赖管理 | 无 scope 概念(_test.go 天然分区) | Maven test scope,不传递给下游(05.4 讲过) |
| 生态成熟度 | 简单、克制、够用 | 庞大、分层细、工具多(也是负担) |