本节目标:能识别代码里「测试插不进去」的位置,用构造器注入、纯函数、窄接口和确定性输入把依赖变成可替换的缝;并能判断一条逻辑该在单元测试、集成测试还是端到端测试里验证。
适用版本:JDK 25(对照 JDK 21);示例使用 JUnit Jupiter 5.11.4。
上一节留下的问题
16.2 的测试替身能替换仓储,但有一个前提:被测类愿意接收外部传入的依赖。 如果 LibraryService 在构造器里写死 new InMemoryLibraryRepository(),测试就换不掉它;如果 LoanPolicy 在方法里直接 LocalDate.now(),测试就无法固定「今天」。
很多人把这种情况归因于「测试难写」,于是去学更多 Mock 技巧。但真正的问题通常在设计的另一头:代码没有留出可替换的缝(seam)。 这一节不讲新工具,讲四个能让测试变简单的设计动作。
四个杠杆
| 杠杆 | 具体做法 | 解决什么 |
|---|---|---|
| 构造器注入 | 依赖从构造器传入,不在内部 new | 替换仓储、时钟、ID 生成器 |
| 纯函数 | 输入决定输出,不读全局状态 | 规则测试不需要任何替身 |
| 窄接口 | 依赖只声明用到的方法 | 替身小、测试意图清楚 |
| 确定性输入 | 时间、随机数、ID 从外部传入 | 测试可重复、失败可复现 |
这四个动作不是为了测试而加层,它们同时改善了生产的可维护性:依赖关系显式、规则可单独推理、替换实现不用改调用方。
时间是第一大隐藏依赖
最典型的隐藏依赖是时间。下面这个写法很常见:
public Loan borrow(String isbn, String memberId) {
LocalDate today = LocalDate.now();
Loan loan = new Loan(..., today, today.plusDays(14), 0, LoanStatus.ACTIVE);
...
}
它的问题不是「用了系统时间」,而是时间从方法内部凭空出现。测试要么等到某一天,要么改系统时钟,要么接受「今天」不确定。前两种不可接受,第三种会让断言变成「到期日大约在 14 天后」,边界条件根本测不了。
把时间变成构造器依赖:
public final class LoanPolicy {
private final Clock clock;
public LoanPolicy(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}
public static LoanPolicy systemDefault() {
return new LoanPolicy(Clock.systemDefaultZone());
}
public LocalDate today() {
return LocalDate.now(clock);
}
}
生产代码调用 LoanPolicy.systemDefault(),测试传 Clock.fixed(...):
Clock clock = Clock.fixed(
Instant.parse("2026-10-14T01:00:00Z"),
ZoneId.of("Asia/Shanghai"));
LoanPolicy policy = new LoanPolicy(clock);
assertEquals(LocalDate.of(2026, 10, 14), policy.today());
Clock.fixed 让「今天」精确到某一天,测试可以断言 2026-10-28 而不是「大约两周后」。Clock.offset 还能模拟「时间往前走」:固定起点,按需推进,不用 Thread.sleep。
更进一步,业务方法可以直接接收 LocalDate:
public Loan borrow(String isbn, String memberId, LocalDate today)
调用方决定「今天」是哪天,规则本身不读时钟。测试传任意日期,生产传 policy.today()。这两种方式都行:
- 传日期参数:最直接,适合规则方法。缺点是每个调用点都要记得传。
- 注入
Clock:适合需要「当前时间」的服务,调用方不用每次传。
library 两种都用:LoanPolicy.dueDate(LocalDate) 是纯函数,LibraryService.borrow 有一个接收日期的重载,也有一个用 policy.today() 的便捷重载。
创建与使用分离
第二个常见问题是「自己创建依赖」:
public final class LibraryService {
private final LibraryRepository repository = new InMemoryLibraryRepository();
}
这行代码把「用仓储」和「建仓储」绑在一起。测试想换 Fake,只能改生产代码,或者用反射。正确做法是构造器注入:
public final class LibraryService {
private final LibraryRepository repository;
private final LoanPolicy policy;
private final Supplier<String> loanIds;
public LibraryService(LibraryRepository repository, LoanPolicy policy) {
this(repository, policy, () -> UUID.randomUUID().toString());
}
public LibraryService(LibraryRepository repository, LoanPolicy policy,
Supplier<String> loanIds) {
this.repository = Objects.requireNonNull(repository, "repository");
this.policy = Objects.requireNonNull(policy, "policy");
this.loanIds = Objects.requireNonNull(loanIds, "loanIds");
}
}
两个构造器分工明确:
- 两参数构造器给生产用,ID 生成器默认是 UUID。
- 三参数构造器给测试用,可以传固定序列。
「谁负责创建」被上移到 LibraryApp 或工厂:
public final class LibraryApp {
public static void main(String[] args) {
LibraryRepository repository = new InMemoryLibraryRepository();
LoanPolicy policy = LoanPolicy.systemDefault();
LibraryService service = new LibraryService(repository, policy);
// 把 service 交给 CLI
}
}
main 是组合根(composition root),所有具体实现在这里组装。LibraryService 只依赖接口,不知道也不关心仓储是内存、文件还是数据库。测试在测试方法里组装自己的组合根,用 Fake 替换仓储、用固定 Clock 替换系统时钟。
依赖注入不一定要用框架。构造器参数就是最轻量的注入;字段注入、Setter 注入会让依赖关系变隐式,不推荐。卷二的 Spring 容器会接管组装,但设计原则不变:类不自己创建它需要的协作者。
接口要窄
LibraryRepository 现在有四个方法:
public interface LibraryRepository {
Optional<Book> findByIsbn(String isbn);
List<Book> findAll();
void save(Book book);
boolean deleteByIsbn(String isbn);
}
LibraryService 只用到 findByIsbn、findAll 和 save,deleteByIsbn 是给管理命令用的。测试替身要实现全部四个方法,其中 deleteByIsbn 用不到,只能抛 UnsupportedOperationException。
方法少的时候这没什么,方法长到十几个时,替身会变得臃肿。可以考虑按使用方拆接口:
public interface BookReader {
Optional<Book> findByIsbn(String isbn);
List<Book> findAll();
}
public interface BookWriter {
void save(Book book);
boolean deleteByIsbn(String isbn);
}
public interface LibraryRepository extends BookReader, BookWriter {
}
LibraryService 依赖 BookReader 和 BookWriter,测试只需要实现它用到的那部分。这就是接口隔离原则(ISP)在测试上的收益:替身的大小等于接口的大小。
不要为了拆而拆。四个方法的接口还好,拆成四个单方法接口会让调用方组合变复杂。判断标准是「测试替身是否被迫实现大量用不到的方法」,以及「不同调用方是否真的只用其中一部分」。
在 library 里落地
TestableDesignTest 用固定时钟、Fake 仓储和固定 ID 生成器验证四条设计约束:
package com.plume.library.service;
import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;
import com.plume.library.repository.InMemoryLibraryRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@DisplayName("可测试性设计")
class TestableDesignTest {
private static final Book BOOK =
new Book("9780134685991", "Effective Java", "Joshua Bloch", 2018);
private static LoanPolicy policyAt(String instant) {
return new LoanPolicy(Clock.fixed(
Instant.parse(instant), ZoneId.of("Asia/Shanghai")));
}
private static LibraryService service(LoanPolicy policy, String... ids) {
AtomicInteger index = new AtomicInteger();
return new LibraryService(
new InMemoryLibraryRepository(),
policy,
() -> ids[Math.min(index.getAndIncrement(), ids.length - 1)]);
}
@Test
void borrowWithoutDateUsesTheInjectedClock() {
LibraryService service = service(policyAt("2026-10-14T01:00:00Z"), "L-1");
service.addBook(BOOK);
Loan loan = service.borrow(BOOK.isbn(), "M-1");
assertEquals(LocalDate.of(2026, 10, 14), loan.borrowedOn());
assertEquals(LocalDate.of(2026, 10, 28), loan.dueOn());
}
@Test
void explicitDateOverridesTheClockForScenarios() {
LibraryService service = service(policyAt("2026-10-14T01:00:00Z"), "L-1");
service.addBook(BOOK);
Loan loan = service.borrow(BOOK.isbn(), "M-1", LocalDate.of(2026, 1, 1));
assertEquals(LocalDate.of(2026, 1, 15), loan.dueOn());
}
@Test
void twoClocksProduceTwoDifferentDueDates() {
Loan first = openLoan("2026-10-14T01:00:00Z");
Loan second = openLoan("2026-11-14T01:00:00Z");
assertNotEquals(first.dueOn(), second.dueOn());
}
@Test
void idGeneratorMakesIdsDeterministic() {
LibraryService service = service(
policyAt("2026-10-14T01:00:00Z"), "L-1", "L-2");
service.addBook(BOOK);
Loan first = service.borrow(BOOK.isbn(), "M-1");
service.returnLoan(first.id(), LocalDate.of(2026, 10, 15));
Loan second = service.borrow(BOOK.isbn(), "M-1");
assertEquals("L-1", first.id());
assertEquals("L-2", second.id());
}
@Test
void dueDateIsAPureFunctionOfItsArgument() {
LoanPolicy policy = policyAt("2026-10-14T01:00:00Z");
assertEquals(LocalDate.of(2026, 1, 15),
policy.dueDate(LocalDate.of(2026, 1, 1)));
assertEquals(LocalDate.of(2026, 12, 25),
policy.dueDate(LocalDate.of(2026, 12, 11)));
}
private static Loan openLoan(String instant) {
LibraryService service = service(policyAt(instant), "L-1");
service.addBook(BOOK);
return service.borrow(BOOK.isbn(), "M-1");
}
}
本机执行:
mvn -Dtest=TestableDesignTest test
真实输出:
[INFO] Running 可测试性设计
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.050 s -- in 可测试性设计
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
五个测试分别验证:
- 不传日期时,
borrow用注入的Clock,不是LocalDate.now()。 - 显式传日期时,参数覆盖时钟,方便构造跨年、闰日等场景。
- 不同时钟得到不同到期日,证明时间确实从外部进来。
- 固定 ID 生成器让借阅 ID 可预测,
returnLoan后再次借出拿到L-2。 dueDate是纯函数,同样输入总是同样输出,不依赖「今天」。
这些测试没有启动文件、没有改系统时间、没有 Thread.sleep,执行时间在毫秒级。
测试该放在哪一层
可测试性设计还包括「在哪一层验证什么」。library 可以分成四层:
| 层 | 测什么 | 用什么 | 例子 |
|---|---|---|---|
| 领域规则 | 借期、续借、罚金、ISBN | 纯单元测试,无替身 | LoanPolicyTest |
| 应用服务 | 借还流程、重复检查 | Fake / Stub 仓储 | LibraryServiceTest |
| 文件适配 | CSV 读写、BOM、原子写入 | 临时目录 + 真实文件 | BookFileStoreTest |
| CLI / 端到端 | 命令解析、输出格式 | 启动进程或调用 main | 少量冒烟测试 |
金字塔的典型比例是「大量单元测试、适量集成测试、少量端到端测试」。但比例不是目标,关键是每一层只验证它负责的东西:
- 领域规则不需要仓储,也不该因为仓储换实现而失败。
- 应用服务验证编排,不重复验证罚金公式。
- 文件适配验证编码和落盘,不重复验证借阅规则。
- 端到端只验证「命令能跑通、输出有关键字段」,不堆所有边界。
如果一个规则在单元测试里已经覆盖,端到端测试不必再枚举所有边界;反过来,单元测试通过不代表文件真的写对了,适配层仍需要真实文件的集成测试。
常见错误与边界
把 LocalDate.now() 写进业务方法。 测试无法固定时间,只能改系统时钟或接受不确定断言。用 Clock 或日期参数。
在构造器里 new 具体依赖。 测试换不掉实现。把依赖作为构造器参数,具体实现在 main 或工厂里组装。
为了测试把方法改成 public。 测试应该通过公开行为验证,不要为了测私有方法放宽可见性。私有方法是实现细节,改了就该允许测试不变。
构造器参数过多。 七八个参数通常说明这个类承担了太多职责。先看能不能合并成值对象(如 LoanRequest),或者拆类,而不是继续加参数。
什么都 Mock。 值对象、集合、String 直接构造;只有外部边界(IO、时间、随机、网络)才需要替身。Mock 越多,测试越像在验证实现。
测试依赖执行顺序。 JUnit 默认每个方法新建实例,但 static 状态、共享文件、数据库记录仍会串。每个测试自己准备数据、自己清理。
用 Thread.sleep 等异步结果。 测试变慢且不稳定。用 Clock 控制时间,用轮询加超时等待状态,不要睡固定秒数。
只测正常路径。 书不存在、已借出、重复 ISBN、逾期续借,这些错误路径才是 bug 高发区。替身和固定时钟正是为了稳定制造这些状态。
把测试当覆盖率任务。 覆盖率高的测试如果只断言「没抛异常」,价值很低。每条测试至少有一个能失败的断言,且失败信息能指出哪条规则坏了。
小结
- 测试难写往往是设计没留缝:依赖写死在内部、时间从方法里凭空出现、接口太宽。
- 构造器注入让仓储、时钟、ID 生成器可替换;组合根负责组装具体实现。
- 纯函数(如
dueDate(LocalDate))不需要替身,是最容易测的部分。 - 窄接口让替身小、测试意图清楚;按使用方拆接口,但不要为拆而拆。
- 时间用
Clock或日期参数,ID 用Supplier,随机数用注入的Random,测试才能可重复。 - 测试分层:领域规则用单元测试,应用服务用 Fake,文件适配用真实临时目录,端到端只做冒烟。
- 每条测试至少一个能失败的断言,错误路径和正常路径同等重要。
第 16 章到这里结束。library 现在有规则、有 IO、有时间处理、有测试。下一步是把它变成可交付的产物:依赖怎么管、jar 怎么打、构建怎么复现。第 17 章从 Maven 依赖开始。
阅读导航:上一节:16.2 测试替身:Stub、Fake 与 Mock · 下一节:17.1 Maven 依赖、作用域与冲突 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。