本节目标:给
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 集成 | LibraryApiTest | Javalin + 内存仓储 | 路由、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-domain | LibraryServiceTest | 10 | 无 |
libraryd-domain | LoanTest | 5 | 无 |
libraryd-persistence | PostgresRepositoriesTest | 9 | Testcontainers PostgreSQL |
libraryd-web | LibraryApiTest | 7 | Javalin、内存仓储 |
libraryd-integration-tests | EndToEndTest | 2 | 完整应用、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 |
校验错误没进 errors | Web 集成 | 端到端 |
| 环境变量没传进连接池 | 端到端 | 领域 |
| 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 集成测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。