主题
界面
主题
《测试替身全景:Stub、Spy、Fake 与 Mock 边界》 把替身的类型讲清了。本节解决三个具体问题:setUp 写得太啰嗦、参数对象的断言太粗、返回值需要动态计算。
这三个问题的答案分别是 @Mock / @InjectMocks、ArgumentCaptor、thenAnswer。它们能把测试从"能用"提升到"精确"。
完成本节后,你应该能够:
@ExtendWith(MockitoExtension.class) + @Mock / @InjectMocks 替代手写 setUp;UnnecessaryStubbingException;ArgumentCaptor 捕获参数对象,逐个字段断言;getAllValues() 校验多次调用的参数序列;thenAnswer 让返回值依赖入参;doThrow / doReturn 给 void 方法与 spy 打桩。@BeforeEach 吗?(T1-002)看一段能工作的测试,找出它的三个问题:
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
void rename() {
when(repository.findById("u1")).thenReturn(Optional.of(new User("u1", "旧名", "a@b.c")));
service.rename("u1", "新名");
verify(repository).save(new User("u1", "新名", "a@b.c")); // ② 靠 equals 整体比对
}
}问题 ①:样板代码。每个测试类都要写三行 mock 声明 + 三行 setUp 赋值。
问题 ②:断言太粗。save(new User(...)) 依赖 User 的 equals。如果 User 是个没重写 equals 的普通类,这行永远不相等;即使重写了,失败时你也只知道"对象不等",不知道是哪个字段错了。
问题 ③:静态返回值不够用。当返回值需要根据入参计算时,thenReturn 写不出来。
@ExtendWith(MockitoExtension.class)
class AdvancedMockitoTest {
@Mock
private UserRepository repository; // 自动创建 mock
@Mock
private Notifier notifier;
@InjectMocks
private UserService service; // 自动创建实例,并把上面的 mock 注入进去
}三个注解的分工:
| 注解 | 做什么 |
|---|---|
@ExtendWith(MockitoExtension.class) | 让 JUnit 5 在测试前后自动初始化与校验 mock |
@Mock | 创建 mock 并注入到字段 |
@InjectMocks | 创建被测对象,并把 @Mock 字段注入进去 |
@InjectMocks 的注入顺序:
实践建议:生产代码用构造器注入(T1-003 讲过它对可测试性的价值),这样 @InjectMocks 走的就是第一条路径,最不容易出意外。
@ExtendWith 忘了写会怎样:@Mock 字段是 null,测试在 when(repository.findById(...)) 处抛 NullPointerException。这是最常见的"注解不生效"现场。
MockitoExtension 默认开启 STRICT_STUBS,它做两件事:
UnnecessaryStubbingException);@Test
void rename() {
when(repository.findById("u1")).thenReturn(Optional.of(user));
when(notifier.isEnabled("u1")).thenReturn(true); // ← 打了桩但永远没被调用
service.rename("u1", "新名");
// STRICT_STUBS 会让这个测试失败:
// UnnecessaryStubbingException: Unnecessary stubbings detected
}为什么这是好事:
| 问题 | 没有严格桩 | 有严格桩 |
|---|---|---|
| 复制粘贴留下的多余桩 | 悄悄留着,越积越多 | 立刻失败 |
| 桩的参数写错了(永远匹配不上) | 测试仍然绿,但"保护力"是假的 | 失败并提示 |
| 桩打得太多、掩盖了真实逻辑 | 无人察觉 | 被迫精简 |
遇到 UnnecessaryStubbingException 怎么办:
@MockitoSettings(strictness = Strictness.LENIENT) 或对单个桩用 lenient()——但先问自己"为什么需要它"。经验之谈:严格桩一开始会让人烦,但它是**把"假绿的测试"变成"真红的测试"**的机制。不要为了消灭报错而全局关掉它。
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
User saved = captor.getValue(); // 拿到实际传进去的对象
assertThat(saved.id()).isEqualTo("u1");
assertThat(saved.name()).isEqualTo("新名");
assertThat(saved.email()).isEqualTo("u1@test.com");它比 verify(repo).save(new User(...)) 强在哪:
| 整体比对 | ArgumentCaptor | |
|---|---|---|
| 依赖 | 参数的 equals 实现 | 不依赖 |
| 失败信息 | "两个对象不等" | "name 期望 X,实际 Y" |
| 能力 | 只能比对已知值 | 能检查运行时才知道的值 |
第三点很关键:有些参数值在测试里写不出来(比如"当前时间戳""自动生成的 id")。用 captor 拿到后,可以断言它的性质而不是具体值:
User saved = captor.getValue();
assertThat(saved.id()).isNotBlank();
assertThat(saved.createdAt()).isBefore(Instant.now());多次调用用 getAllValues():
verify(repository, times(2)).save(captor.capture());
assertThat(captor.getAllValues())
.extracting(User::name)
.containsExactly("新名一", "新名二");注意顺序:getAllValues() 按调用顺序返回,所以能验证"先保存了 A 再保存了 B"。
惯用位置:ArgumentCaptor.forClass(...) 通常写在 verify 附近(就地创建),而不是在 @BeforeEach 里——这样读者一眼就知道它捕获的是哪次调用。
when(repository.findById(anyString())).thenAnswer(invocation -> {
String id = invocation.getArgument(0); // 取第 0 个参数
return Optional.of(new User(id, "名字", "x@y.z"));
});与 thenReturn 的区别:
thenReturn | thenAnswer | |
|---|---|---|
| 返回值 | 固定值 | 每次调用时计算 |
| 能拿到入参 | ✗ | ✓(invocation.getArgument(n)) |
| 能拿到调用次数 | ✗ | ✓(invocation.getArguments().length 或自己计数) |
| 适用 | 大多数场景 | 需要"根据输入给输出"时 |
典型的三个用例:
提醒:thenAnswer 里的逻辑如果复杂到需要注释,说明你正在用 mock 实现业务逻辑——这通常是该换 Fake 的信号(T1-010)。
doThrow(new IllegalStateException("数据库不可用")).when(repository).save(any());为什么需要 do... 系列:when(mock.voidMethod()) 这种写法语法上就编译不过(void 方法没有返回值可以传给 when)。
| 场景 | 写法 |
|---|---|
| 对 void 方法抛异常 | doThrow(ex).when(mock).voidMethod(args) |
| 对 void 方法什么都不做 | doNothing().when(mock).voidMethod(args) |
| 对 spy 打桩(避免真实逻辑先执行) | doReturn(value).when(spy).method(args) |
| 连续行为 | doThrow(ex).doNothing().when(mock).voidMethod(args) |
为什么对 spy 要用 doReturn 而不是 when:
// 危险:spy.method() 会先真的执行一次!
when(spy.method()).thenReturn(value);
// 安全:不会执行真实逻辑
doReturn(value).when(spy).method();原因:when(spy.method()) 里 spy.method() 是被真实调用的——如果它有副作用(写文件、发请求),副作用已经发生了。
记忆点:when(...) 是"先执行、后替换",do...when(...) 是"先替换、后执行"。
when(repository.findById("u1"))
.thenReturn(Optional.of(user))
.thenThrow(new IllegalStateException("连接中断"));语义:第一次调用返回对象,第二次抛异常,之后继续抛。
注意:它适合"验证代码在第二次调用失败时的行为"(如重试、熔断),但不要用它来模拟"对象的状态变化"——那种情况用 Fake 更真实(T1-010)。
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository repository;
@InjectMocks private UserService service;
}6 行 setUp 变成 3 行注解,而且新增依赖时不用改 setUp。
见 5.3 节。这是"参数是对象"时的标准做法。
thenAnswer 里堆业务逻辑 when(pricingClient.quote(any())).thenAnswer(invocation -> {
Order order = invocation.getArgument(0);
// 反例:在测试里重写了定价规则
long total = order.items().stream().mapToLong(Item::price).sum();
if (order.member()) { total -= 500; }
return new Quote(total);
});问题:这段代码重新实现了被测系统的一部分逻辑。如果真实定价规则改了,这个桩不会变——测试仍然绿,但它验证的是一份过期的副本。
修复:用固定的期望值(thenReturn(new Quote(9_000L))),或者用 Fake 承载真实逻辑并单独测试那个 Fake。
文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/AdvancedMockitoTest.java(节选)
@ExtendWith(MockitoExtension.class)
@DisplayName("Mockito 进阶")
class AdvancedMockitoTest {
@Mock
private UserRepository repository;
@Mock
private Notifier notifier;
@InjectMocks
private UserService service;
@Test
@DisplayName("@Mock + @InjectMocks:省去手写构造与 setUp")
void injection_replaces_manual_setup() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
assertThat(service.rename("u1", "新名")).isEqualTo("新名");
verify(notifier).send("u1", "您的名字已更改为 新名");
}
@Test
@DisplayName("ArgumentCaptor:捕获被保存的对象,逐个字段断言")
void argument_captor_inspects_saved_object() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
service.rename("u1", "新名");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
User saved = captor.getValue();
assertThat(saved.id()).isEqualTo("u1");
assertThat(saved.name()).isEqualTo("新名");
assertThat(saved.email()).isEqualTo("u1@test.com");
}
@Test
@DisplayName("thenAnswer:根据传入参数动态生成返回值")
void then_answer_computes_response_from_arguments() {
when(repository.findById(anyString())).thenAnswer(invocation -> {
String id = invocation.getArgument(0);
return Optional.of(new User(id, "名字", "x@y.z"));
});
assertThat(service.rename("u42", "新名")).isEqualTo("新名");
verify(repository).save(new User("u42", "新名", "x@y.z"));
}
@Test
@DisplayName("doThrow:模拟依赖失败,验证异常传播与后续动作被跳过")
void do_throw_simulates_dependency_failure() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
doThrow(new IllegalStateException("数据库不可用")).when(repository).save(any());
assertThatThrownBy(() -> service.rename("u1", "新名"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("数据库不可用");
verify(notifier, never()).send(anyString(), anyString());
}
@Test
@DisplayName("连续桩:第一次正常返回,第二次抛异常")
void consecutive_stubbing_changes_behavior_between_calls() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")))
.thenThrow(new IllegalStateException("连接中断"));
assertThat(service.rename("u1", "新名")).isEqualTo("新名");
assertThatThrownBy(() -> service.rename("u1", "又一个名字"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("连接中断");
}
@Test
@DisplayName("ArgumentCaptor 配合多次调用:捕获全部参数")
void argument_captor_collects_all_invocations() {
when(repository.findById(anyString()))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
service.rename("u1", "新名一");
service.rename("u1", "新名二");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository, times(2)).save(captor.capture());
assertThat(captor.getAllValues())
.extracting(User::name)
.containsExactly("新名一", "新名二");
}
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=AdvancedMockitoTest预期输出:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.458 s
-- in cn.kb.learn.testing.AdvancedMockitoTest注意那个 2.458 秒:这是本类第一次运行时 Mockito 初始化 inline mock maker 的开销(JVM 层的字节码增强)。同一个 JVM 里后续的测试类会快得多——这也解释了为什么"单独跑一个 Mockito 测试类"看起来比"跑整个套件"慢。
六种写法的分工:
| 测试方法 | 演示的 API | 解决的问题 |
|---|---|---|
injection_replaces_manual_setup | @Mock / @InjectMocks | 消灭样板代码 |
argument_captor_inspects_saved_object | ArgumentCaptor | 精确断言参数对象 |
then_answer_computes_response_from_arguments | thenAnswer | 返回值依赖入参 |
do_throw_simulates_dependency_failure | doThrow | 给 void 方法模拟失败 |
consecutive_stubbing_changes_behavior_between_calls | 连续桩 | 模拟"第二次失败" |
argument_captor_collects_all_invocations | getAllValues() | 校验调用序列 |
argument_captor_inspects_saved_object 的 captor.getValue() 处断点,展开 saved:这就是被测代码真实构造出来的对象——不是你在测试里 new 的那个;thenAnswer 的 lambda 里断点,观察 invocation 对象:展开 getArguments() 能看到完整的入参数组;@ExtendWith(MockitoExtension.class) 注释掉,重跑,观察 NullPointerException——@Mock 字段是 null。这是"注解不生效"的最常见现场;doThrow(...).when(repository).save(any()) 处 F7 进入,感受一下它的执行是"先配置好,等调用时再抛";UnnecessaryStubbingException 的完整信息——它会指出哪一行桩是多余的。@ExtendWith(MockitoExtension.class) class MyTest {
@Mock private UserRepository repository; // 没有 Extension → 这个字段是 null
}症状:NullPointerException 出现在 when(repository.xxx()) 那一行。修复:加 @ExtendWith(MockitoExtension.class)。
UnnecessaryStubbingException org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.原因:打了桩但没被调用(常见于复制粘贴、或桩的参数与其他测试不匹配)。
修复顺序:① 删掉它;② 挪到真正需要的测试方法里;③ 确认需要放宽时才用 lenient()。
不要第一反应就是全局关掉严格桩——那等于把体检报告撕掉。
ArgumentCaptor.forClass 用在泛型上 ArgumentCaptor<List<User>> captor = ArgumentCaptor.forClass(List.class); // 编译警告修复:用 @Captor 注解(自动处理泛型):
@Captor
private ArgumentCaptor<List<User>> usersCaptor;when(...) 而不是 doReturn(...) when(spy.method()).thenReturn(value); // spy.method() 已经真实执行了一次!修复:doReturn(value).when(spy).method();
thenAnswer 实现业务逻辑 见 6.3 节。判断标准:thenAnswer 里的代码如果超过三行,或者需要理解业务规则才能看懂,就该换成 Fixed Stub 或 Fake。
verify 的参数用了对象,但对象没重写 equals verify(repository).save(new User("u1", "新名", "a@b.c")); // User 没有 equals → 永远不相等修复:要么确保被比对的类型正确实现了 equals(record 自动有 ✓),要么改用 ArgumentCaptor 逐字段断言——后者更适合"想知道哪里不对"的场景。
目标:把 《测试替身全景:Stub、Spy、Fake 与 Mock 边界》 的测试升级成注解式 + 精确断言。
步骤:
UserServiceTest(T1-003 写的)改造成 @ExtendWith(MockitoExtension.class) + @Mock / @InjectMocks 版本,对比改造前后的行数;should_rename_and_notify_when_user_exists 加上 ArgumentCaptor,逐个字段断言被保存的 User;UnnecessaryStubbingException,然后删掉它;thenAnswer 写一个测试:findById 对任何 id 都返回"该 id 对应的用户"(回显 id),验证 rename 能正确处理任意 id。验收方式:能说出第 4 步失败信息里指出的"多余桩"是哪一行,以及第 5 步为什么需要 thenAnswer 而不是 thenReturn。
扩展任务:给一个"生成订单号"的方法写测试:订单号包含时间戳(无法预先知道具体值)。用 ArgumentCaptor + AssertJ 的 matches(...) 断言它的格式(如 ^ORD-\d{8}-\d{4}$)——这是"断言性质而不是值"的典型写法。
基础题
@InjectMocks 会优先使用构造器注入。(对 / 错)代码阅读题
@ExtendWith(MockitoExtension.class)
class DemoTest {
@Mock private Notifier notifier;
@InjectMocks private UserService service;
@Test
void test() {
when(notifier.isEnabled("u1")).thenReturn(true);
service.delete("u404"); // 用户不存在,会抛异常
}
}isEnabled 的桩(因为另一个测试需要它),应该怎么做?编程题
labs 里的 ArticleService): @Mock / @InjectMocks 初始化;ArgumentCaptor 捕获 repository.save(...) 的参数,断言 id、title、content、published 四个字段;thenAnswer 让 findById 对任意 id 返回"该 id 的未发布文章";doThrow 模拟保存失败,验证异常传播且没有任何副作用(如通知未发出)。综合任务
ArgumentCaptor 断言:新用户对象保留了原有的 email,只改了 name(这是容易写错的地方);thenAnswer 模拟"仓储返回的用户对象带版本号",断言改名后版本号保持不变;UserService 没有重试逻辑,先用 RetryTemplate 包一层);提示 1(方向):第 4 题的关键是"打了桩但没用到"——delete("u404") 在 isEnabled 之前就抛异常了。
提示 2(关键点):第 6 题的 Article 是 record,可以用 extracting 断言多个字段。
提示 3(接近答案):第 7 题的"保留 email / 保留版本号"这类断言最有价值——它们验证的是"改名操作不该顺手清掉别的字段",这是真实 bug 的高发区。
@InjectMocks 的注入顺序是:构造器(参数最多的)→ setter → 字段。所以生产代码用构造器注入时,@InjectMocks 最可靠。ArgumentCaptor;thenAnswer。第 4 题:会报 UnnecessaryStubbingException。
service.delete("u404") 里,"用户不存在"的判断发生在调用 notifier 之前就抛异常了,所以 isEnabled("u1") 的桩从未被使用。
同时:这个测试本身也不完整——delete 抛出的 IllegalArgumentException 没有被断言,它会作为"未预期的异常"让测试失败。
正确写法:
@Test
void throws_when_user_not_found() {
when(repository.findById("u404")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.delete("u404"))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(notifier);
}第 5 题:把它挪到真正需要它的测试方法里。
@Test
void sends_notification_when_enabled() {
when(notifier.isEnabled("u1")).thenReturn(true); // 只有这里需要它
// ...
}为什么不用 lenient() 解决:lenient() 会让测试对"桩是否有效"彻底失去检查。把桩放在需要它的地方,本身就是更好的测试设计——它明确了"这个桩和这个场景相关"。
只有在一种情况下才考虑 lenient():桩确实被多个测试共享、且必须放在 @BeforeEach 里(罕见)。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("文章发布服务:进阶断言")
class ArticleServiceAdvancedTest {
@Mock
private ArticleRepository repository;
@InjectMocks
private ArticleService service;
@Test
@DisplayName("ArgumentCaptor:断言保存的文章四个字段")
void captures_saved_article() {
when(repository.findById("a1")).thenReturn(Optional.of(new Article("a1", "旧标题", "旧内容", false)));
service.publish("a1", "新标题", "新内容");
ArgumentCaptor<Article> captor = ArgumentCaptor.forClass(Article.class);
verify(repository).save(captor.capture());
assertThat(captor.getValue())
.extracting(Article::id, Article::title, Article::content, Article::published)
.containsExactly("a1", "新标题", "新内容", true);
}
@Test
@DisplayName("thenAnswer:任意 id 都返回该 id 的未发布文章")
void then_answer_echoes_id() {
when(repository.findById(anyString())).thenAnswer(invocation -> {
String id = invocation.getArgument(0);
return Optional.of(new Article(id, "标题", "内容", false));
});
Article published = service.publish("a-999", "新标题", "新内容");
assertThat(published.id()).isEqualTo("a-999");
assertThat(published.title()).isEqualTo("新标题");
}
@Test
@DisplayName("doThrow:保存失败时异常传播,且不会返回已发布对象")
void propagates_save_failure() {
when(repository.findById("a1")).thenReturn(Optional.of(new Article("a1", "标题", "内容", false)));
doThrow(new IllegalStateException("磁盘写满")).when(repository).save(any());
assertThatThrownBy(() -> service.publish("a1", "新标题", "新内容"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("磁盘写满");
verify(repository, never()).delete(any());
}
}注意第一条断言的写法:用 AssertJ 的 extracting(...).containsExactly(...) 一次校验四个字段,失败信息会精确指出是哪个字段不对——这比四次单独的 assertThat 更紧凑,也比 isEqualTo 整体比对更精确。
第三条测试的一个细节:verify(repository, never()).delete(any()) 这种"验证没有副作用"的断言,需要生产类确实有 delete 方法才写得出。如果没有,就换成验证"其他方法没被调用"——关键是让断言覆盖"不该发生的事"。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("用户改名:保留不该变的字段")
class RenamePreservesOtherFieldsTest {
@Mock
private UserRepository repository;
@Mock
private Notifier notifier;
@InjectMocks
private UserService service;
@Test
@DisplayName("改名只动 name,email 原样保留")
void rename_keeps_email() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "keep@me.com")));
service.rename("u1", "新名");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
User saved = captor.getValue();
assertThat(saved.name()).isEqualTo("新名");
assertThat(saved.email()).isEqualTo("keep@me.com");
assertThat(saved.id()).isEqualTo("u1");
}
@Test
@DisplayName("thenAnswer 回显:任意 id 都能改名,且 id 不被改动")
void rename_keeps_id_for_any_user() {
when(repository.findById(anyString())).thenAnswer(invocation ->
Optional.of(new User(invocation.getArgument(0), "旧名", "x@y.z")));
service.rename("u-777", "新名");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
assertThat(captor.getValue().id()).isEqualTo("u-777");
}
@Test
@DisplayName("用户不存在时不保存、不通知")
void missing_user_has_no_side_effects() {
when(repository.findById("u404")).thenReturn(Optional.empty());
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
() -> service.rename("u404", "新名"));
verify(repository, org.mockito.Mockito.never()).save(org.mockito.ArgumentMatchers.any());
verify(notifier, org.mockito.Mockito.never()).send(anyString(), anyString());
}
}哪条断言最有价值:
assertThat(saved.email()).isEqualTo("keep@me.com") 这一类"保留字段"的断言最有价值。
理由是它对应了真实的高频 bug:
// 一个很自然但错误的实现
User renamed = new User(user.id(), newName, ""); // 顺手把 email 清空了
// 或者
User renamed = new User(user.id(), newName, null); // 忘了传 email这类 bug 在"只断言 name 变了"的测试里完全不会被发现——因为断言只看了 name。
由此得出一条通用的断言设计原则:
对于"修改类"操作,除了断言"该变的变了",还要断言"不该变的没变"。
这条原则适用范围很广:改密码不该影响用户名、改地址不该清空手机号、更新配置不该重置默认值。测试的价值往往就在于守住这些"没想到会变"的地方——这正是"回归测试"的字面含义。
@Mock / @InjectMocks 替代手写 setUp;@ExtendWith 忘了写会导致什么现象;UnnecessaryStubbingException;ArgumentCaptor(含 getAllValues())做精确的参数断言;thenAnswer 让返回值依赖入参;do...when(...),并说出原因。《何时不该 Mock:过度 Mock 反模式》:Mock 用多了,测试会变成"实现细节的看守人"。
暂无