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. 自测
- 你的用例名能不能直接读成一句断言?(如
数量为0_抛参数异常且不落库) - 把被测类的实现换成等价写法,测试会红吗?会 → 断言到了实现。
- 三个用例里有几个是 happy path?只有一个才算合格。
8. 下一篇
S16-B03 · 集成测试:Spring Boot Test 与 Testcontainers —— 讲清 mock 环境与真实依赖的差距,以及切片测试怎么选。
S16-B02 · 单元测试:JUnit 5 + Mockito 的最小可运行
https://www.clxhxhhr.top/posts/5407/ 评论
0 条
还没有评论,先写一条吧。