一个类写不出测试,通常不是"没时间写",而是天生不可测:构造方法里 new 了 HTTP 客户端,方法体第一行读 LocalDateTime.now(),判断逻辑和查询糊在同一个私有方法里。测试写不动,是设计借测试把耦合度照了出来。
只谈三件事:什么样的单元测试值得留、怎么把类改成可测的、哪些写法让测试变成负债。
1. 一条测试值不值,先过三道判据
| 判据 | 具体问法 | 不满足时的症状 |
|---|---|---|
| 能快速定位失败原因 | 报红时能否直接说出哪个行为坏了 | 要先开 debugger、加日志才知道错在哪 |
| 改动时能给出信号 | 重构实现、行为不变时它是否保持绿色 | 改一行私有方法,十个测试一起红 |
| 失败即真缺陷 | 报红是否等于线上真会出错 | 因时区、执行顺序、共享状态而红 |
最容易被跳过的是第三条。偶发红的测试比没有测试更贵,它训练团队忽略红色。
1.1 覆盖率不等于价值
覆盖率衡量"被执行过的行",不衡量"被验证过的行为"。三种典型的高覆盖低价值:
- 无断言测试:跑通了
service.create(...),返回值与副作用都没断言,保护为零。 - 被替身撑起的覆盖:调了 5 个 mock,每个
if都走到,外部契约一条没校验。 - 只覆盖简单分支:getter、构造校验撑起大半覆盖率,空集合、幂等、并发这类边界没有用例。
覆盖率适合当警戒下限,不适合当 KPI。当目标用,团队就会写大量无断言测试来达标。
2. 可测性的三个来源
可测性不是"多加几个 setter",而来自三处结构性选择:
| 来源 | 做法 | 反面 |
|---|---|---|
| 依赖可替换 | 依赖从构造方法进来,类型是接口或可被 fake 的窄接口 | 方法体内 new、静态单例、Service Locator |
| 副作用可控 | IO、时钟、随机、发消息收在边界,核心逻辑不碰 | 业务方法里直接发 MQ、写文件 |
| 核心逻辑纯函数化 | 决策抽成"输入 → 输出"的函数,壳只负责编排 | 判断与查询交织,必须连库才算得出结果 |
// 不可测:判断、查库、时钟、写库全糊在一起
public void settle(long orderId) {
Order o = repo.find(orderId);
if (o.getAmount().compareTo(BigDecimal.valueOf(100)) > 0
&& LocalDateTime.now().getHour() < 22) {
o.setStatus(Status.SETTLED);
repo.save(o);
mq.send("settled", orderId);
}
}
// 可测:规则是纯函数,壳只做 IO
public Settlement decide(Order o, Clock clock) {
boolean bigEnough = o.amount().compareTo(THRESHOLD) > 0;
boolean inWindow = clock.instant().atZone(ZONE).getHour() < 22;
return bigEnough && inWindow ? Settlement.settle(o.id()) : Settlement.hold(o.id());
}
剥出来之后,规则部分零依赖跑上千个用例,壳部分留给集成测试验证"确实发了消息"。
3. 四个结构要素与命名的信息量
| 要素 | 该做什么 | 常见错误 |
|---|---|---|
| 准备 Arrange | 只构造本用例需要的状态 | 一个 @BeforeEach 准备所有用例的数据 |
| 执行 Act | 一行,一个被测行为 | 一个测试里连调三个方法 |
| 断言 Assert | 断言可观察结果 | 断言内部调用序列 |
| 清理 Teardown | 释放外部资源、复位静态状态 | 靠 JVM 退出兜底 |
@Test
void should_hold_settlement_when_amount_below_threshold() {
Order order = anOrder().withAmount("99.00").build();
Clock clock = Clock.fixed(Instant.parse("2026-01-01T10:00:00Z"), ZoneOffset.UTC);
Settlement result = settlementService.decide(order, clock);
assertThat(result.type()).isEqualTo(SettlementType.HOLD);
}
@Test
void decide_should_throw_when_order_already_settled() {
Order order = anOrder().withStatus(Status.SETTLED).build();
assertThrows(IllegalStateException.class,
() -> settlementService.decide(order, Clock.systemUTC()));
}
测试名要交代被测行为、前置条件、期望结果;testSettle2 一条都不给,should_hold_... 三条都有。
4. 断言该断行为,不该断实现
| 断言对象 | 建议 | 理由 |
|---|---|---|
| 返回值、状态变化 | 主断言 | 消费者真正依赖的东西 |
| 异常类型与关键消息 | 应断言 | 异常也是契约的一部分 |
| 外部调用的存在性 | 仅当那是契约时 | "必须通知风控"是契约,"先查缓存"是实现 |
| 调用次数、顺序 | 默认不断言 | 锁死实现,重构必红 |
| 私有方法、内部字段 | 不断言 | 属于实现细节 |
// 反例:断言的是实现路径
verify(repo).findById(id);
verify(repo).save(any());
verifyNoMoreInteractions(repo);
// 正例:断言的是契约
assertThat(result.type()).isEqualTo(SettlementType.SETTLED);
verify(eventPublisher).publish(argThat(e -> e.orderId().equals(id)));
verifyNoMoreInteractions 只在一个场景有用:确认没有多余副作用(比如没发出第二条消息)。拿它锁序列,等于把测试写成实现的快照。多条断言用 assertAll。
5. 三类替身,各自回答不同的问题
| 替身 | 回答的问题 | 写法 | 适用面 |
|---|---|---|---|
| stub | 给它这个输入返回什么 | when(x).thenReturn(v) |
让被测逻辑走到目标分支 |
| mock | 它有没有被这样调用 | verify(...) |
契约本身就是交互:发通知、扣款、审计 |
| fake | 用一份轻量真实现代替 | 内存仓库、FakeClock |
需要多次读写、需要状态累积 |
| 依赖类型 | 处理 | 原因 |
|---|---|---|
| 外部 HTTP / MQ / 对象存储 | 替身或容器 | 单测连真实网络等于引入不确定性 |
| 时间、随机、UUID | 注入替代实现 | 否则用例不可复现 |
| 数据库 | 单测用 fake,集成测试用真库 | 单测关心规则,不关心方言 |
| 被测算的核心逻辑 | 不 mock | mock 掉它,测试就在验证 mock |
| 值对象、集合 | 用真实例 | 替身会让语义失真 |
一个测试里
@Mock超过三个,通常说明被测类职责过多。该拆类,而不是加替身。
6. 时间与随机:注入,不要静态打桩
public class CouponService {
private final CouponRepository repo;
private final Clock clock; // 时钟是依赖,不是环境
public CouponService(CouponRepository repo, Clock clock) {
this.repo = repo;
this.clock = clock;
}
public boolean usable(Coupon c) {
return !c.expiresAt().isBefore(clock.instant());
}
}
// 测试:固定时钟,无需 mock 静态方法
Clock fixed = Clock.fixed(Instant.parse("2026-05-01T00:00:00Z"), ZoneOffset.UTC);
CouponService svc = new CouponService(new InMemoryCouponRepository(), fixed);
assertThat(svc.usable(expiringAt("2026-04-30T23:59:59Z"))).isFalse();
随机同理:注入 Random 并固定种子。mockStatic 也能让测试绿,但会把测试绑到某个 JDK 版本的静态实现上,且与并行执行互相干扰。能注入就不要打静态桩。
7. 参数化:把用例写成表
@ParameterizedTest(name = "amount={0} hour={1} => {2}")
@CsvSource({
"100.00, 10, SETTLED",
"100.01, 10, SETTLED",
"99.99, 10, HOLD",
"100.00, 23, HOLD"
})
void settlement_decision(String amount, int hour, SettlementType expected) {
Order order = anOrder().withAmount(amount).build();
assertThat(service.decide(order, atHour(hour)).type()).isEqualTo(expected);
}
表驱动的价值不是"代码短",而是边界条件被写成一行数据。等于、刚超过、刚不足、区间外凑齐,复查遗漏比翻十个手写测试容易。
@MethodSource 适合复杂对象,@CsvSource 适合标量。一个用例里若有两套准备逻辑,硬塞进参数表会让可读性塌掉。
8. 测试数据的三种构造方式
| 方式 | 形态 | 适用 |
|---|---|---|
| 显式工厂 | anOrder().withAmount("99").build() |
大多数业务对象,默认选它 |
| fixture 文件 | JSON / SQL 放 src/test/resources |
结构化报文、迁移脚本 |
| 共享静态常量 | static final Order VALID_ORDER |
只读、不可变、无状态的对象 |
public final class OrderFixture {
private BigDecimal amount = new BigDecimal("100.00");
private Status status = Status.CREATED;
public static OrderFixture anOrder() { return new OrderFixture(); }
public OrderFixture withAmount(String v) { this.amount = new BigDecimal(v); return this; }
public Order build() { return new Order(UUID.randomUUID(), amount, status); }
}
核心是默认值合法 + 只改与本用例相关的字段,读测试时一眼看出"这个用例只关心金额"。
9. 脆弱测试的四种成因
| 成因 | 表现 | 修法 |
|---|---|---|
| 依赖执行顺序 | 单独跑绿,全量跑红 | 用例自建状态,不用共享可变字段 |
| 断言过细 | 重构必红,行为没变 | 断言可观察结果,去掉序列断言 |
| 共享可变状态 | 偶发失败,重跑就好 | 用例级隔离;静态缓存显式复位 |
| 依赖真实时间 | 跨零点、跨月失败 | 注入 Clock,断言里别调 now() |
偶发失败先当缺陷处理,别加 @Retry,它往往指向真实并发缺陷。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 全量跑红、单独跑绿 | 共享可变状态或顺序依赖 | 关并行跑一遍;查静态字段与 @BeforeEach 遗漏 |
UnnecessaryStubbingException |
打了桩但没被走到 | 该桩多余;先确认分支是否真被覆盖 |
| 覆盖率上去了没抓到 bug | 无断言或替身撑起的覆盖 | 逐条检查是否存在有效断言 |
11. 边界:什么不该写成单元测试
| 内容 | 更合适的层 | 理由 |
|---|---|---|
| SQL 方言、索引、约束 | 集成测试(容器真库) | 单测里没有真实数据库行为 |
| 序列化、JSON 映射、事务传播 | @JsonTest / @DataJpaTest / 集成测试 |
框架行为不是你的逻辑 |
| 多服务编排、消息投递 | 集成测试或契约测试 | 跨进程边界 |
单元测试测"决策对不对",集成测试测"和真实世界接得上吗"。把后者塞进前者,只会得到一批又慢又假的绿色。
12. 自测
- 三条价值判据里,哪一条对应"偶发红的测试比没有测试更贵"?
- 覆盖率 85% 而线上仍出缺陷,先怀疑哪三类用例?
- 一个方法必须连库才能算出结果,可测性缺的是三个来源中的哪一个?
verify(repo).findById(id)这类断言在什么条件下才算合理?- 为什么推荐注入
Clock,而不是mockStatic(LocalDateTime.class)? - 全量跑红、单跑绿,最先查的两个方向是什么?