2606 字
约 8 分钟
18
S16-X14 · 单元测试的设计:可测性与测试价值

一个类写不出测试,通常不是"没时间写",而是天生不可测:构造方法里 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. 自测

  1. 三条价值判据里,哪一条对应"偶发红的测试比没有测试更贵"?
  2. 覆盖率 85% 而线上仍出缺陷,先怀疑哪三类用例?
  3. 一个方法必须连库才能算出结果,可测性缺的是三个来源中的哪一个?
  4. verify(repo).findById(id) 这类断言在什么条件下才算合理?
  5. 为什么推荐注入 Clock,而不是 mockStatic(LocalDateTime.class)?
  6. 全量跑红、单跑绿,最先查的两个方向是什么?
S16-X14 · 单元测试的设计:可测性与测试价值
https://www.clxhxhhr.top/posts/5964/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。