《Java 编程实战》15.3 端到端与 CI:把服务跑起来验证

单元、持久化和 Web 测试都绿,并不代表 libraryd 能启动。本节用随机端口启动完整 LibraryApplication,走一遍借阅、续借、归还、健康检查和指标,并把 mvn verify 接进 GitHub Actions,给出 33 个测试的真实耗时。

本节目标:让 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 内部按顺序做这些事:

  1. DataSourceFactory.create 建 HikariCP 连接池。
  2. DatabaseMigrator.migrate 执行 Flyway。
  3. 创建三个 JDBC 仓储和事务管理器。
  4. 创建 Telemetry、RedisBookCache、CachingBookRepository。
  5. 创建 LibraryService 和 OverdueScanner。
  6. 创建 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*、*Teststest单元测试、模块内集成测试
Failsafe*IT、IT*、*ITCaseintegration-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 加统一的 send helper 足够写端到端测试,不需要额外 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 与运行时镜像 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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