主题
界面
主题
《JUnit 5:生命周期与参数化测试》 能测试"没有依赖的类"。但真实业务类必然有依赖:查数据库、发短信、调第三方接口。
本节解决这个矛盾:如何在不连数据库、不发真实短信的前提下,验证业务逻辑正确。答案是测试替身(Test Double),而 Mockito 是 Java 里用得最多的替身工具。
完成本节后,你应该能够:
mock() 创建替身,用 when(...).thenReturn(...) 准备返回值(打桩);verify(...) 验证交互,用 never() / verifyNoInteractions() 验证"不该发生的事";Optional 吗?(《Optional》)@BeforeEach 的作用吗?(T1-002 JUnit 5:生命周期与参数化测试)看一个典型的业务方法:
public String rename(String userId, String newName) {
User user = repository.findById(userId).orElseThrow(...); // 要查数据库
repository.save(user.rename(newName)); // 要写数据库
notifier.send(userId, "名字已修改"); // 要发短信
return newName;
}如果测试真的连数据库和短信网关:
测试替身把依赖换成可控的假对象:想返回什么就返回什么,想验证有没有被调用就验证。于是测试变成了纯粹的"输入 → 逻辑 → 输出"。
从 QA 视角理解
这就是你在做接口测试时的"挡板服务"(Mock Server)思路:依赖不稳定或不可控时,用可控的替身隔离它。区别是编码级替身更轻——不需要起服务,直接是内存里的一个对象。
| 类型 | 作用 | Mockito 对应 |
|---|---|---|
| Dummy | 占位用,不会被真正调用 | mock() 后不设置任何行为 |
| Stub | 提供预设返回值 | when(...).thenReturn(...) |
| Spy | 包装真实对象,可选择性打桩 | spy(realObject)、@Spy |
| Mock | 记录交互,用于验证调用 | mock() + verify(...) |
| Fake | 有简化实现的替代品(如内存数据库) | 自己写类,Mockito 不涉及 |
实际工作中 90% 的场景只用 Stub 和 Mock 两种用法,而它们可以是同一个对象:
UserRepository repository = mock(UserRepository.class);
// 当成 Stub 用:准备返回值
when(repository.findById("u1")).thenReturn(Optional.of(user));
// 当成 Mock 用:验证交互
verify(repository).save(any());// 1. 创建替身
UserRepository repository = mock(UserRepository.class);
// 2. 打桩(Stub):指定输入对应的输出
when(repository.findById("u1")).thenReturn(Optional.of(user));
when(repository.findById("u404")).thenReturn(Optional.empty());
// 3. 验证(Mock):确认某次交互发生过
verify(repository).save(any(User.class));
verify(repository, never()).deleteById("u1");
verifyNoInteractions(notifier); // 完全没碰过这个替身常用验证模式:
| 写法 | 含义 |
|---|---|
verify(x).method() | 恰好调用一次 |
verify(x, times(2)).method() | 恰好两次 |
verify(x, never()).method() | 从未调用 |
verify(x, atLeastOnce()).method() | 至少一次 |
verifyNoInteractions(x) | 该替身完全没有交互 |
verifyNoMoreInteractions(x) | 除了已验证的,没有其他交互 |
// 不可测试:依赖写死在内部,测试时无法替换
public class UserService {
private final UserRepository repository = new MySqlUserRepository();
}
// 可测试:依赖从构造方法传入,测试时传替身
public class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
}"难测试"通常是设计问题的信号:当一个类无法注入依赖时,说明它把创建依赖的责任揽在了自己身上(违反了单一职责)。这也是测试驱动设计的价值之一。
| 该替身 | 理由 |
|---|---|
| 数据库访问(Repository) | 慢、有状态、不可控 |
| 外部服务(HTTP、短信、支付) | 慢、收费、不确定 |
| 时间、随机数 | 结果不可预测 |
| 消息发送、文件写入 | 有副作用 |
| 不该替身 | 理由 |
|---|---|
| 被测类自己 | 替身了就等于没测 |
值对象、record、String | 简单且无副作用,真实使用更可靠 |
| 纯计算逻辑 | 真实调用才能验证正确性 |
interface Clock {
long nowInMillis();
}
@Test
void should_use_fixed_time() {
Clock clock = mock(Clock.class);
when(clock.nowInMillis()).thenReturn(1_000L);
assertEquals(1_000L, clock.nowInMillis());
}业务规则:
这三条规则正好对应三个测试,其中第 1、2 条的重点是验证"什么都没发生"——这是 Mock 最擅长、也是手工测试最难覆盖的部分。
// 错误:把被测对象自己 mock 了,测试变成"验证 Mockito 能用"
UserService service = mock(UserService.class);
when(service.rename("u1", "新名")).thenReturn("新名");
assertEquals("新名", service.rename("u1", "新名")); // 永远通过,毫无意义判断标准:如果一个测试删掉被测类的实现(留空方法体)仍然通过,那它没有在测试任何东西。
接口与模型:labs/testing-unit/src/main/java/cn/kb/learn/testing/
package cn.kb.learn.testing;
import java.util.Optional;
public interface UserRepository {
Optional<User> findById(String id);
User save(User user);
}package cn.kb.learn.testing;
public interface Notifier {
void send(String userId, String message);
}package cn.kb.learn.testing;
public record User(String id, String name, String email) {
public User rename(String newName) {
return new User(id, newName, email);
}
}被测类:
package cn.kb.learn.testing;
public class UserService {
private final UserRepository repository;
private final Notifier notifier;
public UserService(UserRepository repository, Notifier notifier) {
this.repository = repository;
this.notifier = notifier;
}
public String rename(String userId, String newName) {
if (newName == null || newName.isBlank()) {
throw new IllegalArgumentException("新名字不能为空");
}
User user = repository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("用户不存在:" + userId));
if (user.name().equals(newName)) {
return newName;
}
repository.save(user.rename(newName));
notifier.send(userId, "您的名字已更改为 " + newName);
return newName;
}
}测试类:labs/testing-unit/src/test/java/cn/kb/learn/testing/UserServiceTest.java
package cn.kb.learn.testing;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class UserServiceTest {
private UserRepository repository;
private Notifier notifier;
private UserService service;
@BeforeEach
void setUp() {
repository = mock(UserRepository.class);
notifier = mock(Notifier.class);
service = new UserService(repository, notifier);
}
@Test
@DisplayName("用户存在且名字变化:保存新用户并发送通知")
void should_rename_and_notify_when_user_exists() {
// Arrange
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
// Act
String result = service.rename("u1", "新名");
// Assert
assertEquals("新名", result);
verify(repository).save(new User("u1", "新名", "u1@test.com"));
verify(notifier).send("u1", "您的名字已更改为 新名");
}
@Test
@DisplayName("用户不存在:抛异常且不保存、不通知")
void should_throw_when_user_not_found() {
when(repository.findById("u404")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> service.rename("u404", "新名"));
verify(repository, never()).save(any());
verifyNoInteractions(notifier);
}
@Test
@DisplayName("名字未变化:不保存、不通知")
void should_skip_when_name_unchanged() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "同名", "u1@test.com")));
assertEquals("同名", service.rename("u1", "同名"));
verify(repository, never()).save(any());
verifyNoInteractions(notifier);
}
}运行方式(Maven):
mvn -pl testing-unit test预期输出:
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS为什么 verify 里的 save 能匹配上
verify(repository).save(new User("u1", "新名", "u1@test.com")) 依赖 User 的 equals。这里 User 是 record,自动按字段比较——所以能匹配。如果 User 是普通类且没重写 equals,这条验证会失败,你会怀疑"代码没执行",其实是"对象不相等"。
在 verify(repository).save(...) 处打断点,展开 Variables 里的 repository,可以看到 Mockito 生成的替身对象名(如 userRepository$MockitoMock$123)以及它记录的调用历史;
故意把 verify(notifier).send("u1", "改动的内容") 的消息文本写错,运行后看失败信息:
Argument(s) are different! Wanted:
notifier.send("u1", "改动的内容");
Actual invocations have different arguments:
notifier.send("u1", "您的名字已更改为 新名");这段信息直接给出"期望调用"与"实际调用"的对比,是 Mockito 最好用的地方;
把 verify(repository, never()).save(any()) 改成 verify(repository).save(any()),观察失败信息变成 Wanted but not invoked——用来区分"调用了但参数不对"与"根本没调用"这两类问题。
// 没有 when(...),findById 默认返回 null
User user = repository.findById("u1").orElseThrow(); // NullPointerException原因:Mockito 的默认返回值是"空值"(对象返回 null、Optional 返回 Optional.empty()、数字返回 0、boolean 返回 false)。
修复:明确打桩;如果就该返回空,用 thenReturn(Optional.empty())。
when(repository.findById(new UserId("u1"))).thenReturn(Optional.of(user));
// 实际调用时传的是另一个 UserId 实例,equals 未重写 → 打桩不生效修复:参数用 record 或重写 equals;或使用 any() 等匹配器放宽条件(但别为了绕过问题而放宽)。
verify(repository, times(2)).findById("u1"); // 实现改一次查询,测试就红了修复:只验证业务结果(保存了什么、通知了什么),不要验证"查了几次"这类实现细节。默认 verify(x).method()(一次)已经足够表达意图。
OrderCalculator calculator = mock(OrderCalculator.class); // 把被测逻辑 mock 掉了修复:被测类要真实实例化;只有依赖才需要替身。
目标:为一段带依赖的业务逻辑写出完整测试。
步骤:
UserService 增加方法 delete(String userId):用户不存在时抛异常;存在时先 repository.delete(user) 再 notifier.send(userId, "账号已删除")(需要给 UserRepository 加 delete 方法);delete 写三个测试:正常删除、用户不存在、以及"删除时不发送改名通知"(verify(notifier, never()).send("u1", "您的名字已更改为 新名"));notifier.send(...),确认测试变红;恢复后确认变绿。验收方式:能解释第 4 步"测试是怎么发现漏掉的通知"。
扩展任务:把 notifier 换成 List<Notifier>(多个通知渠道),测试"两个渠道都收到通知"与"第一个渠道抛异常时的行为"(提示:这涉及异常处理策略,先写清期望再实现)。
基础题
mock() 创建的对象,未打桩的方法会调用真实实现。(对 / 错)代码阅读题
UserRepository repository = mock(UserRepository.class);
when(repository.findById("u1")).thenReturn(Optional.of(new User("u1", "张三", "a@b.c")));
UserService service = new UserService(repository, mock(Notifier.class));
assertThrows(IllegalArgumentException.class, () -> service.rename("u1", " "));编程题
ArticleService 写测试(先给出接口实现,再写 4 个测试):public interface ArticleRepository {
Optional<Article> findById(String id);
Article save(Article article);
}
public record Article(String id, String title, String content, boolean published) {}
public class ArticleService {
private final ArticleRepository repository;
public ArticleService(ArticleRepository repository) { this.repository = repository; }
/** 发布文章:标题为空或内容为空时抛异常;已发布时抛异常;否则发布并保存 */
public Article publish(String id, String title, String content) { ... }
}综合任务
OrderService: StockRepository(查库存、扣库存)与 PaymentGateway(扣款);提示 1(方向):第 5 题的四个测试分别是:标题为空、内容为空、已发布、正常发布。
提示 2(关键点):第 6 题的"回滚"要验证 StockRepository 被调用了两次(扣减 + 回滚),可以用 verify(stockRepository, times(2)).update(any())。
提示 3(接近答案):第 6 题里"库存不足时不调用扣款"用 verifyNoInteractions(paymentGateway) 最直观;"扣款失败"要注意 charge 返回 void,必须用 doThrow(...).when(paymentGateway).charge(any()),写成 when(...).thenThrow(...) 直接编译不过。
mock() 创建的是完全空壳的替身,所有方法默认返回空值,不会调用真实实现(要部分真实行为请用 spy)。when(...).thenReturn(...)、verify(...)。不会通过。
rename 的实现里,空名字校验在查用户之前:
if (newName == null || newName.isBlank()) {
throw new IllegalArgumentException("新名字不能为空");
}所以调用会抛异常,测试会通过断言——等一下,结论要看完:" " 是空白字符串,isBlank() 返回 true,确实抛异常,所以这个测试会通过。
它的价值在于:验证"空名字校验发生在查询数据库之前"——这也是为什么 when(repository.findById("u1")) 在这里其实是多余的(不会被调用)。如果你想顺带验证这一点,可以补上 verifyNoInteractions(repository)。
package cn.kb.learn.testing;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ArticleServiceTest {
private ArticleRepository repository;
private ArticleService service;
@BeforeEach
void setUp() {
repository = mock(ArticleRepository.class);
service = new ArticleService(repository);
}
@Test
@DisplayName("标题为空时抛出异常且不保存")
void should_throw_when_title_blank() {
assertThrows(IllegalArgumentException.class, () -> service.publish("a1", " ", "内容"));
verify(repository, never()).save(any());
}
@Test
@DisplayName("内容为空时抛出异常且不保存")
void should_throw_when_content_blank() {
assertThrows(IllegalArgumentException.class, () -> service.publish("a1", "标题", ""));
verify(repository, never()).save(any());
}
@Test
@DisplayName("文章已发布时抛出异常")
void should_throw_when_already_published() {
when(repository.findById("a1"))
.thenReturn(Optional.of(new Article("a1", "标题", "内容", true)));
assertThrows(IllegalStateException.class, () -> service.publish("a1", "标题", "内容"));
verify(repository, never()).save(any());
}
@Test
@DisplayName("正常发布:保存为已发布状态")
void should_publish_and_save() {
when(repository.findById("a1"))
.thenReturn(Optional.of(new Article("a1", "旧标题", "旧内容", false)));
when(repository.save(any(Article.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
Article published = service.publish("a1", "新标题", "新内容");
assertTrue(published.published());
verify(repository).save(new Article("a1", "新标题", "新内容", true));
}
}要点:前三个测试都在验证"不该发生的事"(不保存),这正是 Mock 的核心价值。
class OrderServiceTest {
private StockRepository stockRepository;
private PaymentGateway paymentGateway;
private OrderService service;
@BeforeEach
void setUp() {
stockRepository = mock(StockRepository.class);
paymentGateway = mock(PaymentGateway.class);
service = new OrderService(stockRepository, paymentGateway);
}
@Test
@DisplayName("正常下单:扣库存、扣款、发送通知")
void should_place_order_successfully() {
when(stockRepository.stockOf("sku-1")).thenReturn(10);
service.place("sku-1", 2);
verify(stockRepository).decrease("sku-1", 2); // 必须验证:核心副作用
verify(paymentGateway).charge(any()); // 必须验证:资金相关
}
@Test
@DisplayName("库存不足:抛出异常且不调用扣款")
void should_not_charge_when_stock_insufficient() {
when(stockRepository.stockOf("sku-1")).thenReturn(1);
assertThrows(IllegalStateException.class, () -> service.place("sku-1", 2));
verify(stockRepository, never()).decrease(any(), anyInt());
verifyNoInteractions(paymentGateway); // 资金绝不能动
}
@Test
@DisplayName("扣款失败:库存被回滚")
void should_rollback_stock_when_payment_fails() {
when(stockRepository.stockOf("sku-1")).thenReturn(10);
doThrow(new RuntimeException("扣款失败")).when(paymentGateway).charge(any());
assertThrows(RuntimeException.class, () -> service.place("sku-1", 2));
InOrder inOrder = Mockito.inOrder(stockRepository);
inOrder.verify(stockRepository).decrease("sku-1", 2);
inOrder.verify(stockRepository).increase("sku-1", 2); // 先扣后加,顺序不能反
}
}必须验证的:影响外部世界的动作(扣库存、扣款、发通知)和顺序(先扣库存再扣款,失败必须反向补偿)。 不该验证的:内部查询次数、日志打印、私有方法调用。
InOrder 是验证顺序的工具——当你关心"A 必须在 B 之前发生"时用它,而不是给每个 verify 加 times(1)。
when/thenReturn)与验证(verify/never/verifyNoInteractions);verify、参数匹配器与 InOrder《JaCoCo:覆盖率指标与报告》 会给出"测试写了多少"的量化反馈;T1-016 Spring 切片测试 会把这套思路搬到 Spring 环境。