《Java 编程实战》15.1 测试金字塔:单元、集成与端到端

libraryd 有 33 个测试,但把领域、仓储、HTTP 和端到端混在一起跑,会让失败定位和反馈速度一起变差。本节按模块划出四层测试,说明每层该依赖什么、不该依赖什么,并用真实的测试数量、耗时与配比解释为什么端到端只保留两个主链路用例。

本节目标:给 libraryd 的多模块测试划定边界,让每条失败都能快速落到具体一层,而不是在一锅端到端测试里猜。
适用版本:JDK 25(Temurin 25.0.5)、JUnit Jupiter 5.14.4、Maven Surefire 3.6.1、Testcontainers 1.21.4、PostgreSQL 17。

承接的问题

到第 14 章为止,libraryd 已经能借书、还书、查缓存、暴露指标。代码分布在五个模块里,测试也散在四个模块里:

  • libraryd-domain 有领域规则和内存仓储测试。
  • libraryd-persistence 有 JDBC 仓储测试。
  • libraryd-web 有 Javalin 路由和 DTO 测试。
  • libraryd-integration-tests 有完整应用测试。

如果只跑一个 mvn test,看到的是 33 个测试全绿或某几个红。但“红在哪一层”决定了修复成本。一个领域规则错误,本应在 50 毫秒内暴露;如果它只在端到端测试里以 409 的形式出现,排查要从 HTTP 日志倒推回 LibraryService。

更常见的问题是测试层用错了依赖。比如为了让测试快,把持久化测试改成 H2。H2 能建 books 表,却会在 V2__book_search_index.sql 的 create extension if not exists pg_trgm 上失败;即使跳过这一条,PostgreSQL 的局部唯一索引 ux_loans_active_isbn 也不是所有数据库都支持。测试通过得很轻松,生产上却可能第一条借阅就报错。

所以这一节不讨论“要不要写测试”,而是回答三个具体问题:

  • 哪一层测试应该启动数据库,哪一层不应该?
  • 哪一层测试应该发 HTTP,哪一层只调用方法?
  • 端到端测试既然最接近生产,为什么不能多写?

核心机制一:四层测试各自验证什么

libraryd 的测试不是经典的三层,而是四层。多出来的一层是“模块内 HTTP”,它介于单元测试和完整端到端之间。

层测试类启动什么验证什么本机耗时
领域单元LibraryServiceTest、LoanTest无借期、罚金、续借、上限、错误码约 0.05 s
持久化集成PostgresRepositoriesTest真实 PostgreSQL 容器SQL、约束、事务、Flyway约 3.0 s
Web 集成LibraryApiTestJavalin + 内存仓储路由、DTO、校验、Problem Details约 1.3 s
端到端EndToEndTest完整 LibraryApplication + PostgreSQL装配、迁移、HTTP、健康检查、指标约 3.6 s

四层的依赖是逐级增加的。领域层只用 InMemoryRepositories,连 JDBC 驱动都不在类路径上;持久化层必须用真实 PostgreSQL;Web 层用 Javalin 的随机端口,但不碰数据库;端到端层把前面所有东西装起来,再加一个真实容器。

这种划分的直接好处是:LibraryServiceTest 失败时,不需要看 Docker 是否启动、端口是否占用、Flyway 是否迁移成功。它只可能错在业务规则。

反过来,EndToEndTest 失败时,也不应该先去改领域代码。它要验证的是“装配是否正确”,比如环境变量有没有传进 AppConfig、迁移有没有在启动时执行、/health 是否会检查数据库。

核心机制二:测试替身放在正确的层

libraryd 有两套内存实现,用途不同,不能混用。

第一套在 libraryd-domain/src/test/java/com/plume/library/domain/InMemoryRepositories.java,实现领域端口,供 LibraryServiceTest 使用:

@BeforeEach
void setUp() {
    books = new InMemoryRepositories.Books();
    members = new InMemoryRepositories.Members();
    loans = new InMemoryRepositories.Loans();
    service = new LibraryService(books, members, loans,
            TransactionManager.direct(), new FinePolicy());
}

TransactionManager.direct() 让服务层的事务调用直接执行,不开启真实事务。领域测试关心的是“借阅规则是否成立”,不是“PostgreSQL 是否回滚”。回滚属于持久化层,由 PostgresRepositoriesTest.transactionRollsBackAllWrites 验证。

它写出来的测试是纯方法调用,没有 HTTP、没有 SQL、没有容器:

@Test
void returnIsIdempotentAndChargesFiftyCentsPerLateDay() {
    givenBook("isbn-1");
    service.borrow("isbn-1", "m-1", TODAY);

    ReturnReceipt first = service.returnBook("isbn-1", TODAY.plusDays(17));
    ReturnReceipt replay = service.returnBook("isbn-1", TODAY.plusDays(30));

    assertEquals(150, first.fineCents());
    assertEquals(first, replay);
    assertEquals(LoanStatus.RETURNED, service.getLoan(1L).status(TODAY));
}

@Test
void renewExtendsDueDateOnce() {
    givenBook("isbn-1");
    service.borrow("isbn-1", "m-1", TODAY);

    Loan renewed = service.renew("isbn-1", TODAY.plusDays(1));

    assertEquals(TODAY.plusDays(28), renewed.dueOn());
    assertEquals(1, renewed.renewCount());
}

TODAY 固定为 LocalDate.of(2026, 10, 20),所以“逾期 17 天罚 150 分”和“续借后到期日加 14 天”都是确定值,不受运行时间影响。这类断言在端到端测试里很难写得这么直接,因为端到端要走 HTTP、读响应 JSON、再反序列化日期。

第二套在 libraryd-web/src/test/java/com/plume/library/web/InMemoryStore.java,一个类同时实现 BookRepository、MemberRepository、LoanRepository。它让 Web 测试可以在不启动数据库的前提下,走完整的 Javalin 请求链路:

InMemoryStore store = new InMemoryStore();
LibraryService service = new LibraryService(store, store, store,
        TransactionManager.direct(), new FinePolicy());
PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
Clock clock = Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"), ZoneOffset.UTC);
app = new LibraryApi(service, registry, registry::scrape, () -> true, null, clock).create();
app.start("127.0.0.1", 0);

注意这里的 () -> true:健康探针被替换成永远返回 UP。Web 测试要验证的是路由和错误映射,不是数据库连通性。数据库健康检查由端到端测试覆盖。

InMemoryStore 不是“假数据库”。它没有 SQL、没有事务、没有约束。它只实现仓储端口的行为契约:保存、查找、分页、活动借阅唯一性。把它当成数据库替身,会漏掉外键、唯一索引和 truncate 语义;把它当成领域测试的协作对象,才是正确用法。

核心机制三:每层测试的生命周期

测试金字塔不只是“分目录”,还要让每层的启动和清理成本匹配它的职责。

领域测试最简单:每个 @Test 前新建三个内存仓储,测试之间没有任何共享状态。LibraryServiceTest 的 10 个测试和 LoanTest 的 5 个测试加起来只花几十毫秒。

持久化测试需要容器,但不能每个测试方法起一次。PostgresRepositoriesTest 用静态字段和 @Container,让一个测试类共用一个 PostgreSQL:

@Testcontainers
class PostgresRepositoriesTest {

    @Container
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:17-alpine")
                    .withDatabaseName("libraryd")
                    .withUsername("libraryd")
                    .withPassword("libraryd");

    @BeforeAll
    static void startDatabase() {
        DatabaseConfig config = new DatabaseConfig(
                POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword(), 4);
        var pool = DataSourceFactory.create(config);
        DatabaseMigrator.migrate(pool);
        dataSource = pool;
        transactions = new ThreadLocalTransactionManager(pool);
        books = new JdbcBookRepository(pool, transactions);
        members = new JdbcMemberRepository(pool, transactions);
        loans = new JdbcLoanRepository(pool, transactions);
    }

    @BeforeEach
    void cleanTables() throws Exception {
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(
                     "truncate loans, members, books restart identity cascade")) {
            statement.execute();
        }
    }
}

truncate ... restart identity cascade 的顺序不能反:loans 引用了 books 和 members,先清子表再清父表,或者直接依赖 cascade。restart identity 让 bigserial 主键从 1 重新开始,测试里才能稳定断言 loan.id() == 1。

Web 测试每个方法起一个 Javalin 实例,端口传 0 让操作系统分配:

@BeforeEach
void startServer() {
    // ... 构造 service 和 registry
    app.start("127.0.0.1", 0);
    baseUrl = "http://127.0.0.1:" + app.port();
}

@AfterEach
void stopServer() {
    app.stop();
}

这里不能用固定端口 8080。开发机上可能已经有一个 libraryd 在跑,CI 上也可能并行执行多个测试类。start("127.0.0.1", 0) 加 app.port() 是 Javalin 提供的标准做法。

端到端测试则把容器和完整应用都放在静态生命周期里。EndToEndTest 的 @BeforeAll 启动 LibraryApplication,@AfterAll 关闭它。两个测试方法共享同一个应用和同一个数据库,所以测试数据必须用不同的 ISBN,或者在方法内自己清理。

核心机制四:端到端测试只验证“装配”

端到端测试最容易膨胀。因为它什么都能测,所以什么都想测。libraryd 的 EndToEndTest 只保留两个方法:

  • fullBorrowReturnLifecycleAgainstRealPostgres:从创建图书、借阅、续借、归还,到健康检查和指标,走一遍主链路。
  • requestIdIsPropagatedThroughTheStack:验证 X-Request-Id 从 HTTP 头进入,经过中间件,再回到响应头。

它不重复验证“ISBN 为空返回 400”——那是 LibraryApiTest 的职责;也不重复验证“逾期 20 天罚 300 分”——那是 LibraryServiceTest 和 PostgresRepositoriesTest 的职责。

端到端测试真正独有的价值是“装配”:

  • AppConfig 从构造参数读到的数据库 URL,是否真的传给了 DataSourceFactory。
  • DatabaseMigrator.migrate 是否在 HTTP 端口打开前执行。
  • LibraryApplication.start 返回后,application.port() 是否可用。
  • /health 是否检查了数据库,/metrics 是否包含 HTTP 和 JVM 指标。
  • application.close() 是否按顺序停掉 Javalin、扫描器、缓存、连接池。

这些问题是单元测试和 Web 测试都覆盖不到的。把它们放在两个端到端测试里,已经足够。

装配代码本身很短,但每一步都可能出错:

@BeforeAll
static void startApplication() {
    AppConfig config = new AppConfig(
            POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword(), 0, 3, "WARN");
    application = LibraryApplication.start(config, "redis://127.0.0.1:1");
    client = HttpClient.newHttpClient();
    baseUrl = "http://127.0.0.1:" + application.port();
}

@AfterAll
static void stopApplication() {
    if (application != null) {
        application.close();
    }
}

这里把 httpPort 设为 0,让 Javalin 选择空闲端口;把 Redis 指向 127.0.0.1:1,让缓存连接快速失败并降级到本地缓存。这样端到端测试既不依赖外部 Redis,也顺带验证了“缓存不可用时服务仍能启动”这一条降级路径。poolSize 设为 3,足够两个测试方法使用,又不会在 CI 上占用过多连接。

在 libraryd 里落地:33 个测试的真实分布

当前工程的测试分布如下:

模块测试类方法数依赖
libraryd-domainLibraryServiceTest10无
libraryd-domainLoanTest5无
libraryd-persistencePostgresRepositoriesTest9Testcontainers PostgreSQL
libraryd-webLibraryApiTest7Javalin、内存仓储
libraryd-integration-testsEndToEndTest2完整应用、Testcontainers PostgreSQL
合计5 个类33—

在本机(Apple Silicon、Colima、Docker 29.5.2、Maven 依赖已缓存)执行 mvn verify,真实输出摘录如下:

[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.plume.library.domain.LoanTest
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 -- in com.plume.library.domain.LibraryServiceTest
[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 -- in com.plume.library.persistence.PostgresRepositoriesTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in com.plume.library.web.LibraryApiTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.plume.library.it.EndToEndTest
[INFO] BUILD SUCCESS
[INFO] Total time:  18.867 s

这个数字不是“测试越多越好”的证据,而是一个配比样本:15 个领域测试、9 个持久化测试、7 个 Web 测试、2 个端到端测试。端到端测试只占 6%,但它覆盖了其他三层都不负责的装配问题。

耗时也能说明问题。领域测试合计约 0.1 秒;持久化测试约 3 秒,其中大部分花在容器启动和 Flyway 迁移;Web 测试约 1.3 秒;端到端测试约 3.6 秒。mvn verify 总共 18.9 秒,还包括编译、打包和 Shade 合并。

如果每次改一行罚金公式都要跑 18 秒,反馈就太慢。开发时的循环应该以单模块测试为主:

# 只跑领域规则,通常两三秒结束
mvn -pl libraryd-domain test

# 改动 SQL 或迁移时,只跑持久化模块;-am 会顺带构建依赖模块
mvn -pl libraryd-persistence -am test

# 改动路由或 DTO 时,只跑 Web 模块
mvn -pl libraryd-web -am test

# 提交前跑完整流程,包含打包和端到端
mvn verify

单模块命令不能省略 -am。libraryd-persistence 依赖 libraryd-domain,如果本地仓库里没有最新版本,Maven 会拿旧快照编译,测试通过但代码不是最新的。-am(also make)会先构建依赖模块,避免这种“测试跑的是旧代码”的假绿。

测试金字塔的配比怎么定

“金字塔”常被说成 70% 单元、20% 集成、10% 端到端。这个比例不是标准答案。libraryd 的实际约束是:

  • 领域规则复杂,但纯函数多,适合大量快速单元测试。
  • SQL 和 schema 是主要风险来源,持久化测试不能省。
  • HTTP 契约字段多,Web 测试用内存仓储就能覆盖大部分分支,成本低。
  • 端到端测试启动完整应用和容器,成本最高,只保留主链路和装配断言。

所以配比应该由“哪一层能最早发现这类缺陷”决定,而不是由数字决定。一个实用判断方法是:

缺陷类型最早能发现的层不应该放在
罚金算错一天领域单元端到端
唯一索引没生效持久化集成Web
校验错误没进 errorsWeb 集成端到端
环境变量没传进连接池端到端领域
Jackson 日期格式变了Web 集成持久化

把缺陷放在它能被最早发现的那一层,测试套件才会既快又准。

常见错误与边界

错误一:用 H2 替代 PostgreSQL 跑持久化测试。 libraryd 的 V2__book_search_index.sql 第一行就是 create extension if not exists pg_trgm,H2 没有这个扩展。即使把 V2 跳过,ux_loans_active_isbn 这种部分唯一索引、on conflict、timestamptz 的时区语义也都和 PostgreSQL 不同。持久化测试必须用真实 PostgreSQL,这不是“追求真实”,而是 schema 本身就依赖 PostgreSQL 特性。

错误二:所有测试共用一个数据库状态。 PostgresRepositoriesTest 在 @BeforeEach 里 truncate,不是因为不信任测试,而是因为 JUnit 不保证方法执行顺序。一个测试留下 isbn-1,另一个测试再插入同一个 ISBN,就会得到 DUPLICATE_ISBN,而失败信息看起来像业务 bug。

错误三:在 Web 测试里启动真实数据库。 LibraryApiTest 用 InMemoryStore 和 () -> true 的健康探针,启动一次只要几十毫秒。如果每个 Web 测试都起容器,7 个测试会从 1.3 秒变成几十秒,而且失败原因会混入 Docker 和迁移问题。

错误四:端到端测试用 Thread.sleep 等后台任务。 OverdueScanner 每 60 秒扫一次,端到端测试不应该等它。扫描器的逻辑由 OverdueScanner 自己的单元测试覆盖;端到端只验证 /health 和主链路。等待固定时间会让 CI 在慢机器上随机失败。

错误五:断言实现细节而不是行为。 比如断言 JdbcBookRepository 内部调用了哪条 SQL、LibraryService 先查书还是先查会员。这些顺序会随着优化改变,但“重复 ISBN 返回 DUPLICATE_ISBN”“借阅上限是 5 本”不会变。测试应该锁住行为契约,而不是锁住当前实现。

边界:容器的时区。 PostgreSQL 容器默认 UTC,LocalDate.now() 用的是 JVM 时区。测试里用 Clock.fixed(...) 固定“今天”,不要依赖运行机器的时区。EndToEndTest 用 AppConfig 启动完整应用,应用内部用 Clock.systemDefaultZone(),所以它断言的是“借阅成功”和“状态为 ACTIVE”,不硬编码具体日期。

边界:测试数量不是覆盖率。 33 个测试能覆盖主要路径,但不等于每一行都被执行。覆盖率由 JaCoCo 报告回答,测试金字塔回答的是“失败时能不能快速定位”。两者不能互相替代。

小结

  • libraryd 的测试分四层:领域单元、持久化集成、Web 集成、端到端;每层依赖不同,失败含义也不同。
  • 领域测试用 InMemoryRepositories 和 TransactionManager.direct(),不碰数据库。
  • 持久化测试用真实 PostgreSQL 容器,验证 SQL、约束、事务和 Flyway;H2 不能替代。
  • Web 测试用 InMemoryStore 和 Javalin 随机端口,验证路由、DTO、校验和错误映射。
  • 端到端测试只验证装配和主链路,占 33 个测试中的 2 个,不重复其他层的断言。
  • 开发时跑单模块测试,提交前跑 mvn verify,CI 跑完整流程。

测试分层解决的是“在哪一层验证”,但持久化层和端到端层都需要一个真实 PostgreSQL。手工 docker run 会让开发机和 CI 的数据库版本、初始化脚本、端口逐渐漂移。下一节用 Testcontainers 把数据库变成测试代码的一部分。

阅读导航:上一节:14.3 分布式缓存:Redis 与一致性 · 下一节:15.2 Testcontainers:真实 PostgreSQL 集成测试 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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