《Java 编程入门》16.2 测试替身:Stub、Fake 与 Mock

把 Dummy、Stub、Fake、Spy、Mock 五种替身放回 library 的仓储接口上:用 Stub 制造「书不存在」,用 Fake 验证借还流程,用记录型 Mock 检查 save 调用次数,并说明什么时候不该验证交互、如何避免测试变脆。

本节目标:能分清 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 个测试,用了三种替身:

测试替身验证什么
stubReturnsCannedBookForBorrowStub借书成功,到期日正确
missingBookIsReportedBeforeAnyLoanIsCreatedStub书不存在时不创建借阅
fakeRepositoryWorksLikeTheRealOneForTheServiceFake新增后能查回来
fakeRepositoryDetectsDuplicateIsbnFake重复 ISBN 被拒绝
activeLoanBlocksSecondBorrowOfSameIsbnFake同一本书不能借两次
mockVerifiesSaveWasCalledExactlyOnceRecording新增调用 save 一次
mockVerifiesDuplicateDoesNotReachSaveRecording重复时不调用 save
returningALoanChangesOnlyItsStatusFake归还只改状态,不改到期日
renewUsesPolicyAndKeepsTheSameLoanIdFake续借保留 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 可测试性设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发