第 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:返回值改成 null

PIT 会生成几十上百个这样的突变体,每个都跑一遍你的测试套件。结果分两类:

  • 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 测试生态对照表

维度GoJava
测试框架标准库 testing(无第三方依赖,无断言库)JUnit 5(Platform + Jupiter + Vintage 三层架构),需要构建插件(surefire)
断言风格手写 if got != want { t.Errorf(...) },或 testify 的 assert.EqualJUnit 自带 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 讲过)
生态成熟度简单、克制、够用庞大、分层细、工具多(也是负担)