Ohhnews

分类导航

$ cd ..
foojay原文

Testcontainers集成测试:在Docker中运行真实WAR包

#testcontainers#集成测试#docker#jakarta ee#war部署

借阅表单上一个空的 librarianID 字段没有报错,却被静默地持久化为空字符串而不是 null。这破坏了另一个页面上某个下拉框的“这已经保存过了吗?”检查。这个缺陷完全存在于 JSF 的字符串到属性绑定与 @PrePersist 回调之间的交互中——这意味着无论做多少带 mock 的 EntityManager 单元测试,都永远找不到它。直到真实浏览器向已部署应用提交真实表单,它才存在。

这就是测试套件中间层存在所要捕获的那类缺陷,而 Testcontainers 正是实现这一目标的途径。读完本文,你将了解如何在 JUnit 测试中,把实际构建出的 WAR 运行在真实的 Azul Payara Micro 实例上,以及如何避免两个会让这类测试变得不稳定的错误。

工作原理

Testcontainers 是一个 Java 库,它在测试自身的生命周期内启动临时 Docker 容器,并在运行结束时将其拆除——可以是一个数据库、一个消息代理,或者在这个项目中,是一整个应用服务器。测试通过真实网络端口与容器通信,就像用户或负载均衡器那样。

你会得到一个 GenericContainer,或者针对 PostgreSQL、Kafka 等常见技术专门构建的子类,它封装了一个 Docker 镜像。你配置它——镜像名称、暴露端口、要复制进去的文件,以及一个告诉 Testcontainers 如何判断容器真正就绪的等待策略——然后调用 start()。Testcontainers 与本地 Docker 守护进程通信,必要时拉取镜像,启动容器,等待就绪条件,并返回连接所需的主机和映射端口。一个名为 Ryuk 的边车容器会与你的构建一起运行,即使 JVM 在测试中途死亡也能回收容器,因此孤儿容器不会在 CI 运行器上堆积。(Ryuk 可以被禁用,在一些受限的 CI 配置中确实会被禁用——在你想当然地认为清理会自动完成之前,这一点值得了解。)

该库通过 org.testcontainers:junit-jupiter 模块接入 JUnit 5,该模块提供 @Testcontainers 和 @Container。当这些注解不适合你的测试拓扑时,你也可以手动管理容器生命周期——例如在静态初始化器中启动一次。这个项目正是这样做的,原因见下文。

你能获得什么

核心优势是部署保真度:你测试的是将要交付的东西,而不是它的替身。一个运行真实 payara/micro 镜像、内部署了 Maven 构建出的真实 WAR 的容器,会暴露打包问题、缺失依赖以及任何 mock 都无法呈现的运行时特定行为。

紧随其后的优势是可复现性。同一个容器定义在笔记本电脑和 CI 运行器上运行结果完全相同,因为两者使用的是同一个镜像。不会因为一个环境本地安装的数据库版本与另一个环境不同而出现“在我机器上能跑”的差距。

它也并非绑定于 Jakarta EE,甚至并非绑定于 Java Web 服务器。相同的模式——在 Docker 中启动真实依赖、让测试指向它、再将其拆除——适用于 PostgreSQL 实例、Kafka broker、Redis 缓存或应用服务器。这是一项通用的集成测试技能,可以跨项目和跨技术栈迁移。

而且,由于一切都通过 Docker 的网络和文件复制原语运行,而不是通过框架特定的部署描述符,心智模型保持得格外容易理解。“启动一个容器,通过网络访问它”是大多数 Java 开发者在使用其他地方用到 Testcontainers 时就已经理解的事情,即使他们从未见过这个特定项目。

你需要付出什么代价

最直接的成本是对 Docker 的依赖。如果 CI 环境或贡献者的机器无法运行容器运行时,这些测试就无法在那里运行。值得尽早检查,而不是在发布期间才发现。这个限制已经比过去宽松——Testcontainers 除了 Docker Desktop,也能与 Podman、Colima 和其他无根运行时配合使用;对于完全无法本地运行容器的环境,还有 Testcontainers Cloud——但它仍然是一个硬依赖,而 @EnabledIfDockerAvailable 是在缺失时优雅跳过而不是失败的方式。

接下来是速度。在容器内启动真实应用服务器以秒计,而不是基于 WeldInitiator 的单元测试所需的毫秒级。这正是 Testcontainers 属于集成层和端到端层的原因——在这些层中,较慢的反馈回路是可以接受的代价——而不是开发者每次保存时都运行的那一层。

这些测试本质上也比纯单元测试更容易不稳定,仅仅因为涉及更多活动部件:缓慢的镜像拉取、容器就绪时间比等待策略预期更长、端口需要一点时间才能绑定。良好的等待策略和有界重试可以很好地管理这一点,但因与被测功能无关的原因而失败的风险永远不会降到零。

最后,Testcontainers 只能把你带到容器边界。它证明已部署应用能够从外部正确响应真实 HTTP 请求和真实浏览器交互;它没有提供钩子,让你像 Arquillian 这样的容器内框架那样直接检查由容器管理的状态。对于大多数功能测试和回归测试,外部视角正是你想要的——但这是一个真实的限制,也是这个项目在 Testcontainers 路径之外保留 Arquillian 路径的原因。那是本系列下一篇文章的主题。

它支持的测试类型

Testcontainers 让你无需维护共享测试环境就能获得集成测试和端到端测试。在实践中,这涵盖三种模式:

  • 全栈集成测试:通过真实 HTTP 针对已部署应用测试 REST API,而不是在进程内直接调用资源方法。
  • 浏览器驱动的端到端测试:无头浏览器针对容器驱动实际渲染的 UI——捕获完全存在于视图层与服务器之间交互中的缺陷。
  • 针对真实部署所需任何外部依赖的集成测试——应用服务器本身,或它通信的数据库、队列、缓存——无需一个需要有人持续打补丁并保持同步的共享预发布环境。

在 testcontainers-example 中的用法

该项目的集成测试层运行一个真实的、Docker 化的 Azul Payara Micro 实例。依赖项以标准 test 作用域添加到 pom.xml:

[LOADING...]

容器定义位于 PayaraMicroContainer 中,这是一个 GenericContainer 子类,配置为拉取项目所构建版本对应的 payara/micro 镜像,复制刚由构建生成的 WAR,并等待 Payara 自己输出的“ready”日志行后才认为自己已启动:

[LOADING...]

注意 requiredProperty。payara.version 和 war.path 这两个系统属性并非硬编码;如果缺失,容器会以有用的消息明确失败——如果有人从 IDE 而不是通过 Maven 运行测试,就会出现这种情况。构建过程通过 Failsafe 插件的 systemPropertyVariables 提供它们:

[LOADING...]

war.path 指向 Maven 在 package 阶段生成的 WAR,因此容器始终部署本次构建刚生成的产物,而不是过期的产物。

整个运行共用一个容器:单例模式

容器管理位于 AbstractContainerIT 中,这是项目中每个集成测试共享的基类:

[LOADING...]

在静态初始化器中启动容器,而不是通过 JUnit 5 的 @Testcontainers/@Container 生命周期来启动,这是有意为之。这是文档中记载的单例容器模式,而这里需要它的原因很具体:JUnit 扩展会以按类执行的 beforeAll/afterAll 管理一个静态 @Container 字段。如果把该字段放在多个测试类共享的基类中,第一个结束的类会在其 afterAll 中停止容器,导致之后运行的每个类都指向一个已死的容器。静态初始化器完全绕开该扩展——一个 Azul Payara Micro 实例在整个运行期间只启动一次,所有集成测试类共享它。清理工作交给 Ryuk 和 JVM 关闭,而不是交给 JUnit。

这个权衡值得直说:共享容器意味着共享状态。测试之间不会重置任何东西,因此测试必须写成彼此不冲突。后文会进一步说明。

针对同一个容器的两类测试

AbstractServiceIT 继承 AbstractContainerIT,并在其周围设置一个 Jakarta REST Client,因此像 BookServiceIT 这样的类可以直接访问已部署的 API:

[LOADING...]

这是通过 HTTP 向 Jakarta REST 资源发出的真实 POST 请求,并通过运行时自带的数据源进行持久化。这里有必要精确说明“数据库”是什么:应用使用 Azul Payara Micro 内置的嵌入式 H2 数据源 jdbc/__default,它运行在同一个容器内。没有单独的数据库容器。对于测试持久化层来说这没问题,但它并不是生产环境运行的数据库——而弥合最后这一点差距只需要改一行,因为添加一个 PostgreSQLContainer 并让部署指向它,正是 Testcontainers 擅长的场景。

启动竞态,以及如何避免把它泄漏到测试中

围绕该 POST 的重试循环值得完整解释,因为这是以这种方式测试已部署应用时最重要的一点。

PayaraMicroContainer 等待 Payara Micro ... ready 日志行,start() 在 Testcontainers 看到它后立即返回。但这一行只意味着服务器已启动并监听 8080。它并不说明复制到 /opt/payara/deployments 的 WAR 是否已完成部署并注册其 Jakarta REST 资源。存在一个短暂窗口:按等待策略的定义容器已经“就绪”,TCP 连接成功,而 Payara 却返回 404,因为 resources/books 尚不存在。

如果你对第一个响应做断言,这个窗口中的 404 与真正的路由错误无法区分——因此测试会重试最多 20 秒,并且只把非 404 状态视为真正的答案。在目前这个项目中,这种容忍逻辑出现在不止一个测试类里,这表明它应该属于别处。

[LOADING...]

更干净的修复方式是把这种容忍逻辑移入容器定义:在日志消息等待策略之后,再对某个应用端点链式添加一个 HTTP 等待策略,这样 start() 在部署本身响应之前不会返回。

[LOADING...]

显式设置总启动超时——WaitAllStrategy 会在其所有策略之间应用同一个预算,而缓慢的首次镜像拉取会消耗掉它。这种方法的代价是让容器定义与一个已知应用路径耦合;好处是启动竞态在一个地方处理,而不是在每个新测试类中重新发现。无论采用哪种方式,基本观点都成立:使用 Testcontainers 时,“容器已启动”和“应用正在提供服务”是两个不同的事件,把它们混为一谈的测试会在启动时变得不稳定。

驱动浏览器访问同一个容器

AbstractUiIT 继承同一个 AbstractContainerIT,但通过 Playwright 驱动无头 Chromium 浏览器访问容器的 JSF 页面,而不是调用 REST API:

[LOADING...]

这段代码片段中有一点需要注意:这里的 assertThat 是 PlaywrightAssertions.assertThat,而不是 JUnit 或 AssertJ 的。Playwright 的版本会重试断言直到通过或超时,这正是让 UI 断言在面对异步渲染时保持稳定的原因——而如果静态导入与另一个 assertThat 冲突,你会失去这种重试行为,却没有任何编译错误提醒你。

正是这一层发现了本文开头提到的 librarianID 缺陷。

由于容器的 Payara 实例及其 H2 数据库在整个运行期间都存活,而不是在测试之间重置,AbstractUiIT 提供了一个 unique(prefix) 辅助方法,为测试数据加上随机 id 后缀,这样测试就能与其他测试已经写入的内容保持独立,而无需每次都使用全新容器。此外,一个基于 TestWatcher 的 FailureDiagnostics 扩展会在任何失败时把截图、页面 HTML、页面 URL 和容器日志转储到 target/playwright-failures/——因此失败的 UI 测试可以直接从 CI 输出进行调试,无需在一台恰好同时安装了 Docker 和浏览器的笔记本上交互式复现。

即使你不从该项目带走任何其他东西,最后一个细节也值得照搬。对浏览器级测试的主要实际反对意见是:当它们在别人的机器上失败时,调试起来非常痛苦。失败时自动写入的截图和服务器日志,消除了大部分这种反对意见。

适用位置

Testcontainers 让你以受控且可移植的方式,在功能预期的环境中测试功能。无需维护服务器:容器从固定镜像启动,每次都建立相同的环境,并针对构建刚生成的 WAR 运行测试。代价是对容器运行时的硬依赖,以及以秒而非毫秒计的启动时间——这正是它属于集成层和端到端层,位于本系列第一部分介绍的快速 CDI 单元测试之上的原因。

本系列第一部分介绍了那个快速的 CDI 单元层,第三部分并排比较了所有三种工具——WeldInitiator、Arquillian 和 Testcontainers——包括 Testcontainers 从容器外部无法触及的情况。

如果你的 Jakarta EE 应用运行在 Azul Payara Micro 或 Azul Payara Server 上,那么这一层值得针对你实际交付的运行时来构建,而不是针对一个相似替代品。

如果你要把一句话带进下一次测试策略讨论,请带上这句:针对 mock 的测试通过,证明你的代码与你的假设一致;针对真实容器的测试通过,证明你的代码与生产环境一致。## 常见问题

Testcontainers 在 Java 测试中用于什么?

Testcontainers 是一个 Java 库,它在测试自身的生命周期内启动一次性 Docker 容器,并在之后销毁,因此集成测试针对真实依赖运行,而不是使用模拟对象——例如数据库、消息代理或完整应用服务器。对于 Jakarta EE 工作,它可以在真实运行时(如 Azul Payara Micro)上运行由 Maven 构建的实际 WAR,并通过真实网络端口暴露,从而发现进程内测试无法看到的打包和部署问题。

如何针对真实应用服务器运行集成测试?

将服务器的 Docker 镜像包装在 Testcontainers 的 GenericContainer 中,将构建好的 WAR 复制到镜像的部署目录,声明用于就绪判断的等待策略,然后调用 start()。随后测试会获得映射的主机和端口,并完全像客户端一样通过 HTTP 驱动应用。对于 Azul Payara Micro,这意味着拉取固定版本的 payara/micro 镜像,并将 WAR 复制到 /opt/payara/deployments,版本和 WAR 路径由 Maven 构建提供,而不是硬编码。

为什么 Testcontainers 测试在容器启动后立即返回 404?

因为“容器已就绪”和“应用已部署并正在提供服务”是两个不同事件。监听服务器启动日志行的等待策略会在服务器开始监听时立即返回,而这可能比 WAR 完成部署并注册其端点早几秒——该时间窗口内的请求会得到 404,看起来与路由错误完全相同。修复方法是在已知应用端点上串联一个 HTTP 等待策略,使 start() 直到部署真正能够响应后才返回,而不是在各个测试中分散重试循环。

什么是单例容器模式,何时需要它?

它是指在静态初始化器中启动一个容器,并在本次运行的所有测试类之间共享,而不是让 JUnit 5 的 @Testcontainers 扩展按类管理容器。只要静态容器字段位于共享基类中,就需要这样做:该扩展按类执行的 afterAll 会在第一个测试类完成时停止容器,导致后续测试类指向一个已停止的容器。代价是测试之间不会重置任何内容,因此必须让测试数据保持唯一,而不能假定它是干净的。

如果已有 Testcontainers 集成测试,还需要单元测试吗?

需要。集成测试需要数秒钟,因为它们要在 Docker 中启动真实运行时,所以无法在每次保存时运行,而且它们只能从容器外部观察应用。快速的 CDI 单元测试——在测试 JVM 中使用真实的 Weld SE 容器,耗时毫秒级——可以回答 Bean 的装配和业务逻辑是否正确,应属于每次构建。发布到 Azul Payara Micro 或 Azul Payara Server 的团队通常运行全部三层:每次保存时运行 CDI 单元测试,在 CI 中运行 Testcontainers 集成测试和浏览器测试,以及在必须直接检查内部状态时运行容器内测试。