Ohhnews

分类导航

$ cd ..
foojay原文

Jakarta EE测试工具对比:WeldInitiator、Arquillian与Testcontainers

#jakarta ee#测试工具#weldinitiator#arquillian#testcontainers

本系列中的示例项目在其领域类中声明了十四个 @NamedQuery 注解。直到最近,它们中没有任何一个被任何东西调用——没有 REST 资源,没有 JSF 支撑 bean,也没有测试。从来没有任何东西确认过它们的 JPQL 能编译,更不用说返回正确的行。共享服务基类上的 count() 和 findRange() 也是如此。

这个缺口并不是因为有人粗心才存在。它存在,是因为项目有单元测试,也有部署后的端到端测试,而这段特定代码两者都触及不到。填补它需要第三种工具。本文在同一个代码库上比较三者——WeldInitiator、Arquillian 和 Testcontainers——这样你就能分辨每一个回答的是哪个问题,以及会留下哪些缺口。

测试 Jakarta EE 应用之所以棘手,有一个具体原因:你关心的行为往往要等容器把它组装起来之后才存在。把所有东西都 mock 掉,你会得到能通过但几乎证明不了什么的测试。把所有东西都部署起来,你会得到缓慢、复杂、开发期间没人运行的场景。这里的三个工具与代码保持三种不同的距离,而有用的问题不是哪个最好,而是某个给定测试需要哪种距离。

示例是 testcontainers-example,一个用 Payara Starter 生成、运行在 Azul Payara Micro 上的小型图书馆管理系统。它的测试以避免冗余为目标编写,因此每个工具覆盖其他工具覆盖不了的部分。

三个工具,三个问题

WeldInitiator 是单元测试工具:这个 bean 自身的逻辑和 CDI 装配,在没有容器、没有外部依赖的隔离环境中能工作吗?Arquillian 是容器内集成测试工具:这段代码在真实容器的 JVM 内执行、面对实际的持久化提供者时,行为正确吗?Testcontainers 覆盖集成测试和端到端测试:完整构建并部署后的应用,从外部看能正常工作吗——无论这意味着调用它的 REST API,还是在真实浏览器中驱动它的 UI?

这是《Testing Jakarta EE: Three Layers, Three Tools》的最后一篇。第一部分介绍 WeldInitiator 和 CDI 单元层;第二部分介绍 Testcontainers 和部署层。本文把三者放在一起比较。

WeldInitiator:CDI 装配和业务逻辑在隔离环境中能工作吗?

来自 weld-junit5 模块的 WeldInitiator 会在测试 JVM 内启动一个 Weld SE 容器。你指定要带入容器的 bean,Weld 解析并注入它们的 CDI 依赖,整个周期在毫秒级完成。

项目中的每个 *ServiceTest 类——BookServiceTest、PatronServiceTest、LibrarianServiceTest、LoanServiceTest——都是这样构建的:

[LOADING...]

优点。 这是速度遥遥领先的最快一层:无需启动容器,无需网络,无需 Docker。因为 weld.select(BookService.class).get() 返回的是容器管理的实例,测试会像生产环境那样捕获装配回归——一个损坏的 @Inject 注入点、一个被改变的作用域——而不像手工实例化的 new BookService()。把 WeldInitiator.from(...) 限定到被测 bean,可以让失败范围保持狭窄、易于诊断。

局限。 它证明 CDI 装配和业务逻辑是正确的,仅此而已。Weld SE 容器不是 Jakarta EE 容器:它不会注入 @PersistenceContext,也不会应用容器的 @Transactional 拦截器,因此被 mock 的 EntityManager 使 JPA 映射、生成的 SQL、真实事务行为和命名查询完全未经验证。项目自己的 Arquillian 测试在其类注释中就是这样说的:AbstractService 的 count()、findRange() 或命名查询方法,没有任何一个被任何 *ServiceTest 执行过,因为 mock 背后没有真实的持久化提供者来运行它们。

通过 @PersistenceContext 进行的字段注入也没有为 Mockito 留下干净的接缝。AbstractService 上的 em 字段是私有的,没有 setter,也没有构造器参数,因此不存在一个公开入口能让测试把 mock 递进去——而这正是 @InjectMocks 或构造器注入通常会使用的方式。缺少这个接缝,测试就绕过封装:Weld 构建 bean 之后,测试通过反射查找私有字段并强行注入 mock。这样可行,但它绕过了类的 API,而不是使用 API。在协作对象可以作为 CDI bean 产生的情况下,用 .addBeans(MockBean.of(...)) 注册 mock bean 是更干净的替代方案。

最适合: 对 CDI 管理的 bean 逻辑进行快速、TDD 友好的验证,以及针对依赖注入本身的回归测试。

Arquillian:这一片容器管理的行为,从容器自己的 JVM 内部看能工作吗?

Arquillian 反转了另外两个工具的做法。它不是从外部作为黑盒来测试,而是把 @Test 方法带入容器。你用 ShrinkWrap 描述一个部署,Arquillian 把它部署到真实容器,然后通过字节码增强,让测试方法在该容器的 JVM 内执行——真实的 CDI bean、企业 bean 和 JTA 资源会直接注入到测试中。

这个项目刻意把 Arquillian 用得很窄,只在一个类中使用:BookServiceArquillianIT。它覆盖 AbstractService 中 BookServiceTest 和 BookServiceIT 都触及不到的角落:count()、findRange() 以及命名查询方法,这些都没有被任何 REST 资源、JSF bean 或其他测试调用。

[LOADING...]

关于那个部署:它是一个最小的 JavaArchive,只包含领域类、AbstractService 和 BookService、真实的 persistence.xml,以及一个空的 beans.xml——不是 Testcontainers 部署的完整 WAR,因为这个测试只需要 BookService 能针对一个可工作的持久化单元被注入,而不需要 Jakarta REST 或 JSF 层。

Payara Micro Managed 适配器(fish.payara.arquillian:arquillian-payara-micro-managed)会把 Azul Payara Micro 作为普通操作系统进程启动,独立于 Testcontainers 为同一版本拉取的 Docker 镜像。arquillian.xml 配置它的运行方式——随机 HTTP 端口、自动绑定,以及 180 秒启动超时——而 Payara Micro JAR 本身的路径来自 Maven 构建的 payara.microJar 系统属性。

优点。 Arquillian 是三者中唯一能把真实 bean 直接 @Inject 到测试方法中,并让它面对真实持久化提供者运行的工具,中间没有 REST 或 UI 层。在这个项目中,这填补了一个真实的空洞,因为 count()、findRange() 和命名查询在代码库中没有其他调用者来执行它们。ShrinkWrap 部署也可以严格限定为测试所需的类,这即使在完整容器内也能让失败面保持狭窄。

局限。 @Deployment 方法是对打包的第二份手工维护描述,没有任何东西强制它与真实 WAR 包含的内容一致——这正是 Testcontainers 通过直接部署构建产物所避免的漂移。启动要花费数秒,因为真实应用服务器必须启动。而且 Arquillian 对读者的要求比另外两个更高:ShrinkWrap 归档构建加上 arquillian.xml 容器配置,实际上是在 Jakarta EE 之上叠加了一套专用 DSL,这就是为什么这个项目把它限制在一个类中,而不是将其采纳为通用策略。

在照搬这套设置之前,有三个实际注意事项值得了解:

  • 本项目中的适配器版本是 4.0.alpha4。它能工作,但它是 alpha 版本——请有意地固定版本,并预期需要回头处理。
  • pom 从适配器的依赖中排除了 org.jboss.weld 和 org.jboss.weld.se。如果没有这些排除,适配器自带的 Weld 会把第二个 CDI 实现拖到一个已经有 weld-junit5 的测试类路径上,两个测试技术栈就会冲突。如果你在同一个模块中运行 WeldInitiator 和 Arquillian,就要预期需要管理这一点。
  • Managed 适配器启动的 Payara Micro JAR 会由 install-deps Maven profile 复制到 target/ 中。在没有运行该 profile 的情况下运行 Arquillian 测试,它会因为缺少文件而失败,而不是因为你的代码有问题——同一个 profile 也会安装 Playwright 浏览器。

另一种选择是 Remote 适配器:把 Arquillian 指向一个由你自己启动和停止的 Azul Payara Micro 或 Azul Payara Server 实例。这是用每次运行的启动时间,换取由你自己负责的设置和清理。无论哪种方式,这些测试都以秒计运行,而不是毫秒。

最适合: 对没有其他层执行的行为做容器内断言——也就是当你发现单元测试和部署测试之间存在缺口后所写的测试。

Testcontainers:部署后的应用真的能工作吗?

Testcontainers 会在测试生命周期中启动 Docker 容器——在这里,是一个 payara/micro 镜像,里面部署着 Maven 构建出的 WAR。没有任何东西被 mock;测试通过网络与一个真正运行中的应用通信,就像用户或 REST 客户端那样。

PayaraMicroContainer 封装了该镜像,而 AbstractContainerIT 在静态初始化器中只启动它一次,并在每个集成测试类之间共享:

[LOADING...]

两类测试共享这个容器。BookServiceIT 通过 HTTP 执行部署后的 REST API:

[LOADING...]

而 BookUiIT 通过 Playwright 用无头 Chromium 浏览器驱动 JSF 页面:

[LOADING...]

那个 assertThat 是 PlaywrightAssertions.assertThat,它会重试直到断言通过或超时——正是这一点让 UI 断言在面对异步渲染时保持稳定。来自 JUnit 或 AssertJ 的静态导入同样能顺利编译,却会静默失去这种行为。

优点。 部署保真度是主要好处:Failsafe 插件 systemPropertyVariables 中的 war.path 指向 package 阶段刚刚生成的 WAR,因此不存在一个单独手工维护、可能与最终交付物发生漂移的归档。这个模式也不是 Jakarta EE 专属的——同样的方法可以启动 PostgreSQL 实例或 Kafka broker。

局限。 它毫无例外地需要容器运行时。执行时间以秒计,因为真实应用服务器必须启动。而且它只从外部观察:它确认部署后的应用能正确响应 HTTP 和浏览器交互,但无法直接检查容器管理的状态。

最适合: 针对部署后的 REST API 进行 HTTP 集成测试,以及针对完整部署应用进行端到端浏览器测试。

为什么它们不能互换:一个具体案例

整个项目中最清楚的例证,是一对关于同一个方法 findOrEmpty() 的测试,该方法把 JPA 的 NoResultException 转换为 Optional.empty()。

WeldInitiator 单元测试对 mock 的 EntityManager 打桩,让它抛出 NoResultException,然后断言 findOrEmpty() 返回空 Optional。该测试通过。它证明错误处理是正确的——前提是 NoResultException 确实会到来。

Arquillian 测试针对一个不存在的 ISBN,在真实持久化提供者上运行真实命名查询,并断言同样得到空 Optional。这个测试证明了单元测试无法证明的事情:真正未命中的查询首先确实会产生 NoResultException,而不是空列表、null,或完全不同的异常类型。

一个测试验证你对某个假设的处理。另一个验证这个假设本身。两者都通过,两者都值得拥有,而且谁也不能替代谁。这就是把这些工具当作层而不是替代品的全部论据。

项目的 findRange() 测试提出了一个相关观点:在这个层上要写诚实的断言。AbstractService#findRange 构建 CriteriaQuery 时没有显式 ORDER BY,因此哪些行落在哪一页并不由 JPA 规范保证——规范只保证分页既不重复也不丢行。该测试断言页与页之间的不相交性,而不是页内容,这正是记录真实行为的测试与因为 H2 恰好按插入顺序返回行而在你机器上通过的测试之间的区别。

并排比较

WeldInitiatorArquillianTestcontainers
测试类别单元测试集成测试(容器内)集成测试和端到端测试
证明什么隔离环境中的 CDI 装配和业务逻辑代码在运行中的容器 JVM 内、面对真实持久化提供者时的行为构建出的 WAR 通过 HTTP 和在浏览器中行为正确
需要 Docker?否否——需要 Azul Payara Micro 或 Server 实例是
部署描述无——只有被测 bean手写的 ShrinkWrap 归档构建产物本身
速度毫秒秒——应用服务器必须启动秒——容器必须启动
在这里用于*ServiceTest——每个服务的 CRUD 逻辑,面对 mock 的 EntityManagerBookServiceArquillianIT——count()、findRange() 和命名查询*ServiceIT(REST)和 *UiIT(Playwright),针对部署后的应用
注意@PersistenceContext 和 @Transactional 在 Weld SE 中不会生效Alpha 适配器、Weld 类路径排除、需要 install-deps profile容器就绪和应用可服务是两个不同事件

如何在它们之间选择

在实践中,这个决定几乎是机械性的。如果问题关于 bean 自身的逻辑或它的 CDI 装配,就用 WeldInitiator,并让它保持在毫秒级。如果问题是部署后的应用从外部看是否行为正确——一个 REST 契约、一次表单提交、一个渲染出的页面——就用 Testcontainers。只有在狭窄的情况下才动用 Arquillian:该行为很重要,没有任何其他层能触及的调用者,并且需要像持久化提供者这样的真实容器服务才有意义。

反过来读,同一条规则也是一次覆盖率审计。某些代码单元测试触及不到,因为它需要真实提供者;部署测试也触及不到,因为没有东西调用它——这样的代码对两层都是不可见的。这正是为什么一个看起来测试良好的项目里,会有十四个命名查询未经验证。

可以带入你下一次测试策略讨论的一句话是:针对 mock 的通过测试证明你的代码符合你的假设;只有真实容器才能证明假设符合现实。## 常见问题

Arquillian 和 Testcontainers 有什么区别?

Arquillian 使用字节码增强,在容器自身的 JVM 内运行测试方法,因此可以将真实的 CDI bean、企业级 bean 和 JTA 资源直接注入测试,并直接检查内部状态。Testcontainers 在 Docker 容器中运行应用,并通过网络从外部针对实际构建产物进行测试。Arquillian 需要手写 ShrinkWrap 部署包,而该部署包可能与真实打包方式产生偏差;Testcontainers 部署构建刚刚生成的 WAR,但无法看到容器内部。在 Azul Payara Micro 上两者都可用,而且大多数项目两者都需要。

对于 Jakarta EE 应用,我应该使用哪种测试工具?

这取决于测试必须回答什么问题。对于 bean 自身逻辑和 CDI 装配,使用 weld-junit5 的 WeldInitiator 可在测试 JVM 中于毫秒级运行真实的 CDI 容器。对于已部署应用能否通过 HTTP 或在浏览器中正常工作,Testcontainers 会在真实运行时(例如 Azul Payara Micro)上运行构建好的 WAR。对于需要真实容器服务、且上述两层都无法触达调用方的行为——分页、计数、命名查询——Arquillian 会在容器内部直接注入 bean。它们是互补的层次,而不是相互竞争的选择。

能否在不部署整个应用的情况下测试 JPA 命名查询?

可以,使用容器内测试并配合范围严格限定的部署。Arquillian 的 ShrinkWrap 可以构建一个仅包含实体类、服务类和真实 persistence.xml 的归档,然后将其部署到 Azul Payara Micro,并将服务注入测试——这足以让命名查询针对真实的持久化提供程序运行,而无需 Jakarta REST 或 JSF 层。模拟的 EntityManager 无法做到这一点:它从不执行 JPQL,因此即使查询无法编译,其单元测试仍会通过。

为什么我的测试在模拟 EntityManager 时通过,但在生产环境中失败?

因为 mock 返回的是你让它返回的内容。一个将 EntityManager 打桩为抛出 NoResultException 的单元测试,只能证明在出现该异常时你的错误处理是正确的;它永远不能证明真实查询会产生该异常,而不是返回空列表或另一种失败。任何依赖持久化提供程序实际执行的内容——JPQL 有效性、映射、生成的 SQL、事务边界、分页——都需要真实的提供程序。在 Jakarta EE 项目中,这意味着使用 Arquillian 进行容器内测试,或在 Azul Payara Micro 之类的运行时上部署测试。

在 Java 中,集成测试比单元测试慢多少?

大约三个数量级。测试 JVM 内 Weld SE 容器中的 CDI 单元测试以毫秒级完成;Arquillian 或 Testcontainers 测试必须启动应用服务器,时间以秒计——示例项目为其受管的 Azul Payara Micro 实例允许 180 秒启动超时。这种差异正是这些层次有不同归属的原因:每次保存都运行单元测试,集成测试和端到端测试放在 CI 中。