《Java 编程入门》16.3 可测试性设计

测试写得吃力,往往不是测试技巧问题,而是设计没有留缝。本节从 library 的 Clock、Supplier、仓储接口出发,讲清构造器注入、纯函数、窄接口与确定性输入四个杠杆,并给出可复用的测试分层、真实 mvn test 输出与反模式清单。

本节目标:能识别代码里「测试插不进去」的位置,用构造器注入、纯函数、窄接口和确定性输入把依赖变成可替换的缝;并能判断一条逻辑该在单元测试、集成测试还是端到端测试里验证。
适用版本: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 依赖、作用域与冲突 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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