1098 字
约 3 分钟
14
S16-B02 · 单元测试:JUnit 5 + Mockito 的最小可运行

适用读者:要给自己写的类补第一批测试的人。
前置:S16-B01(层次与判据)、S04(Java)、会用 Gradle。
覆盖旧文:无直接旧卡(E 驱动)。工具依据:JUnit 5 User Guide、Mockito 文档、AssertJ 文档。
扩展点:E-S16-002(AAA 与断言粒度)。
上一篇:S16-B01 · 测试的层次与判据:单测为什么"全绿"还会出事故
下一篇:S16-B03 · 集成测试。


1. 一句话结论

一个好的单元测试只证明一件事,并且断言得足够少。 一个用例里断言三种行为,失败时你只知道"有问题",不知道"哪里有问题"。

症状 真实原因
一条用例红了一片断言 用例承担了多个行为
改了实现细节测试就红 断言到了实现而不是行为
测试全绿但线上错 只 mock 了 happy path(B01 §2 的盲区)

2. 环境与依赖(2026-11 实测)

// build.gradle — Java 17 / Gradle 8.10
dependencies {
    testImplementation platform("org.junit:junit-bom:5.11.3")
    testImplementation "org.junit.jupiter:junit-jupiter"
    testImplementation "org.mockito:mockito-core:5.14.2"
    testImplementation "org.mockito:mockito-junit-jupiter:5.14.2"
    testImplementation "org.assertj:assertj-core:3.26.3"
}
test { useJUnitPlatform() }

运行与预期:

$ ./gradlew test --tests "*OrderServiceTest"
BUILD SUCCESSFUL in 3s
3 tests completed

3. 被测试的类

public class OrderService {
    private final OrderRepository repo;
    private final PriceCalculator calc;

    public OrderService(OrderRepository repo, PriceCalculator calc) {
        this.repo = repo; this.calc = calc;
    }

    /** 下单:金额 <= 0 或库存不足时抛异常 */
    public Order place(long userId, long skuId, int qty) {
        if (qty <= 0) throw new IllegalArgumentException("qty must be positive");
        long cents = calc.total(skuId, qty);
        if (cents <= 0) throw new IllegalStateException("invalid amount");
        Order o = new Order(userId, skuId, qty, cents);
        repo.save(o);
        return o;
    }
}

4. 三个用例,覆盖三类分支(E-S16-002)

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository repo;
    @Mock PriceCalculator calc;
    @InjectMocks OrderService service;

    @Test
    void 正常下单_金额按计算器结果落库() {           // 正常路径
        // Arrange
        when(calc.total(1L, 2)).thenReturn(1990L);
        // Act
        Order o = service.place(9L, 1L, 2);
        // Assert:只断言"行为产物",不断言内部调用次数
        assertThat(o.getAmountCents()).isEqualTo(1990L);
        verify(repo).save(o);
    }

    @Test
    void 数量为0_抛参数异常且不落库() {              // 边界
        assertThatThrownBy(() -> service.place(9L, 1L, 0))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("qty");
        verifyNoInteractions(repo);
    }

    @Test
    void 金额为0_抛状态异常且不落库() {              // 异常路径
        when(calc.total(1L, 1)).thenReturn(0L);
        assertThatThrownBy(() -> service.place(9L, 1L, 1))
            .isInstanceOf(IllegalStateException.class);
        verifyNoInteractions(repo);
    }
}

判据(怎么知道测到了):

检查 合格标准
分支覆盖 每个 if/throw 至少一条用例命中;用 IDE 覆盖率看分支而非行
断言粒度 一条用例只断言一个可命名行为(方法名就是断言)
交互断言 只在"副作用必须发生/必须不发生"时用 verify,不要断言调用顺序(那是实现细节)
时间/随机 用 Clock / IdGenerator 注入,不在测试里 Instant.now()

5. 两个高频错误

5.1 过度 mock:把被测逻辑 mock 掉了

// 反例:把要测的价格计算 mock 成固定值,等于什么都没测
when(calc.total(anyLong(), anyInt())).thenReturn(100L);

判据:被测类的直接依赖可以 mock,被测类自己的逻辑不能 mock。如果 mock 之后用例永远绿,它就没有证明力。

5.2 断言实现细节

// 反例:改成分批保存就红,但业务没变
verify(repo, times(1)).save(any());
InOrder inOrder = inOrder(calc, repo);

判据:换一种等价实现,测试应该仍然绿。如果必须改测试,说明断言的是实现。


6. 什么时候单元测试不值得写

场景 原因 替代
纯 getter/setter、DTO 映射 没有分支 不写
逻辑主要是 SQL mock 掉数据库等于没测 集成测试(B03)
代码要先跑通框架生命周期 单测环境不具备 切片测试(B03 §3)
一次性脚本 生命周期短于测试维护成本 手工验证 + 记录

7. 自测

  1. 你的用例名能不能直接读成一句断言?(如 数量为0_抛参数异常且不落库)
  2. 把被测类的实现换成等价写法,测试会红吗?会 → 断言到了实现。
  3. 三个用例里有几个是 happy path?只有一个才算合格。

8. 下一篇

S16-B03 · 集成测试:Spring Boot Test 与 Testcontainers —— 讲清 mock 环境与真实依赖的差距,以及切片测试怎么选。

S16-B02 · 单元测试:JUnit 5 + Mockito 的最小可运行
https://www.clxhxhhr.top/posts/5407/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。