本节目标:让
libraryd在一个真实进程里完整启动,用 HTTP 验证装配和主链路,并让 CI 每次提交都重复这件事。
适用版本:JDK 25(Temurin 25.0.5)、JUnit Jupiter 5.14.4、Testcontainers 1.21.4、Maven Failsafe 3.6.1、GitHub Actions。
承接的问题
15.1 节把测试分成四层,15.2 节把 PostgreSQL 装进了持久化测试。到现在,下面这些测试都能单独通过:
LibraryServiceTest证明罚金和续借规则正确。PostgresRepositoriesTest证明 SQL、约束和事务正确。LibraryApiTest证明路由、DTO 和错误映射正确。
但它们都是“模块内正确”。把它们拼起来,仍然可能在启动阶段失败:
AppConfig把数据库 URL 读成了环境变量名,连接池连到了错误的库。DatabaseMigrator没有在 HTTP 端口打开前执行,第一次请求打到空表。LibraryApplication.start里某个组件初始化顺序错了,/health在启动后立刻返回 DOWN。application.close()没有停掉OverdueScanner,进程退出要等 60 秒。- 容器里的环境变量和本地
.env不一致,镜像能启动但连不上数据库。
这些问题不会出现在任何单模块测试里,因为单模块测试根本没有启动 AppMain。端到端测试要补的就是这一段。
核心机制一:用随机端口启动完整应用
EndToEndTest 不直接调用 AppMain.main,而是调用 LibraryApplication.start,这样测试进程不会被 System.exit 影响,也能拿到启动后的端口:
@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();
}
四个参数值得逐个看:
httpPort传 0。Javalin 会让操作系统分配一个空闲端口,避免和开发机上的 8080 冲突。application.port()返回实际端口。poolSize传 3。端到端测试只有两个方法,串行执行,3 个连接足够;传 10 会浪费 PostgreSQL 连接。logLevel传WARN。端到端测试会输出大量 HTTP 和 OpenTelemetry 日志,调到 WARN 能让失败信息更显眼。- Redis URL 固定为
redis://127.0.0.1:1。这个端口没有服务,RedisBookCache.connect会在 300 毫秒超时后降级到本地缓存,顺带验证“Redis 不可用时服务仍能启动”。
LibraryApplication.start 内部按顺序做这些事:
DataSourceFactory.create建 HikariCP 连接池。DatabaseMigrator.migrate执行 Flyway。- 创建三个 JDBC 仓储和事务管理器。
- 创建
Telemetry、RedisBookCache、CachingBookRepository。 - 创建
LibraryService和OverdueScanner。 - 创建
LibraryApi,启动 Javalin,启动扫描器。
端到端测试不关心每一步的实现,只关心“走完这些步骤后,端口可用、健康检查是 UP、主链路能跑通”。
核心机制二:HTTP 客户端与响应断言
测试用 JDK 自带的 HttpClient,不引入 REST Assured 或 OkHttp:
private HttpResponse<String> send(String method, String path, String body)
throws IOException, InterruptedException {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path));
if (body == null) {
builder.method(method, HttpRequest.BodyPublishers.noBody());
} else {
builder.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(body));
}
return client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
这个 helper 把“有没有请求体”和“Content-Type”收在一处。端到端测试里最容易犯的错是忘记 Content-Type: application/json,然后拿到 400 INVALID_COMMAND,却以为是业务校验失败。统一走 send 就不会漏。
主链路测试按真实用户顺序发请求:
HttpResponse<String> created = send("POST", "/api/books", """
{"isbn":"978-0-13-468599-1","title":"Effective Java","author":"Joshua Bloch","year":2018}
""");
assertEquals(201, created.statusCode(), created.body());
assertEquals("/api/books/978-0-13-468599-1",
created.headers().firstValue("Location").orElseThrow());
HttpResponse<String> borrowed = send("POST", "/api/loans", """
{"isbn":"978-0-13-468599-1","memberId":"member-42"}
""");
assertEquals(201, borrowed.statusCode(), borrowed.body());
long loanId = JSON.readTree(borrowed.body()).get("id").asLong();
HttpResponse<String> renewed = send("POST", "/api/renews",
"{\"isbn\":\"978-0-13-468599-1\"}");
assertEquals(200, renewed.statusCode(), renewed.body());
assertEquals(1, JSON.readTree(renewed.body()).get("renewCount").asInt());
HttpResponse<String> returned = send("POST", "/api/returns",
"{\"isbn\":\"978-0-13-468599-1\"}");
assertEquals(200, returned.statusCode(), returned.body());
assertEquals(0, JSON.readTree(returned.body()).get("fineCents").asLong());
每一步都同时断言状态码和关键字段。只断言 201 不够:响应可能是 201,但 Location 指向错误的 URL,或者 loanId 是 0。端到端测试的价值在于“真实链路”,如果断言太弱,它只是在重复 Web 测试的启动工作。
测试还会验证几个边界:
- 同一本书第二次借阅返回 409
E_BOOK_ALREADY_BORROWED。 - 有借阅历史的图书删除返回 409。
- 缺失的图书借阅返回 404。
- 校验失败返回 400,
errors里有title。 - 临时图书可以创建并删除,删除返回 204。
/health返回 200,checks.db是UP。/metrics返回 Prometheus 文本,包含libraryd_http_server_requests_seconds和jvm_memory_used_bytes。
这些断言覆盖了“HTTP 层、领域层、持久化层、可观测性层”的接缝,而不是某一层的细节。
核心机制三:端到端测试的边界
端到端测试最容易被写成“所有功能的第二套测试”。libraryd 的 EndToEndTest 只有两个方法,边界很清楚:
| 应该放在端到端 | 不应该放在端到端 |
|---|---|
| 应用能否启动 | 每个 DTO 字段的序列化 |
| Flyway 是否在启动时执行 | 每个校验注解的边界值 |
| 主链路是否贯通 | 每个领域规则的组合 |
/health 是否检查数据库 | 每个错误码的映射 |
X-Request-Id 是否贯穿 | 每个分页参数的归一化 |
| 关闭是否释放资源 | 每个 SQL 的索引命中 |
第二个方法专门验证请求 ID:
@Test
void requestIdIsPropagatedThroughTheStack() throws Exception {
HttpResponse<String> response = client.send(HttpRequest.newBuilder(
URI.create(baseUrl + "/api/books"))
.header("X-Request-Id", "it-request-id")
.GET()
.build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertEquals("it-request-id",
response.headers().firstValue("X-Request-Id").orElseThrow());
}
这个断言看起来很小,但它验证的是 RequestContext.begin、TracingMiddleware、HttpMetrics、Logback MDC 和响应头之间的协作。任何一处把请求 ID 丢了,客户端日志和服务端日志就无法关联。这种跨组件问题,单模块测试很难覆盖。
在 libraryd 里落地:一次 verify 跑完 33 个测试
libraryd-integration-tests 是 Maven reactor 的最后一个模块,依赖 libraryd-app。执行 mvn verify 时,Maven 按顺序构建五个模块,最后运行端到端测试:
mvn verify
本机(Apple Silicon、Colima、Docker 29.5.2、Maven 依赖已缓存)真实输出摘录:
[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 -- in PostgresRepositoriesTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in LibraryApiTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in EndToEndTest
[INFO] BUILD SUCCESS
[INFO] Total time: 18.867 s
端到端模块自己的耗时是 3.552 秒,其中大部分花在启动 PostgreSQL 容器、执行两次迁移和启动 Javalin。相比 33 个测试的总量,这个成本可接受,因为它验证的是其他 31 个测试都不负责的装配问题。
mvn verify 还会执行 package 阶段,包括 Shade 插件生成 fat jar。所以 CI 通过不仅意味着测试通过,也意味着可执行 jar 能生成。镜像构建和运行验证放在第 16 章。
在 CI 里跑起来
端到端测试依赖 Docker,CI 必须提供 Docker daemon。GitHub Actions 的 ubuntu-latest runner 自带 Docker,可以直接跑 Testcontainers。一个够用的工作流如下:
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Show Docker
run: docker version
- name: Verify
run: mvn -B -ntp verify
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: surefire-reports
path: '**/target/surefire-reports/**'
几个选择的原因:
setup-java的cache: maven会缓存~/.m2/repository,键通常包含**/pom.xml的哈希。依赖没变时,构建能从几分钟降到几十秒。mvn -B -ntp关闭交互和下载进度,CI 日志更干净。-B也让 Maven 在需要输入时直接失败,而不是挂住。timeout-minutes: 20防止 Docker 拉镜像卡住时 job 一直占用 runner。正常冷启动远小于这个值。if: always()让测试失败时也上传报告。否则最需要报告的时候,步骤被跳过。- 不设置
TESTCONTAINERS_RYUK_DISABLED。GitHub runner 是一次性的,Ryuk 能正常清理容器;关掉它反而可能留下资源。
如果 CI 跑在自托管 runner 上,要确认三件事:Docker daemon 已启动、当前用户能访问 Docker socket、runner 能拉取 postgres:17-alpine。很多“Testcontainers 在 CI 上找不到 Docker”的问题,根因是 runner 用了 Podman 或 rootless Docker,而 Testcontainers 没有配置对应的 socket。
Surefire 与 Failsafe 的分工
Maven 有两个测试插件,名字很像,职责不同:
| 插件 | 默认匹配 | 典型阶段 | 用途 |
|---|---|---|---|
| Surefire | *Test、Test*、*Tests | test | 单元测试、模块内集成测试 |
| Failsafe | *IT、IT*、*ITCase | integration-test + verify | 端到端测试、需要外部依赖的测试 |
libraryd 当前的端到端类叫 EndToEndTest,放在独立的 libraryd-integration-tests 模块里,由 Surefire 在 test 阶段执行。这是有意为之:该模块在 reactor 最后,前面的模块都已经构建好,测试自然发生在“所有代码就绪”之后。这样不需要额外的 Failsafe 配置,mvn test 也能跑端到端。
如果团队坚持用 *IT 命名,就要在 libraryd-integration-tests/pom.xml 里绑定 Failsafe:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
父 POM 的 pluginManagement 已经把 Failsafe 版本固定为 3.6.1,并设置 useModulePath=false,子模块只需要声明执行目标。两种方式没有绝对优劣:独立模块 + Surefire 更简单,*IT + Failsafe 的命名更符合 Maven 惯例。关键是不要同一套测试既被 Surefire 跑又被 Failsafe 跑,否则容器会启动两次。
常见错误与边界
错误一:端到端测试用固定端口 8080。 开发机上已经有一个 libraryd 在跑,测试启动时抛 BindException: Address already in use。始终用 start("127.0.0.1", 0) 加 application.port(),不要为了“方便 curl”写死端口。
错误二:忘记关闭应用。 @AfterAll 不调用 application.close(),Javalin 的线程池和 OverdueScanner 的调度线程会继续运行,Surefire fork 的 JVM 可能不退出,CI job 最后超时。close() 还要保证幂等,LibraryApplication 用 AtomicBoolean closed 做了保护。
错误三:两个端到端测试共享数据。 两个方法共用一个数据库,如果第一个方法创建了 isbn-1 且没有删除,第二个方法再用 isbn-1 就会得到 409。要么每个方法用不同的 ISBN,要么在 @BeforeEach 里清理。EndToEndTest 的主链路测试最后删除了临时图书,但没有清空所有数据,所以第二个方法只读 /api/books,不依赖具体行数。
错误四:在 CI 上依赖本地 .env。 AppConfig.fromEnvironment() 只读环境变量,不读 .env 文件。CI 里没有 LIBRARYD_DB_URL 时,端到端测试用的是 AppConfig 构造参数,不受影响;但如果某个测试直接启动 AppMain,就会连默认的 localhost:5432,在 CI 上必然失败。端到端测试应该显式构造配置,不依赖全局环境。
错误五:断言具体日期。 EndToEndTest 用 Clock.systemDefaultZone() 启动应用,借阅日期是“今天”。如果断言 dueOn 等于某个硬编码日期,测试会在第二天失败。只断言状态、相对关系和关键字段,不要把运行日期写进期望值。
边界:CI 的 Docker 版本。 不同 runner 的 Docker 版本可能不同,Testcontainers 1.21.4 支持 Docker 29.x,但很旧的 Docker 20.x 可能缺少某些 API。CI 里打印 docker version,把版本写进构建日志,排查时不用猜。
边界:日志量。 端到端测试会触发 OpenTelemetry 的 LoggingSpanExporter,每个请求输出一行 span。测试通过时这些日志只是噪音,失败时却可能淹没断言信息。AppConfig 的 logLevel 设为 WARN 能压住应用日志,但 OTel 的 JUL 日志不受 Logback 控制,需要在测试资源里单独配置。不要把“日志太多”当成测试失败的原因。
小结
- 端到端测试启动完整的
LibraryApplication,验证的是装配,不是重复单元测试的断言。 httpPort=0让操作系统分配端口,application.port()拿到实际值,避免端口冲突。HttpClient加统一的sendhelper 足够写端到端测试,不需要额外 HTTP 测试库。- 只保留主链路和请求 ID 两个测试,其他细节留给领域、持久化和 Web 层。
mvn verify在本机跑完 33 个测试约 18.9 秒,其中端到端约 3.6 秒。- CI 需要 Docker;GitHub Actions 的
ubuntu-latest自带,自托管 runner 要自己确认 socket 和镜像权限。 - 独立模块 + Surefire 与
*IT+ Failsafe 都能工作,关键是不要让同一套测试跑两遍。
测试和 CI 解决了“代码对不对”,但交付还没有结束:CI 产出的 jar 能不能独立运行、镜像有多大、启动要多久、配置错了会怎样。下一节从 mvn package 的产物开始,拆开 fat jar、jlink 运行时镜像和启动成本。
阅读导航:上一节:15.2 Testcontainers:真实 PostgreSQL 集成测试 · 下一节:16.1 打包:fat jar、jlink 与运行时镜像 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。