本节目标:能分清 Stub、Fake、Mock 各自解决什么问题,能为
LibraryRepository手写三种替身,并用它们测试LibraryService的正常路径和错误路径;同时知道过度验证交互会让测试变脆。
适用版本:JDK 25(对照 JDK 21);JUnit Jupiter 5.11.4。本节不引入 Mockito,替身全部手写,便于看清原理。
上一节留下的问题
16.1 的 LoanPolicyTest 只测了纯计算:给日期,算到期日、罚金。它不依赖文件、数据库、网络,所以直接 new LoanPolicy(clock) 就能测。
LibraryService 不一样。它依赖 LibraryRepository:
public final class LibraryService {
private final LibraryRepository repository;
private final LoanPolicy policy;
private final Supplier<String> loanIds;
private final Map<String, Loan> loans = new LinkedHashMap<>();
public Loan borrow(String isbn, String memberId, LocalDate today) {
Book book = repository.findByIsbn(isbn)
.orElseThrow(() -> new LibraryException(
ErrorCode.BOOK_NOT_FOUND, "图书不存在: " + isbn));
if (activeLoanFor(isbn).isPresent()) {
throw new LibraryException(
ErrorCode.BOOK_ALREADY_BORROWED, "图书已借出: " + book.title());
}
Loan loan = new Loan(loanIds.get(), isbn, memberId, today,
policy.dueDate(today), 0, LoanStatus.ACTIVE);
loans.put(loan.id(), loan);
return loan;
}
}
如果测试时用真实的 BookFileStore,每个测试都要准备 CSV、清理临时目录,速度慢,还会因为文件权限、磁盘状态而失败。更麻烦的是「书不存在」「保存失败」这类路径,用真实仓储很难稳定制造。
测试替身(test double)就是用来替换真实依赖的对象。 它不实现完整功能,只实现测试需要的那部分行为。按用途可以分成五种,但手写时边界并不严格,关键是知道当前测试需要哪一种。
五种替身
| 类型 | 做什么 | 不做什么 | library 里的例子 |
|---|---|---|---|
| Dummy | 只为填参数,不应被调用 | 任何真实行为 | 传一个空 LibraryRepository 给不需要仓储的方法 |
| Stub | 对调用返回预设答案 | 不记录、不验证 | findByIsbn 总是返回同一本书 |
| Fake | 有一个可工作的简化实现 | 不追求生产级性能、事务、持久化 | 用 Map 存书的 FakeRepository |
| Spy | 包住真实对象,记录调用 | 不改变真实行为 | 包一层 InMemoryLibraryRepository 并计数 |
| Mock | 预设期望,验证调用 | 通常不实现真实逻辑 | 记录 save 调用次数和参数 |
两个容易混淆的点:
- Stub 和 Fake 的区别在「是否有行为」。 Stub 是「问什么答什么」,Fake 是「真的能存能查,只是简化」。
- Mock 和 Spy 的区别在「期望是谁定的」。 Mock 通常先设期望再验证;Spy 先记录,测试结束时再看记录。手写时两者可以合并成一个「记录型替身」。
这一节不引 Mockito。Mockito 能自动生成替身、校验调用,但它是额外依赖,而且会让人跳过「这个依赖该不该被替换」的思考。卷二讲测试策略时再引入。
Stub:给固定答案
Stub 最简单:实现接口,方法返回预设值,其他方法抛异常或返回空。
static final class StubRepository implements LibraryRepository {
private final Optional<Book> book;
StubRepository(Optional<Book> book) {
this.book = book;
}
@Override
public Optional<Book> findByIsbn(String isbn) {
return book;
}
@Override
public List<Book> findAll() {
return book.map(List::of).orElseGet(List::of);
}
@Override
public void save(Book book) {
throw new UnsupportedOperationException("stub 不保存");
}
@Override
public boolean deleteByIsbn(String isbn) {
throw new UnsupportedOperationException("stub 不删除");
}
}
用 Stub 测「书不存在」:
@Test
void missingBookIsReportedBeforeAnyLoanIsCreated() {
StubRepository stub = new StubRepository(Optional.empty());
LibraryService service = new LibraryService(stub, policy, () -> "L-1");
LibraryException error = assertThrows(LibraryException.class,
() -> service.borrow("0000000000", "M-1", TODAY));
assertTrue(error.getMessage().contains("图书不存在"));
assertTrue(service.activeLoans().isEmpty());
}
这个测试只关心两件事:异常类型和「没有创建借阅记录」。Stub 返回 Optional.empty(),正好走到 orElseThrow 分支。save 和 delete 抛异常是故意的:如果被测代码意外调用了它们,测试会立刻失败,而不是悄悄通过。
Stub 的纪律:
- 只返回测试需要的数据。 不要在 Stub 里写业务逻辑。
- 未使用的方法抛
UnsupportedOperationException。 沉默地返回null会让 bug 藏起来。 - Stub 不做断言。 断言放在测试方法里,Stub 只负责提供输入。
Fake:可工作的简化实现
Fake 有一个真正能用的实现,只是用内存代替数据库、用简化算法代替复杂算法。FakeRepository 用 LinkedHashMap 存书:
static final class FakeRepository implements LibraryRepository {
private final Map<String, Book> books = new LinkedHashMap<>();
@Override
public Optional<Book> findByIsbn(String isbn) {
return Optional.ofNullable(books.get(isbn));
}
@Override
public List<Book> findAll() {
return new ArrayList<>(books.values());
}
@Override
public void save(Book book) {
books.put(book.isbn(), book);
}
@Override
public boolean deleteByIsbn(String isbn) {
return books.remove(isbn) != null;
}
}
它和 InMemoryLibraryRepository 很像,区别是测试可以随便改它、观察它,不用管生产代码的演进。用 Fake 测借还流程:
@Test
void activeLoanBlocksSecondBorrowOfSameIsbn() {
FakeRepository fake = new FakeRepository();
fake.save(EFFECTIVE_JAVA);
LibraryService service = new LibraryService(fake, policy, () -> "L-1");
service.borrow(EFFECTIVE_JAVA.isbn(), "M-1", TODAY);
LibraryException error = assertThrows(LibraryException.class,
() -> service.borrow(EFFECTIVE_JAVA.isbn(), "M-2", TODAY));
assertTrue(error.getMessage().contains("已借出"));
}
Fake 的价值是让多个步骤串起来:先存书,再借,再借一次,观察状态变化。Stub 只能回答一次查询,做不了这种流程。
Fake 的风险是和真实实现行为不一致。比如真实仓储在 save 时会校验 ISBN 唯一性,Fake 直接覆盖;测试通过,生产却出问题。降低风险的办法:
- Fake 只实现接口契约里测试用到的部分,并在类注释里写清楚「未模拟事务、并发、唯一约束」。
- 对 Fake 本身写少量测试,确认它的行为和真实实现一致。
- 关键契约用集成测试覆盖真实实现,Fake 只用于单元测试。
Mock / Spy:验证交互
有些行为没有返回值,只能通过「调用了什么」来验证。比如 addBook 成功后必须调用 repository.save:
static final class RecordingRepository implements LibraryRepository {
private final FakeRepository store = new FakeRepository();
private final List<Book> saved = new ArrayList<>();
private int saveCalls;
private int findCalls;
@Override
public Optional<Book> findByIsbn(String isbn) {
findCalls++;
return store.findByIsbn(isbn);
}
@Override
public List<Book> findAll() {
return store.findAll();
}
@Override
public void save(Book book) {
saveCalls++;
saved.add(book);
store.save(book);
}
@Override
public boolean deleteByIsbn(String isbn) {
return store.deleteByIsbn(isbn);
}
}
这个替身同时是 Fake(内部有 store)和 Spy(记录调用)。测试它:
@Test
void mockVerifiesSaveWasCalledExactlyOnce() {
RecordingRepository recording = new RecordingRepository();
LibraryService service = new LibraryService(recording, policy, () -> "L-1");
service.addBook(EFFECTIVE_JAVA);
assertEquals(1, recording.saveCalls);
assertEquals(List.of(EFFECTIVE_JAVA), recording.saved);
assertEquals(1, recording.findCalls);
}
另一个测试验证「重复 ISBN 不应该走到 save」:
@Test
void mockVerifiesDuplicateDoesNotReachSave() {
RecordingRepository recording = new RecordingRepository();
recording.store.save(EFFECTIVE_JAVA);
LibraryService service = new LibraryService(recording, policy, () -> "L-1");
assertThrows(LibraryException.class, () -> service.addBook(EFFECTIVE_JAVA));
assertEquals(0, recording.saveCalls);
assertEquals(1, recording.findCalls);
}
这两个测试验证的是交互,不是状态。save 没有返回值,只能看它有没有被调用、调用了几次、参数是什么。
但交互验证很容易过度。下面这些断言会让测试变脆:
- 断言
findByIsbn被调用了 1 次——实现改成先查缓存再查仓储,测试就失败,但行为没变。 - 断言调用顺序——除非顺序本身是契约,否则不该锁死。
- 断言内部方法的调用——测试应该面向公开行为,不是实现细节。
判断标准:这个调用是外部可观察契约的一部分吗? addBook 必须持久化,是契约;findByIsbn 被调用几次,不是。
在 library 里落地
LibraryServiceTest 一共 9 个测试,用了三种替身:
| 测试 | 替身 | 验证什么 |
|---|---|---|
stubReturnsCannedBookForBorrow | Stub | 借书成功,到期日正确 |
missingBookIsReportedBeforeAnyLoanIsCreated | Stub | 书不存在时不创建借阅 |
fakeRepositoryWorksLikeTheRealOneForTheService | Fake | 新增后能查回来 |
fakeRepositoryDetectsDuplicateIsbn | Fake | 重复 ISBN 被拒绝 |
activeLoanBlocksSecondBorrowOfSameIsbn | Fake | 同一本书不能借两次 |
mockVerifiesSaveWasCalledExactlyOnce | Recording | 新增调用 save 一次 |
mockVerifiesDuplicateDoesNotReachSave | Recording | 重复时不调用 save |
returningALoanChangesOnlyItsStatus | Fake | 归还只改状态,不改到期日 |
renewUsesPolicyAndKeepsTheSameLoanId | Fake | 续借保留 loanId,延长到期日 |
在本机执行:
mvn -Dtest=LibraryServiceTest test
真实输出:
[INFO] Running LibraryService 与测试替身
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.064 s -- in LibraryService 与测试替身
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
注意 LibraryServiceTest 的构造方式:
LibraryService service = new LibraryService(stub, policy, () -> "L-1");
第三个参数是借阅 ID 生成器。生产代码用 UUID.randomUUID(),测试用固定值 "L-1"。这样断言 loan.id() 时不用做模糊匹配,失败信息也能直接看出是哪个 ID。这个构造器是包级可见的,测试类和被测类在同一个包下,所以能访问;生产调用方用两参数构造器。
替身的选择可以按一句话判断:
- 只需要一个固定答案:Stub。
- 需要多步流程和状态:Fake。
- 需要验证「有没有调用」:Recording / Mock。
- 只是想填参数、不应调用:Dummy,通常直接传
null或一个空实现,但要确保真的不会调用。
常见错误与边界
用 Mock 替身值对象。 Book、Member、Loan 是不可变 record,直接 new 一个真实对象更简单,也更接近生产。Mock 它们只会让测试更难读。
Stub 返回 null。 接口返回 Optional<Book>,Stub 就应该返回 Optional.empty(),不要返回 null。否则被测代码在 orElseThrow 之前就 NullPointerException,错误信息误导排查方向。
Fake 和真实实现行为分叉。 Fake 允许重复 ISBN 覆盖,真实仓储抛异常,测试就会漏掉 bug。Fake 要尽量遵守接口契约,关键契约再用真实实现做集成测试。
过度验证交互。 断言每个方法的调用次数和顺序,实现一改测试就红。只验证外部契约要求的调用,比如「必须持久化」「不能重复持久化」。
测试之间共享 Fake 状态。 把 Fake 做成 static 字段,前一个测试存的书会出现在后一个测试里。每个测试在 @BeforeEach 里新建 Fake,或者用实例字段。
替身里写业务逻辑。 Stub 里根据 ISBN 返回不同书、Fake 里计算罚金,都会让测试依赖替身的正确性。业务逻辑放被测类,替身只提供数据。
忘记测试错误路径。 只测「借书成功」,不测「书不存在」「已借出」「重复 ISBN」。错误路径往往才是 bug 所在,而它们恰恰最需要替身来稳定制造。
小结
- 测试替身用来替换慢、不稳定或难以制造特定状态的依赖。
- Stub 返回预设答案,Fake 有可工作的简化实现,Mock/Spy 记录调用用于交互验证。
- Stub 的未使用方法应抛异常,Fake 要遵守接口契约,Mock 只验证外部可观察的调用。
LibraryServiceTest用三种替身覆盖借书、重复 ISBN、已借出、归还、续借等 9 条路径。- 借阅 ID 用
Supplier<String>注入,测试固定为"L-1",断言更稳定。 - 过度验证交互会让测试和实现耦合;先问「这个调用是契约的一部分吗」。
- 错误路径至少和正常路径一样重要,替身正是制造错误路径的工具。
替身能解决「依赖不好控制」,但如果代码把 LocalDate.now()、new File(...)、UUID.randomUUID() 写死在方法内部,再好的替身也插不进去。下一节从设计层面讲可测试性:哪些地方需要留缝,怎么让时间、ID、IO 从外部进来。
阅读导航:上一节:16.1 JUnit 5:断言、生命周期与参数化 · 下一节:16.3 可测试性设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。