Java-微服务测试指南-全-
Java 微服务测试指南(全)
原文:Testing Java Microservices
译者:飞龙
第一章。微服务简介
本章涵盖
-
为什么要转向新的微服务架构?
-
今天微服务是什么,以及未来可能的发展方向
-
微服务的基本组件构成
-
测试策略
传统的单体应用程序通常作为一个单一包部署,通常是一个 Web 或企业存档文件(WAR 或 EAR)。它们包含完成多个任务所需的所有业务逻辑,通常还包含渲染用户界面(UI,或 GUI,即图形用户界面)所需的组件。在扩展时,这通常意味着将整个应用程序存档的完整副本复制到新的服务器节点上(基本上,将其部署到集群中的另一个服务器节点)。无论负载或瓶颈发生在哪里;即使它只出现在应用程序的一个小部分,这种扩展方式都是一种全有或全无的方法。微服务专门设计用来针对和改变这种全有或全无的方面,通过允许您将业务逻辑分解成更小、更易于管理的元素,这些元素可以用多种方式使用。
这本书的目的不是介绍今天可用的各种微服务架构;我们假设您对这一主题有一些了解。相反,我们将帮助您克服测试所有微服务应用程序共享的常见功能所涉及到的挑战。为了做到这一点,在本章中,我们将确立一些关于微服务是什么的共识,这样您就可以在后面的章节中讨论这些话题时,了解我们的出发点。
转向越来越受欢迎的微服务架构意味着您需要在开发、测试、重构/重构和重构方面采用新的策略,并远离一些纯粹的单体应用程序实践。
微服务为您提供能够扩展单个服务的优势,以及能够使用多个团队并行开发和维护多个服务的能力,但它们在测试方面仍然需要一种稳健的方法。
在这本书中,我们将讨论使用这种新的、更专注的方式来交付紧密打包的“微”服务的各种方法,以及如何解决维护多个团队稳定性的复杂测试场景。后面的章节将介绍一个示例应用程序及其测试策略的开发;这将帮助您更好地理解如何创建自己的测试环境。
您将看到并使用 Arquillian 测试框架的许多功能,该框架专门设计用来解决您将面临的大多数常见测试挑战。多年来已经开发了一系列成熟的扩展,尽管其他工具也可用,但 Arquillian 是我们的首选工具——因此请期待一些偏见。话虽如此,Arquillian 还提供了与您可能已经熟悉的许多测试工具的紧密集成。
关于软件版本的一个注意事项
本书使用了许多不同的软件包和工具,这些工具都会定期更新。我们试图在整本书中展示不会因这些变化而受到很大影响的示例和技术。所有示例都需要 Java 8,尽管当我们完成这本书时,Java 10 已经发布。我们没有更新示例,因为在测试微服务方面,这个版本并没有增加任何新功能。对于 JUnit 5 也是如此。所有示例都是使用 JUnit 4.12 编写的,因为当我们开始写这本书时,JUnit 5 还没有开发出来。在我们完成这本书的时候,这里解释的所有框架还没有官方支持 JUnit 5,所以我们决定跳过更新 JUnit 版本。其他库,如 Spring Boot 和 Docker (Compose),在本书的开发过程中也发生了演变,但这些变化对编写测试的方式没有重大影响。
1.1. 什么是微服务,为什么使用它们?
在本节中,我们展示了我们认为目前对这些问题的现有答案的合理解释。你所学的知识将为理解微服务架构提供一个坚实的基础,但请期待随着时间的推移会有创新。我们不会做出任何预测:正如所述,本书的主要关注点是测试微服务,这不太可能发生任何重大变化。
在这个阶段,你完全理解微服务架构并不重要。但如果阅读完这一章后,“微服务”这个术语对你来说仍然是一个模糊的概念,我们鼓励你从自己的渠道获取更多信息。
提示
你可能会发现加入 MicroProfile(microprofile.io)的公开讨论很有用。这是由 IBM、伦敦 Java 社区(LJC)、RedHat、Tomitribe、Payara 和 Hazelcast 等公司发起的一项倡议,旨在为微服务开发一个共享的企业 Java 定义,目标是标准化。
1.1.1. 为什么使用微服务?
在我们深入探讨微服务的本质之前,让我们先回答“为什么”这个问题。直到最近,开发单体应用是很常见的,对于不需要扩展的应用来说,这仍然完全可接受。扩展任何类型单体应用的问题很简单,如图 1.1 所示。微服务并不是要告诉你其他一切都是不好的;相反,它们提供了一个比单体更具有未来变化适应性的架构。
图 1.1. 扩展单体应用

微服务使你能够隔离和扩展应用的小部分,而不是整个应用。想象一下,你将应用中的一些核心业务逻辑提取到了服务 A 和 B 中。假设服务 A 提供对商品库存的访问,而 B 提供简单的统计数据。你会发现,平均每小时服务 A 被调用一百万次,而服务 B 每天只被调用一次。扩展单体应用意味着添加一个包含服务 A 和 B 的应用的新节点。
如果你只需要扩展服务 A,岂不是更好?这就是微服务的潜力显现的地方:在新架构中,如图 1.2 所示,服务 A 和 B 变成了微服务 A 和 B。你仍然可以扩展应用,但这种额外的灵活性是关键:你现在可以选择在负载最大的地方进行扩展。更好的是,你可以指派一个开发团队来维护微服务 A,另一个团队来维护微服务 B。你不需要触及应用来添加功能或修复 A 或 B 中的错误,它们也可以完全独立地相互推出。
图 1.2. 独立于主应用扩展微服务

Netflix、Google、Amazon 和 eBay 等公司已经将它们的大部分平台建立在微服务架构之上,并且他们都足够慷慨,愿意免费分享这些信息的大部分内容。尽管对 Web 应用的关注很多,但你也可以将微服务架构应用于任何应用。我们希望这能激发你的兴趣!
1.1.2. 什么是微服务?
初看,术语“微”可能会让人联想到一个体积小、占用空间小的应用。但就应用大小而言,没有固定的规则,除了一个经验法则。一个微服务可能由几个、几百个,甚至几千行代码组成,这取决于你具体的业务需求;经验法则是保持逻辑足够小,以便单个团队管理。理想情况下,你应该专注于单个端点(这可能会提供多个资源);但同样,也没有硬性规定。这是你的派对。
最常见的概念是,单个应用应该是微服务的上限。在典型应用服务器运行多个应用的背景下,这意味着将应用拆分,以便它们在单个应用服务器上运行。理论上,将你的第一个微服务视为拼图的一块,并尝试想象它如何与下一块拼合在一起。
你可以将单体应用程序分解为其逻辑部分,如图 1.3 所示 figure 1.3。每个拼图块中应该包含足够的信息,以便你能够构建更大的图景。在微服务架构中,这些部分耦合得更加松散;参见 figure 1.4。
图 1.3. 每个服务都是大图景的一部分。

图 1.4. 每个微服务仍然是图景的一部分,但被隔离在单独的环境中。

1.1.3. 持续集成、部署和 Docker
将应用程序元素解耦到可扩展的微服务意味着你将不得不从早期阶段开始考虑持续集成(CI)和持续交付(CD)管道。你将需要多个独立的构建,这些构建必须被缝合在一起进行集成测试和部署到不同的主机。
你会发现所需的工作远比你想象的要少。这主要归因于这样一个事实,即微服务在本质上就像任何其他应用程序一样。唯一的区别是微服务将应用程序与其运行环境打包在一起。今天最简单、最公认的方法是将微服务作为 Docker 镜像(www.docker.com)创建和部署。
注意
Docker 是世界上领先的软件容器化平台。如果你不确定 Docker 是什么,那么在某个时候请访问www.docker.com并遵循“什么是 Docker?”教程。不过,不用担心——当我们把所有微服务元素组合在一起,在本书的结尾部分,我们将引导你通过这个管道。
重型 CI/CD 竞争者包括 Travis(travis-ci.org)、Bamboo(de.atlassian.com/software/bamboo)和 Jenkins(jenkins.io)。它们都为微服务和 Docker 镜像的部署管道提供了很好的支持;但在这本书中,我们将使用 Jenkins,因为它开源并且拥有庞大的社区。它可能不是最容易使用的,但它通过插件提供了最丰富的功能。在第八章[kindle_split_017_split_000.xhtml#ch08]中,我们将详细介绍所有涉及的技术,并指导你开发一个可行的 CI/CD 管道。
1.2. 微服务网络和特性
微服务是松散耦合的,这引发了一些新问题。微服务是如何耦合的,这种架构提供了哪些特性?在接下来的章节中,我们将探讨一些答案。但无论如何,每个微服务都是由网络边界隔离的。
1.2.1. 微服务网络
微服务通常通过 RESTful (表示状态转移) API 使用 HTTP 或 HTTPS 进行集成,但它们可以通过任何被认为是一种协议来连接,以访问资源或函数的端点。这是一个广泛的话题,所以我们只将讨论和演示使用 JAX-RS 的 Java REST。
小贴士
如果你还不熟悉使用 JAX-RS 的 RESTful 网络服务(jax-rs-spec.java.net),现在正是学习这些主题的好时机。
在获得这些信息后,你对微服务的初步想法应该开始成形。让我们继续之前的例子。微服务 A,库存服务,通过网络层与 UI 和微服务 B,统计服务隔离。B 通过定义的请求-响应协议与 A 通信以收集统计数据。它们各自拥有自己的领域和外部资源,并且在其他方面完全独立。UI 服务能够调用 A 和 B,以人类可读的形式、一个网站或一个重型客户端展示信息,如图 1.5figure 1.5 所示。
图 1.5. 每个服务通过定义的协议进行通信。

超媒体
应该考虑使用 超媒体 来开发服务。这是最新的热门词汇;它意味着服务在其架构中应该是自文档化的,通过在任何响应中提供相关资源的链接。目前在这个类别中还没有赢家,现在开始下注是不公平的,但你可以看看领跑者并做出明智的猜测:JSON-LD (json-ld.org)、JSON Hypertext Application Language (HAL, tools.ietf.org/html/draft-kelly-json-hal-08)、Collection+JSON (github.com/collection-json/spec) 和 Siren (github.com/kevinswiber/siren)。
测试必须设计得能够全面覆盖与外部服务的任何交互。这一点非常重要,因为网络交互总会带来它自己的一套挑战。我们将在第五章 chapter 5 中对此进行详细讨论。
到现在为止,应该很清楚,微服务在应用大小方面可以很大,而“微”指的是应用的公共接口面积。如今云空间便宜,所以微服务的物理大小不如过去那么重要。
我们经常听到的另一个担忧是,“关于网络速度怎么办?”微服务通常托管在同一本地网络中,这通常是千兆以太网或更好。因此,从客户端的角度来看,考虑到微服务的易于扩展,响应时间可能会比预期的要好得多。再次强调,不要只听我们的话;想想 Netflix、Google、Amazon/AWS 和 eBay。
1.2.2. 微服务特性
在我们的例子中,微服务 A 和 B 都可以独立开发并由两个完全不同的团队部署。每个团队只需要了解他们正在工作的微服务的资源组件层,而不是整个业务域组件。这是第一个重大胜利:在给定上下文中,开发可以更快且更容易理解。
JavaScript 对象表示法(JSON,www.json.org)和可扩展标记语言(XML,www.w3.org/XML)是常见的资源语言,因此编写此类服务的客户端很容易。在某些情况下,可能需要不同的方法,但基本场景基本上是相同的:端点可以通过定义的协议从多种设备和客户端访问。
多个微服务形成一个连接的应用程序网络,其中每个单独的微服务都可以独立扩展。云上的弹性部署现在很常见,这使得单个服务可以根据负载自动扩展或缩减。
微服务的其他一些有趣的好处是提高了故障隔离和内存管理。在单体应用中,单个组件的故障可能会使整个服务器崩溃。具有弹性的微服务,大部分功能将继续运行,直到出现问题的服务问题得到解决。在图 1.6 中,统计服务对于整个应用的功能是否真的必要,或者你可以在一段时间内没有它?
图 1.6. 使用断路器的弹性设计

当然,正如所有美好事物的本质一样,微服务也有其缺点。开发者需要学习和理解开发分布式应用的复杂性,包括如何最好地使用 IDE,这些 IDE 通常面向单体开发。开发跨越多个服务且不包含在分布式事务中的用例需要比单体应用更多的思考和规划。而且测试通常更困难,至少对于连接的元素来说是这样,这也是我们编写这本书的原因。
1.3. 微服务架构
微服务的结构可能多种多样,如图 1.7 所示,但设计上的相似性是必然的。这些元素可以组合在一起形成应用程序组件层。在每个层提供测试覆盖率很重要,你可能会在过程中遇到新的挑战;我们将在整本书中解决这些挑战并提供解决方案。
图 1.7. 基本微服务组件

让我们从上到下看看这些微服务组件层。
注意
微服务应该封装并暴露一个定义良好的逻辑区域作为服务。这并不意味着你不能通过其他方式允许其他系统进行交互。例如,你的服务可能公开了存储在 Elasticsearch(ES)中的特定文档。在这种情况下,其他应用程序直接与 ES 通信以初始化文档是完全合法的。
1.3.1. 资源组件
资源负责通过选定的协议公开服务交互。这种交互使用映射对象进行,通常使用 JSON 或 XML 进行序列化。这些映射对象代表业务域的输入和/或输出。对传入对象的净化和协议特定响应的构建通常发生在这一层;参见图 1.8。
图 1.8. 资源组件公开暴露服务。

注意
现在我们已经在这里,值得提一下,资源组件层是使“微”服务成为“微服务”的层。
在本书的其余部分,为了简单起见,我们将关注今天最常见的资源提供者形式:RESTful 端点^([1])。如果你不熟悉 RESTful Web 服务,请花时间研究和理解这个重要主题。
¹
请参阅 Java EE 6 教程中的“什么是 RESTful Web 服务?”,
mng.bz/fIa2。
1.3.2. 业务域组件
业务域组件是您服务应用程序的核心焦点,并且对于服务正在开发的具体逻辑任务非常具体。域可能需要与各种其他服务(包括其他微服务)进行通信,以便计算响应或处理来自资源组件的请求和响应;参见图 1.9。
图 1.9. 业务域组件是您服务业务逻辑。

在域组件和资源组件之间,以及可能还有远程组件之间,可能需要一个桥梁。大多数微服务都需要在某个时候与其他微服务进行通信。
1.3.3. 远程资源组件
这个组件层是您的拼图碎片可能需要连接到图片的下一部分或几部分的地方。它由一个客户端组成,该客户端了解如何向其他微服务端点发送和接收资源对象,并将其转换为业务组件层中的使用;参见 图 1.10。
图 1.10. 远程资源组件是通往其他服务的入口。

由于远程资源的特点,你必须特别注意创建一个具有弹性的设计。一个弹性的框架旨在在发生故障时提供诸如断路器和超时回退等特性。不要试图重新发明轮子:有多个弹性的框架可供选择,包括我们的首选,Hystrix (github.com/Netflix/Hystrix/wiki),这是一个开源项目,由 Netflix 贡献。
网关服务应充当领域组件和客户端组件之间的桥梁。它负责通过客户端将请求和响应调用翻译为任何远程资源。如果资源无法访问,这是提供优雅失败的最佳位置。
客户端负责使用您选择的协议进行通信。十有八九,这将是通过 HTTP/S 的 JAX-RS (jax-rs-spec.java.net) 用于 RESTful 网络服务。
我们强烈推荐开源服务框架 Apache CXF (cxf.apache.org) 用于此层,因为它完全符合 JAX-WS、JAX-RS 等标准,并且不会将您绑定到特定平台。
1.3.4. 持久性组件
更多时候,应用程序需要某种类型的持久性或数据检索(参见 图 1.11)。这通常以对象关系映射(ORM)机制的形式出现,例如 Java 持久性 API(JPA),^([2]),但也可能是像嵌入式数据库或属性文件这样简单的东西。
²
请参阅“Hibernate ORM:什么是对象/关系映射?”
hibernate.org/orm/what-is-an-orm。³
请参阅 Java EE 6 教程中的“Java 持久性 API 简介”,
mng.bz/Cy69。
图 1.11. 持久性组件用于数据存储。

1.4. 微服务单元测试
第三章 将深入探讨实际的单元测试场景。接下来的几段将介绍我们将使用的术语以及您在开发测试策略时可以期待的内容。
典型的单元测试旨在尽可能小,并测试一个微不足道的项目:工作单元。在微服务环境中,这个工作单元可能更难表示,因为服务通常比乍看之下有更多的底层复杂性。
单元测试往往会导致您需要重构代码以降低被测试组件的复杂性。这也使得测试作为一个设计工具变得有用,尤其是在您使用测试驱动开发(TDD)时。单元测试的一个有益副作用是,它让您在检测回归的同时继续开发应用程序。
虽然您可能会在过程中遇到更详细的场景,但基本上有两种单元测试风格:社交和独立。这些风格松散地基于单元测试是否与其底层协作者隔离。两种风格都不是排他的,并且它们很好地互补。您应该根据测试挑战的性质使用两者。我们将在整本书中扩展这些概念。
1.4.1. 独立单元测试
独立单元测试应专注于单个对象类的交互。测试应仅涵盖该类自己的依赖项或对该类的依赖项。您通常会使用独立测试来测试资源、持久性和远程组件,因为这些组件很少需要相互协作;参见图 1.12。
图 1.12. 主要为独立单元测试组件

您需要通过模拟或存根化该类中所有协作者来隔离单个类进行测试。您应该测试该类的所有方法,但不要跨越到其他具体类的边界。基本上,这意味着所有注入的字段都应该接收一个模拟或存根化的实现,该实现只返回预定义的响应。主要目标是使被测试类的代码覆盖率尽可能高。
1.4.2. 社交单元测试
社交单元测试侧重于通过观察其状态的变化来测试模块的行为。这种方法将单元测试视为一个完全通过其接口测试的黑盒。领域组件几乎总是社交测试的候选者,因为它需要协作以处理请求并返回响应;参见图 1.13。
图 1.13. 主要为社交单元测试组件

你可能仍然需要模拟或伪造测试中类的一些复杂协作者,但这应该在协作对象层次结构中尽可能远。你不应该只测试特定类是否发送和接收正确的有效载荷,还应该测试类协作者在类内部是否按预期操作。测试覆盖率应理想地包括所有模型、变量和字段以及类协作者。测试该类能够正确处理任何响应,包括无效响应(负面测试)也很重要。
摘要
-
微服务是单体应用的一部分,已经被分解成更小的逻辑元素。
-
微服务通过允许有针对性的扩展和专注的开发,使你的应用程序受益。
-
微服务通过提供能力来扩展性能所需的位置和时间,提供了一种逻辑上满足可扩展性要求的方法。
-
你可以将单体应用拆分成更小的元素,这些元素可以用作微服务。
-
微服务允许几个团队专注于构成更大图景的个别、非冲突任务。
-
单独的单元测试用于那些不需要存储状态或不需要协作以进行测试的组件。
-
社交单元测试用于那些必须协作或存储状态以进行测试的组件。
第二章. 测试中的应用程序
本章涵盖
-
探索一个示例应用程序
-
理解代码的关键部分
-
使用 Java EE 和 Spring Boot 开发微服务
上一章向您介绍了微服务,包括它们的基本结构和架构。这个介绍旨在让您了解您可能需要为基于微服务架构编写的测试类型。
本章介绍了将在整本书中用于演示微服务架构开发和测试的应用程序。我们的目标是提供一个易于遵循的示例,以帮助您了解将要应用的各种测试的相关性。我们试图遵循微服务架构的最佳实践,但为了简单起见,也为了纯粹的教育目的,我们做出了一些设计选择。例如,我们可能会使用比必要的更多技术,或者简化微服务中使用的层数,因为它们从测试的角度来看没有增加价值。在这种情况下,我们会指出采取特定方法的原因,并讨论如何在现实世界的编程中执行这些任务。最终,作为开发者,选择合适的工具是您的责任,但我们始终提供推荐的方法。
2.1. 开始
示例应用程序 Gamer 是一个简单的软件门户,面向玩家。其目的是公开软件游戏的信息,并让玩家不仅能够阅读有关游戏的重要事实和观看游戏播放的视频,还能够对已玩的游戏进行评论并留下星级评分。尽管这个应用程序故意很简单,但它涵盖了展示微服务架构所需的所有主要主题。在整本书中,我们将引导你了解为基于微服务架构的应用程序编写的各种测试。
我们将首先提供一些关于 Gamer 应用程序的使用案例,以获得玩家可以采取的高层次视图。玩家希望能够做以下事情:
-
通过名称搜索游戏,以便他们可以看到符合他们兴趣的游戏列表
-
了解游戏的重要方面,如发布日期和哪些平台支持
-
阅读其他玩家对游戏的评论,以帮助他们决定是否喜欢它并想购买它
-
对游戏写评论,以便其他玩家可以从他们的评估中受益
-
为游戏分配星级评分,并快速查看评分最高的游戏
-
观看与游戏相关的视频,如预告片、教程和实际游戏中的玩法
让我们先定义这个应用程序所需的数据。我们目前不会关注技术细节——本节仅描述概念数据模型。
主要实体是 游戏。表 2.1 展示了构成游戏的部分。
表 2.1. 游戏的组成部分
| 字段 | 描述 |
|---|---|
| 标题 | 表示游戏名称的字符串 |
| 封面 | 游戏封面的图片 URL |
| 发布日期 | 游戏的发布日期 |
| 发行商 | 游戏的发行商 |
| 开发者 | 游戏的开发者 |
表 2.2 展示了构成一个 发布日期 的部分。
表 2.2. 发布日期的组成部分
| 字段 | 描述 |
|---|---|
| 平台 | 游戏发布的平台名称 |
| 日期 | 游戏在平台上发布的日期(日、月和年) |
表 2.3 展示了构成一个 注释 的部分。
表 2.3. 注释的组成部分
| 字段 | 描述 |
|---|---|
| 注释 | 包含注释信息的字符串 |
| 评分 | 从 1 到 5 的星级评分,表示游戏的整体质量 |
现在你已经了解了 Gamer 应用将管理的数据类型,我们可以进一步深入,检查应用架构。
2.2. 前提条件
本书不是 Java 教程。如果你对 Java 语言不熟悉,那么你很可能不会享受阅读。话虽如此,我们希望提供对所有兴趣水平读者都有用的信息。docs.oracle.com/javase/tutorial 上的 Java 教程是任何有志于成为 Java 开发者的优秀资源,也是所有使用 Java 的人的绝佳参考。
本书也不是学术杰作。作者主要是开发者,对我们中的一些人来说,英语不是第一语言。我们喜欢亲自动手,也希望你也是如此。我们希望你保持开放的心态,并理解并非所有人都会分享我们的观点。没有一种方式是完全正确或完全错误的,我们的建议是为了激发你的创造性思维。
注意
大部分源代码是基于在打印页面上呈现的限制进行格式化的。这可能导致冗长的布局。请随意调整代码格式以符合你的偏好。
2.2.1. Java 开发工具包
你至少需要 Java 开发工具包(JDK)的 SE(标准版)8 版本来编译和运行本书中的代码。你可以在mng.bz/83Ct找到最新的 Oracle JDK(推荐)或在openjdk.java.net找到 OpenJDK。
要测试 Java,运行以下命令,它应该显示类似于以下的结果,具体取决于你的安装版本:
$ java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)
2.2.2. 构建工具
我们使用 Apache Maven (maven.apache.org) 和 Gradle (gradle.org) 来构建项目的几个模块。请确保你已按照相应网站上的说明安装了这两个工具。
要测试 Maven 是否正确安装,运行以下命令:
$ mvn -version
Apache Maven 3.3.9
...
对于 Gradle,运行以下命令:
$ gradle -v
------------------------------------------------------------
Gradle 3.2.1
------------------------------------------------------------
2.2.3. 环境变量
完整的应用程序需要两个 API 密钥才能访问一些远程资源。API 密钥注册到个人用户账户,因此我们无法在此提供共享密钥。
运行本书中提供的测试示例中的大多数示例不需要获取这些密钥。但如果你希望实验代码,我们建议你获取密钥并创建相应的环境变量。要获取 YouTube 的 API 密钥,请访问developers.google.com/youtube/v3/getting-started并遵循“开始之前”部分的说明。要获取互联网游戏数据库(IGDB)的 API 密钥,请访问igdb.github.io/api/about/welcome或直接访问api.igdb.com注册访问。
一旦您有了自己的 API 密钥,请将它们添加到您的环境变量中。在 Linux 上,请在/home/profile 中添加以下内容:
...
export YOUTUBE_API_KEY="Your-Key"
export IGDB_API_KEY="YourKey"
Windows 用户可能会发现以下链接在配置环境变量时很有用:mng.bz/1a2K.^([1])
¹
William R. Stanek,"配置系统和用户环境变量",MSDN,来自 Microsoft Windows 2000(Microsoft Press,2002 年)。
2.2.4. 集成开发环境 (IDE)
应用程序代码中没有任何部分要求您使用 IDE。记事本就足够了。
当然,您可以自由地使用您喜欢的 IDE(支持基于 Maven 和 Gradle 的项目)打开项目。如果您想在代码中添加断点以跟踪执行路径(强烈推荐),那么我们建议使用 IDE。
我们在以下 IDE 中测试了代码,没有特别的偏好顺序:
-
IntelliJ IDEA (www.jetbrains.com/idea)
-
NetBeans (
netbeans.org) -
Eclipse (www.eclipse.org/downloads)
2.3. 架构
如本章开头所述,Gamer 应用程序遵循微服务架构。首先要做的是确定哪些服务构成了应用程序。对于这个应用程序,我们得出结论,需要将领域划分为四个不同的微服务:
-
游戏服务—提供与游戏相关的所有信息。它包括通过特定名称获取游戏 ID 的查询,或返回特定游戏 ID 的信息。
-
评论服务—为特定游戏添加星级评分和评论,以及检索它们。
-
视频服务—返回特定游戏的三个最突出的视频的位置。
-
聚合服务—调用上述命名的服务,并将每个服务的数据聚合到单个响应中。
应用程序架构如图 2.1 所示。figure 2.1。
Figure 2.1. 游戏应用程序架构

如您所见,前端(通常是浏览器)消耗 Gamer API 提供的信息。入口点是聚合服务,它与游戏、视频和评论服务通信,以获取或插入游戏所需的数据。聚合服务将所有数据编译成单个响应,并将其返回给前端。你现在可以理解应用程序架构以及每个服务做出决策的技术原因。
注意
首先,当你从命令行或 IDE 构建应用程序时,请先跳过测试。为了说明一个观点,并提供练习,一些测试可能不会按照提供的方式完成。随着你在本书中的知识积累,你将处于更好的位置来玩转和扩展示例代码。
2.3.1. 游戏服务
使用以下代码安装游戏服务:
cd ./game
mvn install -DskipTests
游戏服务是一个运行在 WildFly Swarm 上的 Java EE 7 应用程序,负责提供所有与游戏相关的信息。它提供了两个操作来检索这些信息:
-
通过标题获取游戏列表(多个游戏可能具有相同的标题)。为此端点提供的信息必须是最少的:例如,只有游戏的标识符和/或标题。
-
通过指定已知的游戏标识符来返回有关游戏的详细信息。
你可能已经注意到没有插入游戏的操作。这是因为游戏服务充当外部服务 API 的代理/缓存。外部服务是一个超出当前应用程序范围的服务,由第三方开发和维护,而你只是订阅者。这些服务的典型例子包括搜索引擎、天气预报和地理空间计算。
此服务示例依赖于互联网游戏数据库网站(www.igdb.com)来提供所有必需的游戏数据。
互联网游戏数据库 API
IGDB 是一个视频游戏数据库,旨在供游戏消费者和视频游戏专业人士使用。除了作为获取游戏信息的门户外,该网站还提供了一个公共 REST API(www.igdb.com/api),允许您访问网站上注册的游戏数据。
要授权访问 REST API,您需要在网站上注册并请求一个新的 API 密钥。此密钥必须在每次调用中作为 HTTP 头传递。
在本书的过程中,我们提供了更多关于 IGDB REST API 的信息,例如如何对 IGDB 进行身份验证,以及资源端点的所需格式。
当你依赖第三方服务的外部调用时,始终很重要(如果可能的话)尽可能多地缓存外部服务的数据。这有三个原因很重要:
-
你避免了往返外部网络的来回,这通常是一个慢速操作。
-
如果你对外部 API 有配额/计费访问权限,你可以节省对服务的访问次数。
-
如果外部服务出现故障,您的应用程序可以继续使用缓存数据工作。
警告
通常,缓存仅在外部数据不经常更改或您可以在您的系统上复制所有数据的情况下使用。为了保持外部数据的一致性,您应该对缓存应用定期刷新策略,以防止其过时。为了简化,示例应用程序中没有实现刷新策略,但在实际场景中这是一个需要考虑的问题。
在这个微服务中,使用轻量级 SQL 数据库 H2 (www.h2database.com/html/main.html) 实现了一个缓存持久化层系统。游戏服务中使用的实体-关系(ER)模型由四个实体组成,在表 2.4–2.7 中进行了描述。图 2.2 以图形方式展示了这一点。
表 2.4. Game表
| 字段 | 数据类型 | 描述 |
|---|---|---|
| id | Long | 游戏标识符。 |
| version | Int | 用于避免乐观锁冲突的内部字段。 |
| title | String | 游戏名称。此值是唯一的。 |
| cover | String | 游戏封面的 URL,如果没有封面则为 null。 |
| 发布日期 | ReleaseDate | 类型为 ReleaseDate 的一对多关系。 |
| Publishers | 字符串集合 | 出版商与游戏名称之间的一对多关系。 |
| Developer | 字符串集合 | 开发者与游戏名称之间的一对多关系。 |
表 2.5. ReleaseDate表
| 字段 | 数据类型 | 描述 |
|---|---|---|
| OwnerId | Long | 游戏标识符。此字段作为外键。 |
| platformName | String | 游戏发布的平台名称。 |
| releaseDate | String | 游戏在此平台上发布的日期,格式为 YYYY/MM/DD。 |
表 2.6. Publisher表
| 字段 | 数据类型 | 描述 |
|---|---|---|
| OwnerId | Long | 游戏标识符。此字段作为外键。 |
| publisherName | String | 出版社名称。 |
表 2.7. Developer表
| 字段 | 数据类型 | 描述 |
|---|---|---|
| OwnerId | Long | 游戏标识符。此字段作为外键。 |
| developer | String | 开发者名称。 |
图 2.2. 游戏应用实体关系

图 2.2 中的实体-关系模式显示,一个游戏由一个标题和一个封面组成,由一个或多个开发者制作,由一个或多个出版商出版,并且每个平台都有零个或多个发布日期。
备注
对于微服务架构的缓存数据,还有其他一些很好的选择,例如 Infinispan、Hazelcast 和 Redis。它们不仅提供生存时间(TTL)功能,这使得刷新逻辑更加简单,而且在分布式(集群)环境中也能工作,这在微服务架构中很典型。为了教学目的,在这本书中我们使用 SQL 数据库。这种方法很简单,并使用您可能熟悉的技术。这也使我们能够介绍一个重要功能:ORM 的持久化测试。
在服务器层,游戏服务运行在 WildFly Swarm 应用程序服务器上。该服务的整体架构图显示在图 2.3 中。持久化层使用 H2 SQL 数据库来存储和检索游戏的缓存数据。最后,该服务连接到外部网站(IGDB.com)以获取尚未在系统中缓存的游戏的详细信息。
图 2.3. 游戏服务概览

WildFly Swarm
WildFly Swarm (wildfly-swarm.io)提供了一种打包和运行 Java EE 应用程序的方法,通过生成 uber-JAR(java -jar MyApp.jar),将应用程序与服务器运行时打包在一起以运行。
它还内置了对 Logstash、Netflix 项目如 Hystrix 和 Ribbon、Red Hat 项目如 Keycloak 和 Hawkular 等应用程序和框架的支持。
2.3.2. 评论服务
使用以下代码构建和打包评论服务:
cd ./comments
./gradlew war -x test
评论服务是一个运行在 Apache TomEE 上的 EE 7 应用程序。它负责管理特定游戏的评论以及游戏评分。评分是一个介于 1(最低评分)和 5(最高评分)之间的数字。请注意,此功能不是由 IGDB 提供的;这是您将添加到门户中使其更具参与性的功能。此服务提供两个端点:
-
一条添加评论和游戏评分
-
第二个返回所有为游戏编写的评论以及平均游戏评分
此服务的持久化层使用面向文档的 NoSQL 数据库来存储服务所需的所有数据。我们特别选择了 MongoDB NoSQL 数据库,因为它具有开箱即用的聚合框架。这对于计算给定游戏的平均评分是一个完美的解决方案。
注意
可以使用传统的 SQL 数据库实现类似的逻辑,但如今由于在某些情况下性能更好,使用 NoSQL 数据库并不罕见。此服务使用 NoSQL 数据库来展示示例。
MongoDB
MongoDB 是一个面向文档的 NoSQL 数据库。它不使用关系型数据库结构,而是在集合中以动态架构存储类似 JSON 的文档。具有相似目的的文档存储在同一个集合中。你可以将集合视为与 RDBMS 表等效,但不需要强制架构。
除了存储文档外,MongoDB 还提供索引、复制、水平分片负载均衡和聚合框架等功能。
MongoDB 将文档组织成 集合。对于评论服务,这个集合被命名为 comments。每个代表评论的文档具有以下架构,并包含游戏的 ID、评论本身以及游戏的评分(见 图 2.4):
{
"gameId": 1234,
"comment": "This game is awesome",
"rate": 3
}
图 2.4. 评论集合

评论服务的整体架构在 图 2.5 中展示。在服务器层,它运行在 Apache TomEE 应用服务器上(tomee.apache.org);对于持久化层,它使用 MongoDB NoSQL 数据库来存储和检索与游戏相关的评论。
图 2.5. 评论服务概览

Apache TomEE
Apache TomEE (tomee.apache.org),发音为“Tommy”,是一个全 Apache Java EE 6 Web Profile 认证和 EE 7 启用的堆栈,其中 Apache Tomcat 是主要角色。Apache TomEE 是从普通的 Apache Tomcat 压缩文件构建而成的。从 Apache Tomcat 开始,TomEE 添加了自己的 JAR 文件,并将剩余的文件压缩起来。结果是添加了 EE 功能的 Tomcat——因此得名 TomEE。
2.3.3. 视频服务
使用以下代码构建视频服务:
cd video
./gradlew build -x test
视频服务是一个 Spring Boot 应用程序,负责检索与给定游戏相关的三个最突出的视频。请注意,此功能不是由 IGDB 提供的;这是你将添加到门户中,使其对最终用户更具吸引力的内容。显然,此服务不会通过创建新的视频分享/流媒体网站来重新发明轮子,因此它使用 YouTube 来检索视频。
YouTube
YouTube 是一个全球性的视频分享网站。你可以将 YouTube 功能添加到任何网站,甚至可以搜索内容。
YouTube 数据 API 是 YouTube 为用户提供以连接其系统并执行上传视频、修改视频和搜索匹配特定术语的视频等操作而提供的 REST API。有关如何使用 YouTube 数据 API 的信息,请参阅本书附录。
此服务提供了一个单一端点,返回指定游戏的三个最突出的视频链接。
这个微服务在系统中长期存储数据的持久层方面没有。对于这个微服务,使用一个键值对的 NoSQL 内存数据库来缓存从 YouTube 搜索结果。当您需要缓存分布式数据且需要可选的持久性时,键值数据库是最佳选择,因为它们完美地满足这一需求。采用这种方法,您可以节省时间,因为外部网络请求比内部请求更昂贵。您还可以节省 YouTube 数据 API 分配的请求配额。在视频服务中,用作缓存系统的键值数据库是 Redis 数据库。
Redis
Redis 是一个内存数据结构存储,可以用作数据库、缓存系统或消息代理。它支持字符串、散列、列表、集合、范围查询的有序集合、位图、HyperLogLogs 和具有半径查询的地理空间索引等数据结构。
Redis 提供集群功能、主从复制和事务,在不持久化数据时具有极好的性能。
用于此微服务的 Redis 结构是一个列表。这个基本结构为给定键存储一个字符串值列表,并可选地带有 TTL。在这种情况下,键是游戏 ID,列表中每个元素的值是与游戏相关的视频 URL。如图 2.6 所示,Redis 结构存储了一个游戏 ID 和三个 YouTube URL 的列表。
Spring Boot
Spring Boot (projects.spring.io/spring-boot) 使创建独立、生产级别的基于 Spring 的应用程序变得简单,您可以“直接运行”。它采用 uber(fat)-JAR 方法,将应用程序打包到一个单一的 JAR 文件中,该文件包含运行时(嵌入服务器加应用程序)和一个Main类来运行它。它与 Spring 生态系统中的其他产品(如 Spring Data 和 Spring Security)很好地集成。
图 2.6. Redis 中缓存的视频 URL

该服务的整体架构如图 2.7 所示。figure 2.7。您可以看到视频服务是一个 Spring Boot 应用程序,它使用的缓存层是 Redis。该服务连接到外部网站(youtube.com)以获取特定游戏的视频链接。
图 2.7. 视频服务

2.3.4. 聚合服务
使用以下代码构建和打包聚合服务:
cd aggregator
./gradlew war -x test
此服务是一个 EE 7 应用程序,负责对游戏和评论服务进行调用,将两个调用的结果合并成一个文档,并将此文档返回给调用者。此服务提供三个端点:
-
一个用于添加评论和游戏评分
-
返回所有具有指定名称的游戏的第二个
-
一个返回与指定游戏相关的所有数据、所有用户评论和评分以及该游戏最重要的三个视频的第三个
聚合器服务整体架构如图图 2.8 所示。它没有持久化层。该服务在 Apache Tomcat 服务器内运行,并连接到所有其他服务。
Apache Tomcat
Apache Tomcat 服务器是 Java Servlet、JavaServer Pages、Java Expression Language 和 Java WebSocket 技术的开源实现。
图 2.8. 游戏聚合器服务与其他服务的关系

2.3.5. 总体架构
总结来说,Gamer 应用程序由三个服务组成。每个服务都部署在不同的平台上,从轻量级应用程序服务器 Apache TomEE 到 WildFly Swarm uber-JAR。持久化层使用了两种不同的数据库引擎:H2,一种传统的 SQL 数据库,以及 MongoDB,它属于 NoSQL 数据库家族。
这是一个广泛的技术范围。我们选择使用这些各种技术,具体是为了扩大本书的说明范围。在现实世界中,你的应用程序可能基于更相似的技术。但是,如前所述,不同团队工作在不同微服务上并不罕见。
Gamer 应用程序的整体架构可以在图 2.9 的架构图中看到。在架构图上,所有组件如何连接起来以组成一个完全功能化的、基于微服务的应用程序是非常重要的。
图 2.9. 我们项目的架构图

2.4. 应用设计模式
在前面的章节中,你已经从高层次的角度了解了 Gamer 应用程序,并且我们从业务角度对应用程序的要求给予了极大的关注。在接下来的章节中,我们将深入探讨应用程序的技术层面。
2.4.1. 解剖
Gamer 应用程序通过在架构层面应用单一职责原则(SRP),遵循微服务架构,使每个服务在部署、技术和语言方面都独立。总的来说,每个微服务都是按照图 2.10 中显示的架构进行构建的。让我们看看 Gamer 应用程序中每个部分是如何实现的。
图 2.10. 详细微服务结构

资源组件
资源组件是应用程序的一个薄层,它作为传入消息(通常是 JSON 文档)和领域组件中的业务逻辑之间的映射器。它还根据业务逻辑产生的结果,使用所需的协议提供响应。
在 Java EE 中,此组件通常使用 Java API for RESTful Web Services (JAX-RS)实现,该 API 为遵循 REST 架构模式的 Web 服务创建提供支持。一个资源组件的示例代码位于评论服务中(code/comments/src/main/java/book/comments/boundary/CommentsResource.java)。
列表 2.1. 资源组件
@Path("/comments") *1*
@Singleton
@Lock(LockType.READ)
public class CommentsResource {
@Inject
private Comments comments;
@Inject
private DocumentToJsonObject transformer;
@GET *2*
@Path("/{gameId}")
@Produces(MediaType.APPLICATION_JSON) *3*
public Response getCommentsOfGivenGame(@PathParam("gameId")
final Integer
gameId) { *4*
final Optional<Document>; commentsAndRating = comments
.getCommentsAndRating(gameId);
final JsonObject json = transformer.transform
(commentsAndRating.orElse(new Document()));
return Response.ok(json).build(); *5*
}
}
-
1 设置类或方法的相对路径
-
2 指示该方法服务 HTTP GET 请求类型
-
3 设置响应 MIME 类型
-
4 将参数绑定到路径段
-
5 返回带有 HTTP 响应代码 OK (200)的内容
默认情况下,请求处理以同步方式进行;这意味着客户端请求从开始到结束由单个容器 I/O 线程处理。这种阻塞方法适用于执行时间短的业务逻辑。
但对于长时间运行的任务,容器线程将保持占用状态,直到任务完成。这可能会对服务器的吞吐量产生重大影响,因为新的连接可能会比预期更长地被阻塞,等待处理积压队列。
为了解决这个问题,JAX-RS 有一个异步模型。这允许容器线程在客户端连接关闭之前释放,以接受新的连接。长时间运行的任务在另一个线程中运行,容器 I/O 线程可以由另一个在积压队列中等待的连接使用。
异步资源组件的示例代码位于游戏服务中(code/game/src/main/java/book/games/boundary/GamesResource.java),因为连接到外部资源可能需要相当长的时间才能完成。
列表 2.2. 异步资源组件
@Path("/")
@javax.ejb.Singleton *1*
@Lock(LockType.READ)
public class GamesResource {
@Inject
GamesService gamesService;
@Inject *2*
ExecutorServiceProducer managedExecutorService;
@GET
@Produces(MediaType.APPLICATION_JSON)
@javax.ejb.Asynchronous *3*
public void searchGames(@Suspended final AsyncResponse
response, *4*
@NotNull @QueryParam("query") final
String query) {
response.setTimeoutHandler(asyncResponse ->; asyncResponse
.resume(Response.status(Response.Status
.SERVICE_UNAVAILABLE).entity("TIME OUT !")
.build()));
response.setTimeout(15, TimeUnit.SECONDS);
managedExecutorService.getManagedExecutorService().submit(
() ->; { *5*
try {
final Collector<JsonObject, ?, JsonArrayBuilder>;
jsonCollector = Collector.of
(Json::createArrayBuilder,
JsonArrayBuilder::add, (left,
right) ->; {
left.add(right);
return left;
});
final List<SearchResult>; searchResults =
gamesService.searchGames(query);
final JsonArrayBuilder mappedGames = searchResults
.stream().map(SearchResult::convertToJson)
.collect(jsonCollector);
final Response.ResponseBuilder ok = Response.ok
(mappedGames.build());
response.resume(ok.build()); *6*
} catch (final Throwable e) {
response.resume(e); *7*
}
});
}
}
-
1 资源标记为 Singleton EJB,因此端点变为事务性的
-
2 注入容器提供的执行器服务
-
3 将方法指定为异步。仅当它是 EJB 时有效。
-
4 指示 JAX-RS 运行时此方法是异步的,并注入 AsyncResponse
-
5 在不同的线程中执行逻辑
-
6 当结果准备好时,连接将恢复。
-
7 在出现错误的情况下,也应恢复通信。
对于 Spring 应用程序,资源使用 Spring Web 模型-视图-控制器(MVC)框架实现。此框架围绕DispatcherServlet类构建,并将请求调度到配置的处理程序以执行业务逻辑。
为 Spring Web MVC 框架编写的资源示例代码位于视频服务中(code/video/src/main/java/book/video/boundary/VideosResource.java)。
列表 2.3. Spring 资源
package book.video.boundary;
import book.video.controller.VideoServiceController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@CrossOrigin(origins = {"http://localhost:8080",
"http://localhost:8181", "http://localhost:8282",
"http://localhost:8383"})
@RestController *1*
public class VideosResource {
@Autowired *2*
VideoServiceController videoServiceController;
@RequestMapping(value = "/", produces = "application/json") *3*
public ResponseEntity<List<String>;>; getVideos(
@RequestParam ("videoId") final long videoId,
@RequestParam("gameName") final String gameName) {
final List<String>; linksFromGame = videoServiceController
.getLinksFromGame(Long.toString(videoId), gameName);
return ResponseEntity.ok(linksFromGame);
}
}
-
1 资源标记为 Spring Rest 控制器。
-
2 注入视频服务逻辑
-
3 配置端点方法
领域模型
领域模型是一个表示或抽象现实世界中属于领域且需要在软件中建模的概念。领域中的每个对象都包含对象的数据和行为。
在 Java EE 和 Spring 应用程序中,如果领域需要持久化到 SQL 数据库,则领域会被标注为 Java 持久化 API(JPA)注解。我们将在第四章和第五章中深入讨论 JPA。
以游戏服务(code/game/src/main/java/book/games/entity/Game.java)为例,其中的领域模型是Game。
列表 2.4. 领域模型
@Entity
public class Game implements Serializable {
@Id
@Column(name = "id", updatable = false, nullable = false)
private Long id;
@Version
@Column(name = "version")
private int version;
@Column *1*
private String title;
@Column
private String cover;
@ElementCollection
@CollectionTable(name = "ReleaseDate", joinColumns =
@JoinColumn(name = "OwnerId"))
private List<ReleaseDate>; releaseDates = new ArrayList<>;();
@ElementCollection
@CollectionTable(name = "Publisher", joinColumns = @JoinColumn
(name = "OwnerId"))
private List<String>; publishers = new ArrayList<>;();
@ElementCollection
@CollectionTable(name = "Developer", joinColumns = @JoinColumn
(name = "OwnerId"))
private List<String>; developers = new ArrayList<>;();
public JsonObject convertToJson() { *2*
final JsonArrayBuilder developers = Json.createArrayBuilder();
this.getDevelopers().forEach(developers::add);
final JsonArrayBuilder publishers = Json.createArrayBuilder();
this.getPublishers().forEach(publishers::add);
final JsonArrayBuilder releaseDates = Json
.createArrayBuilder();
this.getReleaseDates().forEach(releaseDate ->; {
final String platform = releaseDate.getPlatformName();
final String date = releaseDate.getReleaseDate().format
(DateTimeFormatter.ISO_DATE);
releaseDates.add(Json.createObjectBuilder().add
("platform", platform).add("release_date", date));
});
return Json.createObjectBuilder().add("id", this.getId())
.add("title", this.getTitle()).add("cover", this
.getCover()).add("developers", developers)
.add("publishers", publishers).add("release_dates",
releaseDates).build();
}
}
-
1 领域对象有字段描述其在系统中的属性。
-
2 对象操作方法应位于对象内部。
服务层
服务层中的服务负责协调各个领域活动以及与其他子系统的交互。例如,这些服务通过持久化组件处理数据库交互,并通过远程资源组件调用外部服务。
在 Java EE 和 Spring 中,这一层通常实现为一个简单的 Java 类,通过注解使其有资格通过上下文依赖注入(CDI)或 Spring 组件中的自动装配进行注入。服务应该可以在构成微服务的任何元素中注入。
一个 Java EE 服务的示例可以在游戏服务(code/game/src/main/java/book/games/control/GamesService.java)中找到。此服务负责检查游戏是否缓存在 Gamer 本地数据库中,或者是否必须首先从 IGDB API 检索。
列表 2.5. Java EE 服务
@Dependent *1*
public class GamesService {
@EJB *2*
Games games;
@EJB
IgdbGateway igdbGateway;
public Game searchGameById(final long gameId) throws IOException {
final Optional<Game>; foundGame = games.findGameById(gameId); *3*
if (isGameInSiteDatabase(foundGame)) {
return foundGame.get();
} else {
final JsonArray jsonByGameId = igdbGateway
.searchGameById(gameId); *4*
final Game game = Game.fromJson(jsonByGameId);
games.create(game);
return game;
}
}
}
-
1 将此类设置为 CDI 容器中依赖范围的合格
-
2 其他元素可以注入到服务中。
-
3 在数据库中查找游戏
-
4 如果找不到游戏,则从 IGDB 获取
Spring 服务的一个示例可以在视频服务(code/video/src/main/java/book/video/boundary/YouTubeVideos.java)中找到。
列表 2.6. Spring 服务
package book.video.boundary;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class YouTubeVideos {
@Autowired
StringRedisTemplate redisTemplate;
public void createYouTubeLinks(final String gameId, final
List<String>; youtubeLinks) {
final ListOperations<String, String>;
stringStringListOperations = redisTemplate
.opsForList();
stringStringListOperations.leftPushAll(gameId, youtubeLinks);
}
public boolean isGameInserted(final String gameId) {
final ListOperations<String, String>;
stringStringListOperations = redisTemplate
.opsForList();
return stringStringListOperations.size(gameId) >; 0;
}
public List<String>; getYouTubeLinks(final String gameId) {
final ListOperations<String, String>;
stringStringListOperations = redisTemplate
.opsForList();
final Long size = stringStringListOperations.size(gameId);
return stringStringListOperations.range(gameId, 0, size);
}
}
仓库
仓库作用于领域实体集合,通常作为入口点或到持久化组件后端的桥梁。
提示
如果你只打算管理单个实体,我们不推荐添加仓库层,因为这会增加不必要的开销。也没有必要将对象通过从未与之交互的层传递。为了教育目的,我们实现了一个简单的仓库层,以展示如何在现实世界场景中最佳地测试该类。
当在 Java EE 容器中使用 JPA 的 SQL 数据库时,你应该使用企业 JavaBeans(EJBs),因为它们提供了开箱即用的事务感知、并发管理和安全性。
游戏服务(code/game/src/main/java/book/games/boundary/Games.java)中可以找到一个示例仓库层。
列表 2.7. 仓库层
@Stateless *1*
public class Games {
@PersistenceContext *2*
EntityManager em;
public Long create(final Game request) {
final Game game = em.merge(request); *3*
return game.getId();
}
public Optional<Game>; findGameById(final Long gameId) {
Optional<Game>; g = Optional.ofNullable(em.find(Game.class,
gameId));
if (g.isPresent()) {
Game game = g.get();
game.getReleaseDates().size();
game.getPublishers().size();
game.getDevelopers().size();
em.detach(game);
}
return g;
}
}
-
1 EJBs 确保类默认具有事务感知性。
-
2 注入 EntityManager 以进行数据库操作
-
3 创建一个新的游戏
当您使用 Spring 时,通常会编写仓库以使用 Spring Data 项目。这为数据访问提供了熟悉、一致的基于 Spring 的编程模型,无论是访问关系型和非关系型数据存储,还是映射-减少框架和基于云的数据服务。一个利用 Spring Data 的仓库示例可以在视频服务代码中找到,用于访问 Redis。
数据映射和对象关系映射
几乎所有微服务都需要将某种类型的数据持久化到持久存储中。在 Java EE 中,当持久化后端是 SQL 数据库时,通过对象关系映射(ORM)工具使用 JPA 规范。ORM是将面向对象编程(OOP)语言的类转换为关系数据库系统(RDBSs)的关系表的技术。
提示
一些供应商还提供对象映射到 NoSQL 数据库,但此功能不在规范中。
一个展示此功能的 JPA 数据映射示例可以在游戏服务中找到(code/game/src/main/java/book/games/entity/Game.java)。
列表 2.8. 数据映射
@Entity
public class Game implements Serializable {
@Id
@Column(name = "id", updatable = false, nullable = false)
private Long id;
@Version
@Column(name = "version")
private int version;
@Column *1*
private String title;
@Column
private String cover;
@ElementCollection
@CollectionTable(name = "ReleaseDate", joinColumns =
@JoinColumn(name = "OwnerId"))
private List<ReleaseDate>; releaseDates = new ArrayList<>;();
@ElementCollection
@CollectionTable(name = "Publisher", joinColumns = @JoinColumn
(name = "OwnerId"))
private List<String>; publishers = new ArrayList<>;();
@ElementCollection
@CollectionTable(name = "Developer", joinColumns = @JoinColumn
(name = "OwnerId"))
private List<String>; developers = new ArrayList<>;();
}
- 1 对象属性映射到关系表元素。
网关和 HTTP 客户端
当一个服务与一个或多个微服务协作时,必须实现逻辑来与这些外部服务进行通信。一个网关封装了连接到远程服务的所有逻辑,并负责底层的协议以及将对象从领域对象到和从领域对象进行序列化和反序列化。REST 架构通常使用 RESTful-web-services 方法,因此网关通常会使用 HTTP 客户端来连接到外部服务。在 Java EE 中,JAX-RS 规范提供了用于消费 RESTful web 服务的客户端类。
Spring 提供了一个简单但功能强大的类RestTemplate,它提供了消费其他 REST 服务的各种方法。一个与另一个微服务通信的网关示例可以在聚合器服务中找到(code/aggregator/src/main/java/book/aggr/GamesGateway.java)。
列表 2.9. 网关
public class GamesGateway {
private final Client client;
private final WebTarget games;
private final String gamesHost;
public GamesGateway() {
this.client = ClientBuilder.newClient();
this.gamesHost = Optional.ofNullable(System.getenv
("GAMES_SERVICE_URL")).orElse(Optional.ofNullable
(System.getProperty("GAMES_SERVICE_URL")).orElse
("http://localhost:8181/"));
this.games = this.client.target(gamesHost); *1*
}
public Future<JsonObject>; getGameFromGamesService(final long
gameId) {
return this.games.path("{gameId}").resolveTemplate
("gameId", gameId) *2*
.register(JsonStructureBodyReader.class) *3*
.request(MediaType.APPLICATION_JSON).async() *4*
.get(JsonObject.class);
}
}
-
1 创建到指定服务器的客户端连接
-
2 定义端点 URL
-
3 注册反序列化器
-
4 通常,聚合器希望以异步模式执行调用。
在本节中,我们向您介绍了微服务的层次结构。现在让我们看看如何将这些元素引入 Java 空间。
2.4.2. ECB 模式
为 Gamer 应用程序开发的每个微服务都遵循实体控制边界(ECB)模式。ECB 是众所周知的 MVC 模式的变体,但与 MVC 不同,它不仅负责处理用户界面,还负责没有 UI 的应用程序(在我们的案例中,微服务)。
ECB 模式由三个元素(或关键视角)组成:实体、控制和边界。微服务的每个元素都可以组装成这三个视角之一:
-
实体—表示领域模型的对象。它主要包含领域所需的数据(属性),同时也执行与实体相关的行为操作,例如验证数据和执行业务操作。
-
控制—作为边界和实体之间中介的对象。它管理场景的端到端行为。
-
边界—位于系统边界的对象。一些边界对象可能负责系统的前端:例如,REST 端点。其他可能负责后端,管理与其他外部元素(如数据库或其他服务)的通信,例如。
图 2.11 展示了这些元素是如何组合在一起的。
图 2.11. ECB 模式

这三个元素可以有一定的适当交互;其他交互不应发生。实体、控制和边界对象之间的关系可以总结如下:
-
元素可以与其他相同类型的元素通信。
-
控制可以与实体和边界元素通信。
-
边界和实体元素不应直接通信。
表 2.8 说明了这些关系。
表 2.8. 实体、控制和边界对象之间的通信
| 实体 | 边界 | 控制 | |
|---|---|---|---|
| 实体 | X | X | |
| 边界 | X | X | |
| 控制 | X | X | X |
您可以看到,ECB 模式非常适合微服务的结构。例如,将 ECB 模式应用于 Gamer 微服务可能看起来像这样:
-
资源、仓库和网关可能被放置在边界中。
-
服务层可能被放置在控制中。
-
领域(以及当提供多个对象时ORM)可能被放置在实体中。
图 2.12 中的架构图展示了微服务中的每个元素是如何映射到 ECB 模式的。
图 2.12. 将 ECB 模式应用于 Gamer 微服务的示例

2.4.3. 其他模式
到目前为止,您已经看到了如何将资源、数据映射器和网关等模式应用于微服务架构,以及这些模式如何适应 ECB 模式。Gamer 微服务还使用了其他值得注意的模式。
聚合模式
聚合模式在微服务架构中使用,但这在软件开发中并不是什么新事物:它来自企业集成模式目录。这种模式的目标是在几个服务的响应之间充当聚合器。一旦收到所有响应,聚合器将它们关联起来,并将单个响应发送回客户端进行处理,如图 2.13 所示 figure 2.13。
图 2.13. 聚合模式

这种模式的例子可以在游戏聚合服务(code/aggregator/src/main/java/book/aggr/GamersResource.java)中找到。正如其名称所暗示的,它负责聚合游戏玩家可能想要看到的所有信息:游戏数据和评论。
列表 2.10. 聚合模式
@Inject
private GamesGateway gamesGateway; *1*
@Inject
private CommentsGateway commentsGateway;
private final Executor executor = Executors.newFixedThreadPool(8);
@GET
@Path("{gameId}")
@Produces(MediaType.APPLICATION_JSON)
public void getGameInfo(@Suspended final AsyncResponse
asyncResponse, @PathParam
("gameId") final long gameId) {
asyncResponse.setTimeoutHandler(ar ->; ar.resume(Response
.status(Response.Status.SERVICE_UNAVAILABLE).entity
("TIME OUT !").build()));
asyncResponse.setTimeout(15, TimeUnit.SECONDS);
final CompletableFuture<JsonObject>; gamesGatewayFuture =
Futures.toCompletable(gamesGateway
.getGameFromGamesService(gameId), executor);
final CompletableFuture<JsonObject>; commentsGatewayFuture =
Futures.toCompletable(commentsGateway
.getCommentsFromCommentsService(gameId),
executor);
gamesGatewayFuture.thenCombine(commentsGatewayFuture, *2*
(g, c) ->; Json.createObjectBuilder() *3*
.add("game", g).add("comments", c).build())
.thenApply(info ->; asyncResponse.resume(Response.ok
(info).build()) *4*
).exceptionally(asyncResponse::resume);
}
-
1 网关模式与另一个服务进行通信。
-
2 游戏和评论异步检索。
-
3 当可用时,将两个响应合并。
-
4 响应组合完成后,将其发送回调用者。
客户端连接作为环境变量
建议通过环境变量配置客户端连接的 URL,并提供默认/回退值。虽然这不能算作一种模式,但在开发微服务时,这是一个好的实践。
根据我们的经验,配置客户端连接的最佳方式是使用环境变量,因为它们定义简单,并且被本地操作系统、构建工具和系统配置脚本所支持。您无需重新发明轮子。
任何简化运行时配置管理的东西都将是有用的。而不是在项目中分散多个配置文件,每个环境都有不同的硬编码值,您可以使用一个在构建时从环境生成的配置。例如,当使用 Docker 时,您有一个 docker-composition 文件。这可以通过使用构建环境属性从资源生成,以设置组合中的运行时环境值。以其他方式做这件事意味着系统配置脚本必须负责将正确的文件,带有正确的值,复制到正确的位置。
您可以将这种方法进一步扩展,并添加对 Java 系统属性的回退支持(或反之亦然)。Java 系统属性可以通过命令行使用-D选项设置:例如,-DmyVey=myValue。这意味着当涉及到配置微服务时,DevOps 团队都可以使用这两种选项。使用这种方法,在部署期间可以完全自由地调整微服务配置。
这种例子在其他地方也有出现,例如聚合服务(code/aggregator/src/main/java/book/aggr/CommentsGateway.java)。此服务需要部署的游戏和评论服务的 URL。
列表 2.11. 环境变量
String commentsHost = Optional.ofNullable(System.getenv
(COMMENTS_SERVICE_URL)) *1*
.orElse(Optional.ofNullable(System.getProperty
("COMMENTS_SERVICE_URL")).orElse
("http://localhost:8282/comments-service/"));
- 1 使用 Optional 类设置属性优先级
2.5. 设计决策
本书是关于如何为微服务架构编写测试。因此,我们简化了许多示例,以便它们尽可能易于阅读。这些简化意味着我们没有遵循编写微服务的最佳实践:
-
在某些情况下(这些情况已被识别和解释),我们尽可能包含尽可能多的不同技术,以展示一个原则。因此,我们并不总是使用最适合当前任务的最佳技术。
-
一些层,如 API 网关、负载均衡和缓存,已被移除,因为它们对测试特定范围没有提供任何帮助。
我们强烈建议您遵循可用的最佳实践实施微服务架构。当我们跳过一个主题时,我们会提供相关的笔记、见解和进一步阅读的链接。
摘要
-
我们已经有序地展示了本书示例应用程序的业务领域细节,现在你应该理解了应用程序的目标。
-
我们展示了应用程序的技术细节,包括它使用的容器和数据库;我们做出这些选择是为了展示更广泛的解决方案范围。我们还展示了应用程序代码的片段,在这些片段中,理解为什么在编写特定测试时使用特定方法是很重要的。
-
示例应用程序中关于微服务模式的介绍是基础的。随着我们开发各种测试技术和策略,我们将在以下章节中扩展这些模式。
第三章. 單元測試微服務
本章涵蓋
-
在微服務架構中應用單元測試技術
-
確定哪些工具最適合编写單元測試
-
理解獨立與社交單元測試
-
编写可讀、健壯的單元測試
到目前为止,你已经熟悉了开发微服务时使用的基礎架構、解剖結構以及典型模式。你也已經被介紹了 Gamer 應用程序,這是整本書中使用的參考應用程序,我們將為其编写一些測試。在本章中,你將學習如何為基於微服務的架構编写單元測試。
3.1. 單元測試技術
如你所知,單元測試是一種測試技術,測試應用程序的代碼應該是最小的可能片段,以產生所需的行為。單元測試的另一個主要特點是它應該盡可能快地執行,以便在對代碼庫進行任何添加或重构後,開發者都能獲得快速的反饋。
重要的是要記住,單元測試不僅是一種測試技術——當你使用測試驅動開發(TDD)方法時,單元測試也是一個強大的設計工具,我們非常推薦這種方法。
提示
编写單元測試應該是一個簡單的任務。TDD 幫助設計可測試的類別;如果你在编写單元測試時遇到困難,那麼問題可能不在單元測試,而在被測試的代碼中。在這種情況下,這通常是一個標誌,表明你的代碼設計應該以某種方式進行更改。
單元測試應該涵蓋的範圍在單元測試的歷史中已經被廣泛討論,答案可能會根據語言范式而有所不同。例如,在過程式語言的情況下,單元測試通常涵蓋一個單個函數或過程。對於面向對象的語言,單元測試通常涵蓋一個類別。但這裡暴露的範圍僅僅是指示性的。根據項目、團隊、測試的代碼和架構,你可能會決定擴大或縮小單元測試的範圍,在一個測試中涉及更多的或更少的業務類別。始終,這是需要整個團隊研究、討論並達成共識的事情。
警告
因為 Java 是一種面向對象的語言,我們在測試的單元(一個通用術語)和測試的類別之間建立了一個等價關係,這是面向對象語言的具體測試單元。
自然地,单元测试将根据要测试的代码是否与其依赖隔离而有所不同——只测试单元测试本身,不考虑任何协作者——或者将单元测试的协作者作为测试范围的一部分。Jay Fields,《与单元测试有效工作》(Leanpub,2015)的作者,为这两种编写单元测试的方法提供了名称。与依赖协作的测试被称为社交单元测试,而独立风格则恰当地命名为独立单元测试。
3.1.1. 社交单元测试
社交单元测试通过考虑状态变化来关注测试类的行为及其依赖和协作者。显然,当使用这种方法时,你将单元测试视为一个外观接口,其中提供了一些输入。之后,单元测试使用其协作者来计算输出,最后,通过断言验证输出。
使用这类测试,你构建了一个黑盒环境,因为所有测试都是通过要测试的类进行的。这种方法是测试属于业务域组件的类的一个很好的方式,在这些类中,通常暴露计算和状态转换。
因为业务域对象是现实世界概念的表示,它们包含模型的数据和改变模型状态的行为。域对象是基于状态的,以独立方式测试它们没有实际价值。因此,社交单元测试最适合测试业务域逻辑。
图 3.1 说明了社交单元测试的构成。你可以看到测试类和要测试的类都包含在测试范围内。与独立测试一样,要测试的类的依赖(和协作者)在范围之外。社交测试与使用测试替身进行的独立测试的主要区别在于,依赖(和协作者)是真实类。
图 3.1. 社交单元测试

3.1.2. 测试替身
正如你在 3.1 节中看到的,关于单元测试应该覆盖的范围有两种方法。第一种方法是独立单元测试,其中要测试的类与其所有依赖和协作者隔离,第二种方法是社交单元测试,其中要测试的类涉及其协作者。
可能你已经开始思考以下问题:“如果我要测试的类有一个协作者,而我想要使用独立的单元测试方法来编写单元测试,我该如何隔离要测试的类?”
如果你什么都不做,那么测试可能最多会在NullPointerException上完成,因为没有可用的协作者实例进行测试。因此,对这个问题的答案是使用测试替身。
测试替身是在用简化类双用来替换生产对象(通常是正在测试的类的协作者)进行测试时使用的通用术语。一般来说,根据 Gerard Meszaros 在《xUnit Test Patterns: Refactoring Test Code》(Addison-Wesley,2007)中确定的,有五种测试替身类型:
-
哑元—哑元对象被传递但从未使用。它只是用来填充所需的方法参数。
-
伪造—伪造对象是协作者的实现,但它采取了一些捷径以提高性能。通常,伪造对象不适合生产。一个众所周知的例子是内存数据库。
-
存根—存根在测试期间为调用提供预定义的答案。
-
模拟—模拟是一个具有预编程期望的对象。期望可以是离散值或异常。与存根不同,模拟执行运行时行为验证。不存储任何状态。
-
间谍—间谍对象是在对其调用期间记录信息的存根。例如,一个电子邮件网关存根可能能够返回它“发送”的所有消息。
通常,测试替身是可预测的(这对于模拟来说尤其如此)。这意味着使用测试替身可以使你的单元测试更加健壮,因为协作者在每次执行中都会以完全相同的方式表现。使用测试替身时的执行速度也是一个很大的优势。它们不是真实实现,所以性能良好。这使得你的测试套件性能良好,同时也为开发者提供了快速的反馈。
模拟与存根(以及间谍)
根据我们的经验,大多数情况可以使用模拟或间谍进行测试;唯一的区别是你在测试中提出的断言以及这个断言给你带来的信心水平。
让我们用一个具体的例子来说明。假设你想测试一个类,其中一个协作者用于发送电子邮件。这是你如何间谍协作者的方法:
public interface MailService {
void sendEmail(Message msg);
}
public class MailServiceStub implements MailService {
List<Message> messages = new ArrayList<>();
void sendEmail(Message msg) {
messages.add(msg);
}
boolean hasSentMessage(Message msg) {
return messages.contains(msg);
}
}
现在,你可以使用之前的类来编写测试:
Registration r = new Registration();
MailServiceStub mss = new MailServiceStub();
r.setMailService(mss);
User u = ....
r.register(u);
Message expectedEmailMessage = ...;
assertThat(mss.hasSentMessage(expectedEmailMessage),
is(true));
assertThat(mss.numberOfSentMessage(), is(1));
注意,这个测试验证了间谍的状态:在这种情况下,预期的消息已被发送。
以类似的方式,模拟协作者使用一个看起来像这样的测试:
Registration r = new Registration();
MailService mss = mock(MailService.class);
r.setMailService(mss);
User u = ....
r.register(u);
Message expectedEmailMessage = ...;
verify(mss).sendEmail(expectedEmailMessage);
注意,最大的区别是,这个测试只验证了交互,因此它执行行为验证。另一个很大的区别是,测试依赖于协作方法调用。同时,间谍和存根的情况需要额外的方法来帮助验证状态变化。
如前所述,选择存根/间谍和模拟取决于你的单元需要哪种类型的测试。如果你需要状态验证或协作者的自定义测试实现,那么间谍是最好的选择。另一方面,如果你只需要行为验证而没有任何自定义实现,那么模拟通常是最好的选择。
根据我们的经验和在存在疑问的情况下,最好的开始方式是使用模拟。这可以更快地开始,你只需要记录期望,通常它涵盖了要测试的使用案例。
3.1.3. 独立单元测试
独立单元测试专注于以隔离和受控的方式测试单元或工作。单元的协作者被测试替身或模拟所取代。
这种方法是一个很好的方法来避免与被测试类无关的测试失败,但与它的某个依赖项有关。这通常远远超出了测试的范围。
此外,独立单元测试在以下情况下也很有用,即被测试的类有一个需要与网络、数据库、电子邮件服务或甚至另一个微服务建立物理连接的依赖项。在这些情况下使用真实依赖项是两方面的坏处:
-
对 I/O 的访问是一个相对较慢的操作,单元测试应该是快速的。
-
远程服务随时可能失败(由于服务器故障、网络故障、防火墙规则等),这意味着测试可能因为除了代码错误以外的其他原因而失败。
因此,你应该努力在以下场景中使用独立单元测试:
-
当被测试单元的协作者运行缓慢——这可能是由于 I/O 访问,但也可能是由于长时间运行的计算。
-
当被测试单元的协作者包含可能经常变化的逻辑——在这种情况下,测试可能失败并不是因为被测试的类,而是因为其中一个协作者失败了。
-
当你想要测试那些使用真实实例难以测试的边缘情况——例如,磁盘满的测试用例。
图 3.2 展示了独立单元测试的样子。你可以看到测试类和被测试单元在测试范围内。被测试类的依赖项(和协作者)在范围之外,并且它们不是真正的类——它们被测试替身所取代。
图 3.2. 独立单元测试

3.1.4. 微服务中的单元测试
我们已经介绍了单元测试是什么,你可能采取的不同方法——例如独立或社交——以及测试替身以及它们如何帮助你编写更好的单元测试。在本节中,你将看到如何将单元测试概念应用于微服务架构。在我们查看具体示例之前,让我们回顾一下微服务的结构。
资源和服务组件层
资源和服务通常包含网关、领域对象和存储库之间的协调代码,以及模块之间的消息转换。我们强烈建议为资源和服务使用独立的单元测试。原因是资源和服务的协作者通常反应较慢。这会影响测试的鲁棒性(例如,网络可能中断,导致假阴性)。在网关的情况下,它们通常与部署在另一台服务器上的服务进行通信,这意味着又要触及网络。使用存储库的情况也类似,因为磁盘 I/O 几乎与网络流量相似。因此,在处理资源和服务时,使用测试替身,更具体地说,使用模拟对象,是最佳选择。
小贴士
有时,资源和服务充当着门面,在协作者之间传递消息。在这种情况下,单元测试可能不会带来回报——其他测试级别,如组件测试,可能更有价值。
网关组件
网关组件层包含连接到外部服务的逻辑,通常使用 HTTP/S 客户端。在这种情况下,使用模拟或存根进行独立的单元测试是避免在单元测试中触及网络的最佳方式。
在这一层要测试的项目如下:
-
在底层协议和业务对象之间的映射逻辑
-
强制错误条件,这些条件可能难以使用真实服务进行模拟(负面测试)
领域组件
我们在第 3.1.1 节中讨论了如何测试领域层。领域逻辑暴露了计算和状态转换。由于领域对象基于状态,以独立方式测试它们没有价值。因此,社交单元测试是测试领域逻辑的最佳策略。
存储库组件
存储库通常通过连接到数据库、执行查询并将输出调整为领域对象来充当持久层的“网关”。存储库也可以是属性文件,或 Elasticsearch 等。列表无穷无尽。
持久层通常使用 ORM(如 JPA)实现,因此在大多数情况下,这一层仅处理系统的核心类。(在 JPA 的情况下,这是EntityManager。)如果你决定为持久化对象编写单元测试不会带来回报,那么至少为它们编写集成测试。但如果某些逻辑包含在映射响应中,那么对核心类(如EntityManager)进行独立的单元测试和模拟应该足够提供映射对象。
独立还是社交?
表 3.1 总结了何时使用独立方法以及何时使用社交方法。
表 3.1. 独立与社交单元测试的比较
| 组件 | 独立 | 社交 |
|---|---|---|
| 资源 | X | |
| 服务 | X | |
| 网关 | X | |
| 领域 | X | |
| 仓库 | X |
3.2. 工具
我们已经介绍了如何将单元测试风格与微服务架构的各个组件最佳匹配。现在,让我们看看用于编写单元测试的工具。有许多这样的工具可用,但据我们观察,以下是目前最广泛采用并被 Java 社区接受的。
3.2.1. JUnit
JUnit 是一个 Java 单元测试框架。目前,它被其他工具扩展,用于编写不同级别的测试,如集成和组件测试。
JUnit 测试是一个带有特殊注解的 Java 对象,用于标记方法为测试。旧版本提供了可以扩展的对象,但注解方法更灵活。
下面的代码片段展示了典型的 JUnit 测试的结构:
public class MyTest {
@BeforeClass *1*
public static void setUpClass() throws Exception {
}
@Before *2*
public void setUp() throws Exception {
}
@Test *3*
public void testMethod() {
assertEquals("Hello World", message); *4*
assertTrue (17 < age);
}
@After *5*
public void tearDown() throws Exception {
}
@AfterClass *6*
public static void tearDownClass() throws Exception {
}
}
-
1 该方法在类中定义的测试之前执行。
-
2 该方法在类中的每个测试之前执行。
-
3 将方法标记为测试方法
-
4 JUnit 提供了断言结果的方法。
-
5 该方法在类中的每个测试之后执行。
-
6 该方法在类中所有测试执行之后执行。
3.2.2. AssertJ
几个重要的特性对于编写好的单元测试至关重要。单元测试应该是快速和隔离的;我们之前已经讨论过这一点。但同样重要的是,它们应该是可读的。
任何阅读测试的人都应该能够快速识别测试的内容以及预期的结果。前一个节中的代码片段使用了assertEquals和assertTrue这样的语句——这些都是清晰的表述。
使用这些语句是有效的,但它们在可读性方面提出了挑战。让我们探讨一些这些问题:
-
你不会立即知道哪个参数是预期值,哪个是结果值。假设
assertEquals(val1, val2),第一个参数是预期值还是计算值?它是预期值。这很重要,因为在失败的情况下,错误信息是使用这些参数构建的。 -
断言简单的条件可能难以阅读和编写。
assertTrue (17 < age)看起来不太自然。 -
通常,方法返回更复杂的对象或对象列表。请注意,
assertTrue(games.contains(zelda))有两个问题。首先,断言的可读性不高;其次,如果zelda对象没有实现equals,或者以对断言逻辑有害的方式实现它,会发生什么?断言可能变成了假设。
为了避免所有这些问题,开发了 AssertJ (joel-costigliola.github.io/assertj),以便开发者在测试中使用流畅的断言。AssertJ 在提供丰富的断言集合方面表现出色,这些断言有助于编写复杂元素的假设,同时提高可读性。
你需要使用的静态导入是 import static org.assertj.core.api.Assertions.*。如果你愿意,你仍然可以使用 assertEquals 和 assertTrue,但让我们比较一下使用 AssertJ 和之前的例子。
你可以不用写 assertEquals(val1, val2),而是使用 AssertJ 写成 assertThat(val1).isEqualTo(val2)。注意,现在断言对读者来说是明确的,而且关于预期值和实际值应该是什么没有歧义。
你可以使用 assertTrue (17 < age),但用 AssertJ 重新编写它看起来像 assertThat(age).isGreaterThan(17)。你以为我们用了糟糕的例子!
最后,AssertJ 可以帮助进行复杂的验证。你不用使用 assertTrue(games.contains(zelda)),这依赖于 equals 方法,而是可以用 AssertJ 重新编写成类似 assertThat(games).extracting ("name").contains("Zelda") 的形式。这没有 equals 方法的任何歧义,同时,它也提高了可读性。
注意
你可能会想知道 AssertJ 和 Hamcrest(JUnit 的一部分)之间的区别是什么。这两个项目都是为了同一个目的而创建的:提高测试的可读性。但它们之间最重要的区别是,虽然 AssertJ 使用流畅的断言,使得在 IDE 中编写断言变得容易,而在 Hamcrest 中,你编写的断言就像洋葱的层一样,一层套一层,这在 IDE 中阅读和编写起来不太自然。
你可能会同意,在测试中使用 AssertJ 不仅是一种提高可读性的好方法,而且还可以避免在测试中编写繁琐的代码。我们将使用所有这些,并将选择权留给你自己,决定在你的代码中更喜欢哪一个。
3.2.3. Mockito
在 第 3.1.2 节 中,你看到了用测试对象替换生产对象的几种不同策略。有五种类型的测试双胞胎:哑元、伪造、存根、间谍和模拟。
在单元测试中,间谍和模拟是最常用的。间谍通常实现为一个带有自定义代码的接口。对于模拟,你需要一个框架,让你可以记录调用时的预设答案。
在 Java 中,有几个模拟框架,例如 JMockit、EasyMock 和 Mockito。在这本书中,我们将关注的模拟框架是 Mockito (site.mockito.org)——使用多个可能会让人困惑。根据对 30,000 多个 Java 项目的分析,Mockito 是 Java 项目中第四常用的库,因此我们可以将其视为 Java 中事实上的模拟框架。这是一个快速发展的项目,将超过本书的内容,所以请确保在线关注该项目。但请随意分析和选择你偏好的模拟框架。
Mockito 是一个用 Java 编写的模拟框架,它允许使用干净、简单的 API 创建测试双倍(模拟对象)。此外,Mockito 与其他模拟框架区分开来的一个特性是,你可以在不事先定义期望的情况下验证单元测试的行为,这使得测试更加简单,并减少了测试代码与单元测试之间的耦合。
以下片段展示了 Mockito 测试的典型结构:
import static org.mockito.Mockito.*;
List mockedList = mock(List.class); *1*
mockedList.add("one"); *2*
mockedList.clear();
verify(mockedList).add("one"); *3*
verify(mockedList).clear();
List mockedList2 = mock(List.class);
when(mockedList2.get(0)).thenReturn("first"); *4*
System.out.println(mockedList2.get(0)); *5*
-
1 创建一个代理来模拟你想要模拟的接口(或类)
-
2 模拟对象的调用方式与任何其他方法相同。
-
3 验证在测试执行期间是否产生了预期的交互
-
4 当此方法使用给定参数调用时产生答案
-
5 返回“first”作为 canned response
该片段仅展示了 Mockito 的基本用法;更多功能将在书中根据需要解释。
现在你已经了解了单元测试工具,让我们看看你需要做什么才能开始使用它们。
3.2.4. 构建脚本修改
为了使用 JUnit、AssertJ 或 Mockito 这样的测试框架,你需要在构建脚本中将它定义为测试依赖项。在 Maven 的情况下,这个信息位于 pom.xml 中的 dependencies 块内:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope> *1*
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.5.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>2.8.47</version>
<scope>test</scope>
</dependency>
- 1 在测试范围内定义一个依赖项
警告
Mockito 之前错误地提供了一个与 JUnit 一起提供的 Hamcrest 库版本,这导致了冲突。尽管在 Mockito 2 中已经修复了这个问题,但请注意,较旧的项目可能需要升级。
在构建脚本中注册依赖项后,你可以开始编写单元测试。
3.3. 为 Gamer 应用编写单元测试
在讨论了单元测试是什么,可以使用哪些工具,以及你可以遵循的每个微服务层的策略,并在项目中注册这些工具之后,现在是时候开始编写单元测试了。
通常,单元测试是在名为 src/test/java 的既定测试目录下创建的。我们建议你坚持这个模式。
3.3.1. YouTubeVideoLinkCreator 测试
让我们看看如何编写 YouTubeVideoLinkCreator 类的单元测试(code/video/src/main/java/book/video/controller/YouTubeVideoLinkCreator.java)。这是一个简单的 controller 类,它不依赖于任何其他类,因此不需要任何测试双倍。这个类负责创建 YouTube 视频 ID 的嵌入 URL。
列表 3.1. YouTubeVideoLinkCreator 类
public class YouTubeVideoLinkCreator {
private static final String EMBED_URL = "https://www.youtube" +
".com/embed/";
public URL createEmbeddedUrl(final String videoId) {
try {
return URI.create(EMBED_URL + videoId).toURL();
} catch (final MalformedURLException e) {
throw new IllegalArgumentException(e);
}
}
}
对于这个类,只需要一个测试方法来测试 URL 的创建是否正确(code/video/src/test/java/book/video/controller/YouTubeVideoLink CreatorTest.java)。
列表 3.2. YouTubeVideoLinkCreator 的单元测试
public class YouTubeVideoLinkCreatorTest {
@Test *1*
public void shouldReturnYouTubeEmbeddedUrlForGivenVideoId() { *2*
final YouTubeVideoLinkCreator youTubeVideoLinkCreator = new
YouTubeVideoLinkCreator(); *3*
final URL embeddedUrl = youTubeVideoLinkCreator
.createEmbeddedUrl("1234"); *4*
assertThat(embeddedUrl).hasHost("www.youtube.com").hasPath
("/embed/1234"); *5*
}
}
-
1 测试方法必须使用 @Test 注解。
-
2 描述性测试方法名称:直接称呼即可。
-
3 测试中的类
-
4 调用 createEmbeddedUrl
-
5 断言 YouTube 链接是有效的
你可以看到测试方法被注解为@Test,并且它使用众所周知的 Given-When-Then 结构进行结构化,这本质上将测试用例分为三个部分。Given是测试的前提条件,When是测试的刺激,Then描述了测试的期望。
提示
注意,当前尚未测试捕获代码(IllegalArgumentException)。它提供的反馈不值得测试的努力。
3.3.2. YouTubeLink 测试
让我们为YouTubeLink类编写一个单元测试(code/video/src/main/java/book/video/entity/YoutubeLink.java)。这个类是一个域对象,它有一个协作者(YouTubeVideoLinkCreator),该协作者使用此域对象实现逻辑。
列表 3.3. YouTubeLink类
public class YouTubeLink {
private final String videoId;
Function<String, URL> youTubeVideoLinkCreator; *1*
public YouTubeLink(final String videoId) {
this.videoId = videoId;
}
public void setYouTubeVideoLinkCreator(final Function<String,
URL> youTubeVideoLinkCreator) {
this.youTubeVideoLinkCreator = youTubeVideoLinkCreator;
}
public URL getEmbedUrl() {
if (youTubeVideoLinkCreator != null) {
return youTubeVideoLinkCreator.apply(this.videoId);
} else {
throw new IllegalStateException
("YouTubeVideoLinkCreator not set");
}
}
public String getVideoId() {
return videoId;
}
}
- 1 行为逻辑实现为 Java 8 功能接口。
注意,编写此测试有两种选择:
-
一种独立测试方法,即模拟
YouTubeVideoLinkCreator类 -
一种社交测试方法,即使用真实的
YouTubeVideoLinkCreator类
这个案例很典型,因为它不值得嘲笑依赖,使用真实的依赖项更好。
列表 3.4. YouTubeLink的单元测试
public class YouTubeLinkTest {
@Test
public void shouldCalculateEmbedYouTubeLink() {
final YouTubeLink youtubeLink = new YouTubeLink("1234");
final YouTubeVideoLinkCreator youTubeVideoLinkCreator = new
YouTubeVideoLinkCreator(); *1*
youtubeLink.setYouTubeVideoLinkCreator
(youTubeVideoLinkCreator::createEmbeddedUrl); *2*
assertThat(youtubeLink.getEmbedUrl()).hasHost("www.youtube" +
".com").hasPath("/embed/1234");
}
}
-
1 创建真实依赖
-
2 注入逻辑
这个单元测试看起来和上一个一样。但由于它是一个社交测试,创建了两个实例:一个用于测试类,另一个作为它的依赖。
注意
在 YouTube 链接案例中,测试看起来很简单,你可能想知道是否值得编写它:毕竟,YouTubeVideoLinkCreator已经在自己的测试中进行了测试。这里测试的不是为视频创建 YouTube 嵌入链接的能力,而是YouTubeLinkTest的域对象能够使用自己的数据生成正确的 YouTube 嵌入链接。
3.3.3. 游戏测试
到目前为止,我们编写了不需要模拟任何依赖的单元测试。让我们为仓库元素编写一个单元测试。在这种情况下,我们将使用Games类(code/game/src/main/java/book/games/boundary/Games.java)。
列表 3.5. Games类
@Stateless *1*
public class Games {
@PersistenceContext *2*
EntityManager em;
public Long create(final Game request) {
final Game game = em.merge(request); *3*
return game.getId();
}
public Optional<Game> findGameById(final Long gameId) {
Optional<Game> g = Optional.ofNullable(em.find(Game.class,
gameId));
if (g.isPresent()) {
Game game = g.get();
game.getReleaseDates().size();
game.getPublishers().size();
game.getDevelopers().size();
em.detach(game);
}
return g;
}
}
-
1 EJB 默认使类具有事务感知性。
-
2 注入 EntityManager 以进行数据库操作。
-
3 创建一个新的游戏
单元测试中最重要的事情之一是保持良好的执行速度。因为这个仓库是用 JPA 实现的,所以你需要模拟这部分。
列表 3.6. Games的单元测试
@RunWith(MockitoJUnitRunner.class) *1*
public class GamesTest {
private static final long GAME_ID = 123L;
@Mock *2*
EntityManager entityManager;
@Test
public void shouldCreateAGame() {
final Game game = new Game();
game.setId(GAME_ID);
game.setTitle("Zelda");
final Games games = new Games();
when(entityManager.merge(game)).thenReturn(game); *3*
games.em = entityManager; *4*
games.create(game); *5*
verify(entityManager).merge(game); *6*
}
}
-
1 Mockito 运行器初始化@Mock 字段。
-
2 将字段标记为模拟
-
3 当调用模拟时记录答案
-
4 设置模拟依赖
-
5 对测试类中的类的正常调用
-
6 验证 merge 方法是否以预期的游戏调用
这个测试与之前的测试略有不同。首先要注意的是,它使用了一个自定义的 JUnit 运行器。这个运行器由 Mockito 框架提供,负责初始化所有带有@Mock注解的字段,以及其他一些功能。你可以使用 Mockito 而不使用 JUnit 运行器,就像在 Mockito 部分的介绍中所示;但据我们意见,使用运行器时单元测试看起来更清晰。
第二个区别是,现在在测试方法中,为EntityManager类记录了一些预设答案。最后,除了断言预期值外,测试还验证了被模拟的方法也被调用。这很重要,这样你可以确信正在测试的类调用了预期的依赖方法,而不是与测试无关的其他方法。
对于其他方法也可以做类似的事情。
列表 3.7. GamesTest.java 中的附加测试方法
@Test
public void shouldFindAGameById() {
final Game game = new Game();
game.setId(GAME_ID);
game.setTitle("Zelda");
final Games games = new Games();
when(entityManager.find(Game.class, GAME_ID)).thenReturn(game);
games.em = entityManager;
final Optional<Game> foundGame = games.findGameById(GAME_ID); *1*
verify(entityManager).find(Game.class, GAME_ID);
assertThat(foundGame).isNotNull().hasValue(game)
.usingFieldByFieldValueComparator(); *2*
}
@Test
public void shouldReturnAnEmptyOptionalIfElementNotFound() {
final Game game = new Game();
game.setId(GAME_ID);
game.setTitle("Zelda");
final Games games = new Games();
when(entityManager.find(Game.class, GAME_ID)).thenReturn(null);
games.em = entityManager;
final Optional<Game> foundGame = games.findGameById(GAME_ID);
verify(entityManager).find(Game.class, GAME_ID);
assertThat(foundGame).isNotPresent(); *3*
}
-
1 ID 必须与 when 函数中记录的相同。
-
2 验证 find 被调用,但也返回所需的 Optional
-
3 断言 Optional 没有值
小贴士
有时候编写单元测试对仓库来说并不划算,因为从单元测试的角度来看,它们只充当一个桥梁。在这种情况下,不编写单元测试,只为仓库层编写集成测试是值得的。
3.3.4. GamesService 测试
你已经看到了如何在简单场景中使用 JUnit、AssertJ 和 Mockito。现在,让我们为searchGameById(gameId)方法编写一个单元测试,这个方法稍微复杂一些(code/game/src/main/java/book/games/control/GamesService.java)。这个方法负责控制游戏信息是否存在于数据库系统中,或者是否需要从官方 IGDB 网站上检索。
列表 3.8. searchGameById(gameId)方法
@Dependent *1*
public class GamesService {
@EJB *2*
Games games;
@EJB
IgdbGateway igdbGateway;
public Game searchGameById(final long gameId) throws IOException {
final Optional<Game> foundGame = games.findGameById(gameId) *3*
if (isGameInSiteDatabase(foundGame)) {
return foundGame.get();
} else {
final JsonArray jsonByGameId = igdbGateway
.searchGameById(gameId); *4*
final Game game = Game.fromJson(jsonByGameId);
games.create(game);
return game;
}
}
}
-
1 将此类设置为 CDI 容器中依赖范围的合格类
-
2 其他元素也可以注入到服务中。
-
3 在数据库中查找游戏
-
4 如果找不到游戏,则从 IGDB 网站上获取它。
如果你仔细查看GamesService的逻辑,你会看到Game实体的convertToJson和fromJson方法被调用。再次,你可能想知道调用这些真实方法,还是模拟实体更有意义。
在我们的经验中,如果管理它们的行为并不复杂,那么最好的做法是使用真实实体(即社交测试方法)而不是模拟它们。需要注意的是,这个测试并不代替对实体对象的测试,因为它专注于测试GameService类而不是Game。正如你在上一节中看到的,Game类必须有自己的测试类,在那里你可以深入测试它。
让我们看看一个测试,验证如果您在内部数据库中有游戏数据,它将从那里检索,而不是通过访问 IGDB 网站(code/game/src/test/java/book/games/control/GamesServiceTest.java)。
列表 3.9. 游戏缓存单元测试
@RunWith(MockitoJUnitRunner.class)
public class GamesServiceTest {
@Mock
Games games;
@Mock
IgdbGateway igdbGateway;
@Test
public void shouldReturnGameIfItIsCachedInInternalDatabase()
throws IOException {
final Game game = new Game();
game.setId(123L);
game.setTitle("Zelda");
game.setCover("ZeldaCover");
when(games.findGameById(123L)).thenReturn(Optional.of(game));
final GamesService gamesService = new GamesService();
gamesService.games = games;
gamesService.igdbGateway = igdbGateway;
final Game foundGame = gamesService.searchGameById(123L);
assertThat(foundGame).isEqualToComparingFieldByField(game); *1*
verify(igdbGateway, times(0)).searchGameById(anyInt()); *2*
verify(games).findGameById(123L);
}
}
-
1 返回的游戏应该是记录在模拟对象中的那个。
-
2 验证没有与 IGDB 网站的交互
这个测试与之前的测试非常相似,但有一个简单而强大的变化。请注意,这个测试不仅验证了某些事情已经发生——在这个例子中,通过从数据库中调用搜索方法检索游戏——而且还验证了某些事情没有发生,在这个例子中是 IGDB 网关方法没有被调用。
以类似的方式,您可以测试其他情况:当游戏数据不在内部数据库中,需要从 IGDB 检索时。
列表 3.10. 游戏检索单元测试
@Test
public void
shouldReturnGameFromIgdbSiteIfGameIsNotInInternalDatabase()
throws IOException {
final JsonArray returnedGame = createTestJsonArray();
when(games.findGameById(123L)).thenReturn(Optional.empty());
when(igdbGateway.searchGameById(123L)).thenReturn
(returnedGame);
final GamesService gamesService = new GamesService();
gamesService.games = games;
gamesService.igdbGateway = igdbGateway;
final Game foundGame = gamesService.searchGameById(123L);
assertThat(foundGame.getTitle()).isEqualTo("Battlefield 4");
Assertions.assertThat(foundGame.getReleaseDates()) *1*
.hasSize(1).extracting("platformName",
"releaseDate").contains(tuple("PlayStation 3",
LocalDate.of(2013, 10, 29)));
assertThat(foundGame.getDevelopers()).hasSize(1).contains
("EA Digital Illusions CE");
assertThat(foundGame.getPublishers()).hasSize(1).contains
("Electronic Arts");
verify(games).create(anyObject()); *2*
verify(igdbGateway).searchGameById(123L); *3*
}
-
1 断言发布日期元组是正确的
-
2 验证游戏是否存储在内部数据库中
-
3 验证游戏是否从 IGDB 检索
但在某些情况下,事情可能会变得更加复杂。在下一个测试中,我们将引入ArgumentCaptor功能和异常测试。
3.3.5. GamesResource 测试
如您在之前的章节中看到的,Mockito 对于模拟测试单元的依赖项非常有帮助。但在异常场景和特殊验证情况等情况下,测试可能会更加复杂。
让我们为GamesResource编写一个单元测试(code/game/src/main/java/book/games/boundary/GamesResource.java)。记住,这个类是关于游戏操作的 REST 端点的定义。
列表 3.11. GamesResource 类
@Path("/")
@javax.ejb.Singleton *1*
@Lock(LockType.READ)
public class GamesResource {
@Inject
GamesService gamesService;
@Inject *2*
ExecutorServiceProducer managedExecutorService;
@GET
@Produces(MediaType.APPLICATION_JSON)
@javax.ejb.Asynchronous *3*
public void searchGames(@Suspended final AsyncResponse
response, *4*
@NotNull @QueryParam("query") final
String query) {
response.setTimeoutHandler(asyncResponse -> asyncResponse
.resume(Response.status(Response.Status
.SERVICE_UNAVAILABLE).entity("TIME OUT !")
.build()));
response.setTimeout(15, TimeUnit.SECONDS);
managedExecutorService.getManagedExecutorService().submit(
() -> { *5*
try {
final Collector<JsonObject, ?, JsonArrayBuilder>
jsonCollector = Collector.of
(Json::createArrayBuilder,
JsonArrayBuilder::add, (left,
right) -> {
left.add(right);
return left;
});
final List<SearchResult> searchResults =
gamesService.searchGames(query);
final JsonArrayBuilder mappedGames = searchResults
.stream().map(SearchResult::convertToJson)
.collect(jsonCollector);
final Response.ResponseBuilder ok = Response.ok
(mappedGames.build());
response.resume(ok.build()); *6*
} catch (final Throwable e) {
response.resume(e); *7*
}
});
}
}
-
1 资源被标记为单例 EJB,因此端点变为事务性
-
2 注入容器提供的执行器服务
-
3 @Asynchronous 将方法指定为异步。只有当它是 EJB 时才有效。
-
4 @Suspended 指示 JAX-RS 运行时该方法异步,并注入 AsyncResponse。
-
5 在另一个线程中执行逻辑
-
6 一旦结果准备就绪,连接将恢复。
-
7 在出现错误的情况下,通信也应恢复。
注意,这个待测试的类有一些与其他类略有不同的元素。第一个值得注意的事项是,该方法返回void。这并不意味着该方法不返回任何东西,而是它使用异步方法response.resume()返回响应。第二个不同之处在于,如果抛出异常,会有特殊处理。
为了解决第一个问题,你可以使用之前测试中看到的 verify 方法,但 verify 方法只是通过调用 equals 方法以自然风格检查参数的相等性。这是当你控制被验证的对象时匹配参数的推荐方式,因为你可以决定 equals 方法是否有变化。但是,当对象不在你的控制之下,比如 javax.ws.rs.core.Response,那么使用默认的 equals 方法可能不起作用,使用 ArgumentCaptor 是值得的,它允许你在验证后对某些参数进行断言,而不是使用 equals 实现来断言。
列表 3.12. 使用 ArgumentCaptor 的单元测试
@RunWith(MockitoJUnitRunner.class)
public class GamesResourceTest {
@Mock
GamesService gamesService;
@Mock
ExecutorServiceProducer executorServiceProducer;
@Mock
AsyncResponse asyncResponse; *1*
@Captor *2*
ArgumentCaptor<Response> argumentCaptorResponse;
private static final ExecutorService executorService =
Executors.newSingleThreadExecutor(); *3*
@Before
public void setupExecutorServiceProducer() {
when(executorServiceProducer.getManagedExecutorService())
.thenReturn(executorService);
}
@AfterClass *4*
public static void stopExecutorService() {
executorService.shutdown();
}
@Test
public void restAPIShouldSearchGamesByTheirNames() throws
IOException, InterruptedException {
final GamesResource gamesResource = new GamesResource();
gamesResource.managedExecutorService =
executorServiceProducer;
gamesResource.gamesService = gamesService;
when(gamesService.searchGames("zelda")).thenReturn
(getSearchResults());
gamesResource.searchGames(asyncResponse, "zelda");
executorService.awaitTermination(2, TimeUnit.SECONDS); *5*
verify(asyncResponse).resume(argumentCaptorResponse.capture
()); *6*
final Response response = argumentCaptorResponse.getValue()
; *7*
assertThat(response.getStatusInfo().getFamily()).isEqualTo
(Response.Status.Family.SUCCESSFUL);
assertThat((JsonArray) response.getEntity()).hasSize(2)
.containsExactlyInAnyOrder(Json.createObjectBuilder
().add("id", 1).add("name", "The Legend Of " +
"" + "Zelda").build(), Json
.createObjectBuilder().add("id", 2).add
("name", "Zelda II: The " +
"Adventure of Link").build()
);
}
}
-
1 创建一个模拟对象作为 JAX-RS 端点的参数
-
2 使用 @Captor 创建 ArgumentCaptor
-
3 创建一个单线程池
-
4 终止 ExecutorService
-
5 等待方法在分离的线程中执行
-
6 验证是否调用了
resume,并捕获调用期间使用的对象 -
7 获取捕获的对象,并使用预期值进行断言
这个测试比为你写的其他类的测试更复杂。首先要注意的是,它使用了 ArgumentCaptor。在这种情况下很有用,因为你只对响应的家族感兴趣。你不知道 equals 方法是如何实现的,以及它将来可能会如何改变,因此在测试中捕获和操作对象是最安全的方式。捕获器是通过使用 @Captor 注解而不是 @Mock 来初始化的。捕获器要求你设置将要捕获的数据类型:在这种情况下,是 javax.ws.rs.core.Response。
其次,这个案例使用了一个社交测试,通过使用“真实”的 java.util.concurrent.ExecutorService。模拟一个执行器服务意味着在测试中不执行任何逻辑,这将使测试变得无用。
你可以争论说,submit 方法中的逻辑可以添加到它自己的类中。在这种情况下,你可以编写一个与 ExecutorService 分离的单元测试。这种方法也是有效的,但事实是,随着 Java 8 中函数式接口的引入,示例中采用的方法是有效且被广泛接受的。
最后要注意的是 ArgumentCaptor 的生命周期,可以总结如下:
1. 捕获器被初始化,在这个测试中是通过使用
@Captor注解来实现的。2. 捕获在模拟方法中使用的参数值。在这个测试中,重要的值是传递给
AsyncResponse的resume方法的响应。为此,你需要验证该方法是否被调用,如果被调用,则捕获其参数。在测试中,这是通过调用verify(asyncResponse).resume(argumentCaptorResponse.capture())来实现的。3. 获取捕获器的值。在这个测试中,捕获了一个
Response对象。要获取它,你需要调用getValue方法。
以类似的方式,您可以测试异常场景。
JSON 处理 API 依赖
GamesResource 类使用了由应用服务器提供的 JSON 处理 API。由于单元测试不在应用服务器上运行,您需要提供一个 JSON 处理规范的实现。对于这个例子,因为您正在使用 WildFly Swarm,您将添加 WildFly 中使用的那个:
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>3.0.21.Final</version>
<scope>test</scope>
</dependency>
列表 3.13. 测试异常场景的单元测试
@Test
public void exceptionShouldBePropagatedToCaller() throws
IOException, InterruptedException {
final GamesResource gamesResource = new GamesResource();
gamesResource.managedExecutorService =
executorServiceProducer;
gamesResource.gamesService = gamesService;
when(gamesService.searchGames("zelda")).thenThrow
(IOException.class); *1*
gamesResource.searchGames(asyncResponse, "zelda");
executorService.awaitTermination(1, TimeUnit.SECONDS);
verify(gamesService).searchGames("zelda");
verify(asyncResponse).resume(any(IOException.class)); *2*
}
-
1 当调用 searchGames("zelda") 方法时抛出 IOException
-
2 验证 resume 是否被 IOException 实例调用
这是我们将向您展示的最后一个单元测试。下一节建议您编写一些测试以确保您理解了所学的知识。
练习
在阅读本章之后,您可能已经准备好为 book.video.boundary.YouTubeGateway 和 book.games.entity.Game 类编写测试。在第一个测试中,我们建议使用独立测试方法,并使用 Mockito 进行依赖项测试。对于第二个测试,尝试使用社交测试方法。
摘要
-
我们开始探讨如何将单元测试原则应用于微服务架构,并使用最佳策略编写单元测试。
-
总是尝试开发尽可能快、健壮和可读的单元测试是很重要的。
-
您应该知道在哪些情况下最好使用测试替身,遵循示例中介绍的基本原则。
-
JUnit、AssertJ 和 Mockito 技术可以一起使用来创建强大的单元测试。
第四章. 组件测试微服务
本章涵盖
-
介绍 Arquillian 测试框架
-
使用 Arquillian 扩展进行 RESTful Web 服务和 Spring 的开发
-
测试资源、领域和远程组件
你已经了解了微服务的基本组件构成和各种基本测试技术。现在,是时候深入探讨哪种测试风格最适合每个组件,以及你应该如何实现这些测试了。
组件测试应该设计为验证微服务内部模块的功能以及它们之间的功能,但有一个例外:外部面向公众的资源组件。你想要确保面向公众的服务以你计划的方式对世界开放,因此你需要定义一个基于客户端的测试来确保这一点。组件测试不应该跨越本地机器之外的远程边界。
你需要良好的独立和社交测试混合,以覆盖正在测试的微服务的所有内部交互。测试应使用替身来替换通常与外部资源协作的交互。
我们确信在某个时刻,你曾编写过一个测试来确保一段代码的正确性,但后来在部署到预发布或生产环境时,代码却失败了。这种情况我们都遇到过!通常这与生产环境中在开发阶段不明显的问题有关。在所谓的生产近环境中测试组件可以确保它们以你期望的方式反应。接下来,我们将关注这个场景:使用 Arquillian 测试框架开发生产近测试。
4.1. Arquillian 测试框架
Arquillian 最初是为测试在 Java EE 容器中运行的应用程序而创建的,例如 Apache TomEE、JBoss AS、IBM Liberty Profile、Payara、Red Hat WildFly 以及许多其他容器。自那时起,该框架已经发展,不仅包括 Java EE,还包括 Spring 和 Docker 环境。你可以在 Arquillian 网站上找到完整的历史记录:www.arquillian.org。
注意
尽管我们经常在网上或会议上宣传 Arquillian,但我们不希望你认为我们推广这个框架是因为我们得到了赞助。我们推广它是因为在过去的几年里,我们发现它是我们解决许多个人测试挑战的最佳工具。我们希望你会像我们一样享受学习和使用它。
Arquillian 是一个可以用于各种软件测试的框架。当涉及到测试微服务、Java EE 或 Spring Boot 时,Arquillian 测试框架是自切片面包以来最好的东西。找到不将你绑定到特定产品或实现的工具很重要。如果我们知道软件工程中的任何事情,那就是事情会变化。Arquillian 从一开始就被设计成在变化方面尽可能灵活,但在使用上尽可能简单。
通过使用扩展,框架可以扩展以满足新的需求。因此,它可以整合许多其他知名的测试组件,如 Mockito 和 PowerMock。它并不试图与这些技术竞争,而是将它们的使用纳入标准化的生命周期中。
你已经理解了单元测试和@Test注解,因此尝试引入一个全新的概念将是错误的方向。Arquillian 将你已知的知识融入其中,并利用新特性丰富了这种共同知识。因此,如果你不是在几分钟内,你可以在几小时内开始运行。
在我们查看真实测试之前,你应该了解 Arquillian 测试生命周期。测试本身可能看起来很简单,但在底层有很多事情在进行。你不需要确切地知道它是如何工作的,但如果你遇到问题,基本的理解可能会在将来帮助你。图 4.1(kindle_split_013_split_001.xhtml#ch04fig01)至图 4.6(kindle_split_013_split_001.xhtml#ch04fig06)中的图表概述了基本的 Arquillian 自动化生命周期。
图 4.1. 选择测试容器

注意
从所有目的和意义上讲,容器是你选择的应用服务器的另一个名称。无论何时你看到“容器”这个词,只需想到“应用服务器”。
如前所述,我们不会将你限制在特定的容器上。第一阶段是选择用于当前测试的容器(图 4.1)。你可以同时针对多个容器运行测试,正如我们将在第五章中讨论的那样。
要么由 Arquillian 启动选定的容器,要么你可以连接到现有的容器(图 4.2)。连接到正在运行的容器意味着测试可以避免容器的启动成本。这一点将在本章后面的 4.6.1 节中详细说明。
图 4.2. 激活容器环境

需要将测试类以某种方式部署到容器中。Arquillian 知道如何将你的测试类部署到选定的容器中运行测试。这个任务的复杂性被框架隐藏了。你可以使用一个名为 ShrinkWrap 的极其强大的工具来打包你的测试应用程序,使其符合容器的期望(图 4.3)。
图 4.3. 使用 ShrinkWrap 打包测试应用程序并将其部署到容器

一旦 ShrinkWrap 打包并部署了你的测试,测试就会在容器上运行(图 4.4)。测试环境中属于许多元素的内容都会被代理,并且可以使用 CDI 从本地上下文中访问。你很快就会看到这个极其强大的功能是如何发挥作用的。
图 4.4. 部署的测试应用程序在容器中运行。

在测试运行过程中,需要捕获和收集结果。同样,框架会隐藏这种复杂性,并与你熟悉的常见单元测试紧密协作。IDE 或构建环境会以与正常测试完全相同的方式显示结果(图 4.5)。
图 4.5. 捕获结果并将其返回到测试环境

一旦所有测试完成,整个测试周期中使用的资源都会被安全地处置(图 4.6)。这可能包括关闭应用程序服务器、数据库和其他在测试期间使用的远程连接。
图 4.6. 清理资源和关闭容器

警告
如果容器由 Arquillian 启动并且测试发生灾难性故障,请注意服务器可能作为悬挂进程继续运行。这通常表现为下一次测试运行时的端口冲突。如果发生这种情况,你需要找到错误的容器进程并将其手动终止。
4.2. 介绍 @RunWith(Arquillian.class) 注解
在本章中,我们将介绍几个相互关联的概念:构建脚本依赖项、@RunWith(Arquillian.class) 注解和 arquillian.xml 配置文件。目前,请记住,尽管 Arquillian 配置文件对于测试是可选的,但你最终可能需要自定义内部配置的默认设置。阅读本章后,这会变得清晰;现在不必担心容器是如何配置的或需要哪些构建脚本修改。
注意
在这个例子中,我们保留了 imports 以便你可以看到相关的命名空间。在后续的例子中,为了简洁起见,我们省略了这些内容的大部分。所有演示应用程序的源代码都随书提供作为参考;请参阅 www.manning.com/books/testing-java-microservices。
以下列表显示了一个简单的 Arquillian 测试(code/game/src/test/java/book/games/arquillian/ArquillianBasicTest.java)。
列表 4.1. Arquillian 测试
package book.games.arquillian;
import book.games.boundary.Games;
import book.games.boundary.IgdbGateway;
import book.games.control.GamesService;
import book.games.entity.Game;
import book.games.entity.ReleaseDate;
import book.games.entity.SearchResult;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit.Arquillian;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.EmptyAsset;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import javax.ejb.EJB;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@RunWith(Arquillian.class) *1*
public class ArquillianBasicTest {
@Deployment *2*
public static WebArchive createDeployment() {
//return ShrinkWrap.create(JavaArchive.class *3*
//return ShrinkWrap.create(EnterpriseArchive.class *3*
return ShrinkWrap.create(WebArchive.class, *3*
ArquillianBasicTest.class.getName() + ".war") *3*
.addClasses(IgdbGateway.class, GamesService.class, *3*
SearchResult.class, Games.class, Game *3*
.class, ReleaseDate.class) *3*
.addAsResource("test-persistence.xml", *3* *4*
"META-INF/persistence.xml") *3*
*3*
.addAsWebInfResource(EmptyAsset.INSTANCE, *3*
"beans" + ".xml"); *3* *5*
}
@Inject *6*
private GamesService service;
@EJB *7*
private Games games;
@PersistenceContext *8*
private EntityManager em;
@Test *9*
public void test() {
Assert.assertNotNull(this.service); *10*
Assert.assertNotNull(this.games); *10*
Assert.assertNotNull(this.em); *10*
}
}
-
1 将此定义为 Arquillian 测试
-
2 定义部署
-
3 构建应用程序存档
-
4 添加 test-persistence.xml 以启用 JPA
-
5 生成并添加 beans.xml 文件以启用 CDI
-
6 将 GamesService bean 的运行时实例注入到测试类中
-
7 在运行时将事务感知的 Games EJB 实例注入到测试类中
-
8 在运行时将运行时 JPA EntityManager 实例注入到测试类中
-
9 指示该方法是一个测试
-
10 断言以证明注入已成功
让我们逐一了解构建这个 Arquillian 测试所需的所有元素。以下步骤对应于列表 4.1 中的注解:
1. 你将这个测试类定义为 Arquillian 测试,并通知 JUnit 在任何测试运行之前有一些连接要做。
2. @Deployment 定义了测试运行在容器中所需的所有内容的部署。这是要物理部署到容器的 shrink-wrapped 应用程序。定义方法必须是静态的。
3. 使用 ShrinkWrap 构建应用程序存档。这可能会有些令人困惑,但第 4.3 节讨论了如何使用 ShrinkWrap 构建部署存档。在这里,你正在构建一个 WAR 文件,所以忽略注释。你只需添加构成测试的类;应用程序可能要大得多。
4. 在应用程序的 META-INF 目录下添加 test-persistence.xml 的副本以启用 JPA。test-persistence.xml 位于项目的 test/resources 目录中。它将被复制到自动创建的目录并重命名为 persistence.xml。你的测试可能不需要持久化,但在这里添加资源很容易。
5. 在存档 WEB-INF 目录中生成并添加一个空的 beans.xml 文件以启用 CDI(上下文和依赖注入—EE6)。EmptyAsset.INSTANCE 告诉 ShrinkWrap 创建一个空文件,但在步骤 4 中你看到了如何添加本地文件。
6. 当 JUnit 运行测试时,通过 CDI 将 GamesService bean 的运行时实例注入到测试类中。你可以在测试环境中调用这个实例,就像你从应用程序中调用一样。这有多酷?这就是你第一次尝到 Arquillian 框架有多强大的味道。
7. 在运行时将事务感知的 Games EJB 实例注入到测试类中。类似于 CDI,直接将容器托管的 EJB 注入到测试类中,可以让你完全访问运行时 bean。
8. 在运行时将 JPA
EntityManager实例注入到测试类中。此示例允许在测试中直接访问应用程序持久层。9. 常规的
@Test注解表示该方法是一个测试。前几个步骤显然是可选的,例如 CDI 和 JPA,并提供了一些从 Arquillian 可以期待的内容的示例。10. 简单的断言证明了注入是有效的。
想象一下,现在在几行代码中,你的微服务测试可以多么彻底。正如你所见,添加@RunWith(Arquillian.class)、@Deployment和@Test注解创建了一个非常强大的测试环境。
即使你的应用程序是一个微服务,可以说部署不太可能只包含几个类。在这个基本测试中,你单独添加类——在一个更大的应用程序中这将很繁琐。下一节将深入探讨 ShrinkWrap 的强大功能;它将使你能够添加你归档中可能需要的任何东西——甚至更多!
4.3. ShrinkWrap 实用工具类
与 Arquillian 宇宙中的许多组件一样,ShrinkWrap 从基本需求发展成为一个强大的工具。在 Arquillian 的开发初期,很明显需要一个工具来构建包含所有所需依赖项的 Java 归档,并且还需要一种将归档部署到应用服务器的手段。
依赖于应用程序的构建过程是不可行的。构建过程可能很慢,生成的可部署归档可能很大,并且与每个可能的构建过程集成将非常困难,如果不是不可能的。Arquillian 需要更简单、更快、更隔离的东西。ShrinkWrap 通过管理一个可以导出为 Java 归档的虚拟文件系统来实现这一点。该归档的所有可能的条目类型都被建模并暴露给你,因此你可以构建包含任何测试所需内容的任何类型的归档。
我们相信你对常见的网络部署归档格式很熟悉,包括 Java 归档(JAR)、网络归档(WAR)、企业归档(EAR)和 Roshal 归档(RAR)文件。ShrinkWrap 对这些格式理解得非常好,但它可以构建任何基于 zip 的归档。
理念是将你的应用程序分解成实现测试目标所需的最小归档格式:
Archive jar = ShrinkWrap.create(JavaArchive.class, "service-provider.jar")
.addClass(MyService.class); *1*
WebArchive war = ShrinkWrap.create(WebArchive.class, "web-library.war")
.addClass(MyResource.class)
.addAsLibrary(jar); *2*
EnterpriseArchive ear = ShrinkWrap
.create(EnterpriseArchive.class, "application.ear")
.addAsModule(war); *3*
-
1 创建一个简单的 JAR 文件,并添加一个类
-
2 创建一个更复杂的 WAR 文件格式,并添加一个 JAR 文件
-
3 创建一个 EAR 文件,并添加一个 WAR 文件
对于 Arquillian 来说,ShrinkWrap 是用于创建测试运行时所需的微部署的库。你几乎可以称这些微部署为微服务,因为本质上就是这样。但 ShrinkWrap 可以做更多,也可以作为一个独立的实用工具使用。这超出了本书的范围,所以我们不会涉及它;如果你想要了解更多信息,请随意进行研究 (arquillian.org/modules/shrinkwrap-shrinkwrap/)).
ShrinkWrap 基本上管理了一个 Java 存档的内存表示,无论格式如何。这个表示可以通过添加或删除条目来通过强大的 API 进行修改。存档构建到满足你的要求后,可以将其交给 Arquillian 进行部署到容器中。我们将在第 4.5.1 节中介绍添加所需的依赖项,但在达到那个阶段之前,了解你将要添加的内容以及为什么这样做是很重要的。
4.3.1. 使用 ShrinkWrap 构建存档
创建存档很简单。你使用org.jboss.shrinkwrap.api.ShrinkWrap类的静态create()方法创建一个空存档:
Archive jar = ShrinkWrap.create(GenericArchive.class, "my-first-archive.jar");
当然,所有这些只是创建了一个带有名称的空存档,但你已经创建了你的第一个 ShrinkWrap 存档。恭喜你!
接下来,你可能会想要向存档中添加更多资产。在你这样做之前,你可能发现这个片段很有用,可以帮助你在开发过程中查看存档的内容:
System.out.println(jar.toString()); *1*
// OUTPUT: my-first-archive.jar: 0 assets
System.out.println(jar.toString(true)); *2*
// OUTPUT: my-first-archive.jar:
-
1 使用
toString()是一种查看你的存档包含什么内容的好方法。这是一个普通的 JavatoString()调用。 -
2 也可以使用其他形式的
toString()。使用你的 IDE 来发现更多。
4.3.2. 向 ShrinkWrap 存档添加内容
GenericArchive在向存档添加资产方面并不做什么。但 ShrinkWrap 可以创建比简单的GenericArchive更多!
要创建不同类型的存档,你只需要在方法中传递你想要创建的Archive子类。当使用更具体的子类型时,可用于向存档添加内容的选择会更多。
例如,对于 JAR 文件,你可以添加类、包、META-INF 资源等。其他类型也有类似的功能:WAR 有添加 Web 内容、库和 web.xml 的方法。EAR 可以添加模块、应用程序资源、application.xml,以及库和清单资源。你可以通过探索 ShrinkWrap API 来了解更多。
你会注意到这些方法中有多个重载。你可能不会使用所有这些方法,但每个方法都有其用途。如果你发现自己正在做某件事,感觉可能更简单,那么看看你不太常用的方法重载——可能有一些东西可以使你的任务更容易。
使用存档时,你首先想要做的是向其中添加类:
ShrinkWrap.create(WebArchive.class, "my-first-archive.jar").
add
addAsDirectories
addAsDirectory
addAsLibraries
addAsLibrary
addAsManifestResource
addAsManifestResources
addAsResource
addAsResources
addAsServiceProvider
addAsServiceProviderAndClasses
addAsWebInfResource
addAsWebInfResources
addAsWebResource
addAsWebResources
addClass *1*
addClasses
addDefaultPackage
addHandlers
addManifest
addPackage *2*
addPackages
...
-
1 所有添加方法都是直观的,但你可能想要添加类。
-
2 您可能还想添加包。
注意
复数形式的 add 方法是单数形式的 vararg 版本,可以一次添加多个项目:例如,addClasses 和 addPackages。
以下是一个使用 单数 addClass 方法的示例:
Archive jar = ShrinkWrap.create(JavaArchive.class, "my.jar").addClass
(HelloWorld.class);
System.out.println(jar.toString(true));
/*
OUTPUT:
my.jar:
/org/
/org/example/
/org/example/shrinkwrap/
/org/example/shrinkwrap/HelloWorld.class
*/
您在这里添加名称:my.jar。如果您不添加名称,将生成一个格式为 UUID.jar 的名称,因此我们建议从一开始就使用描述性名称。它们在您在控制台输出或日志文件中直观扫描时更有意义。
小贴士
使用测试类名作为存档名称的一部分:this.getClass().getName() + .jar。这将有助于在控制台输出中直观地分离测试存档。
有时将整个命名空间包添加到存档中会更方便。正如您现在应该预料到的,有一个简单的调用可以做到这一点——addPackages:
Archive jar = ShrinkWrap.create(JavaArchive.class, "my.jar")
.addPackages(true, "org.example"); *1*
System.out.println(jar.toString(true));
/*
package-jar.jar:
/org/
/org/example/
/org/example/shrinkwrap/
/org/example/shrinkwrap/util/
/org/example/shrinkwrap/util/StringUtils.class
/org/example/shrinkwrap/GoodbyeWorld.class
/org/example/shrinkwrap/ShrinkWrapUsage.class
/org/example/shrinkwrap/HelloWorld.class
*/
- 1 此方法最有用的版本,其中 true 表示递归调用,添加所有子包和类
4.3.3. 添加资源
您不可避免地需要知道如何将资源添加到存档。这些包括以下内容:
-
CDI 的 beans.xml 文件
-
JPA 的 persistence.xml 文件
-
服务实现
-
标签库描述符
-
其他常见配置文件
这是将这些项目添加到存档的方法:
Archive jar = ShrinkWrap.create(JavaArchive.class, "my.jar")
.addPackages(true, "org.example")
.addAsResource("test-persistence.xml", "META-INF/persistence.xml") *1*
.addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml"); *2*
//Alternative methods...
URL persistenceXml = getClass().getClassLoader()
.getResource("test-persistence.xml"); *3*
jar.addAsResource(persistenceXml
, new BasicPath("META-INF/persistence.xml")); *4*
-
1 将测试资源 test-persistence.xml 添加到存档中的 META-INF/persistence.xml
-
2 创建一个空文件资产,并将其添加到存档中的 beans.xml
-
3 从测试类路径中定位和添加资源的替代方法
-
4 使用 BasicPath 描述符将替代资源添加到存档
这两种方法产生相同的输出:
System.out.println(jar.toString(true));
/*
my.jar:
/META-INF/
/META-INF/beans.xml
/META-INF/persistence.xml
*/
如您所见,ShrinkWrap 有许多方法可以达到相同的结果。您现在不必知道如何使用所有这些方法,但知道几乎总是有简单的方法来做您想做的事情是很好的。
4.3.4. 添加库和依赖项
您的应用程序可能需要额外的库和第三方依赖项,例如 Apache Commons 库 (commons.apache.org)。将一些项目添加到存档中是相当简洁的:
Archive jar = ShrinkWrap.create(JavaArchive.class, "service-provider.jar")
.addClass(CdiExtension.class)
.addAsServiceProvider(Extension.class, CdiExtension.class); *1*
WebArchive war = ShrinkWrap.create(WebArchive.class, "web-library.war")
.addClass(StringUtils.class) *2*
.addAsWebResource(new File("src/main/webapp/images/1.gif")
, "/images/1.gif") *3*
.addAsLibrary(jar); *4*
System.out.println(war.toString((true)));
/*
web-library.war:
/WEB-INF/
/WEB-INF/lib/
/WEB-INF/lib/service-provider.jar
/WEB-INF/classes/
/WEB-INF/classes/org/
/WEB-INF/classes/org/example/
/WEB-INF/classes/org/example/shrinkwrap/
/WEB-INF/classes/org/example/shrinkwrap/util/
/WEB-INF/classes/org/example/shrinkwrap/util/StringUtils.class
/images/
/images/1.gif
*/
-
1 创建一个 JAR 文件,在本例中是一个 SPI(关于 SPI 的更多信息将在下一节中介绍)
-
2 添加单个类。ShrinkWrap 知道在哪里将其添加到 WAR 文件中 (/WEB-INF/classes/)。
-
3 将标准资源添加到定义的路径
-
4 将 JAR 文件添加到 WAR 文件中。同样,ShrinkWrap 会自动将其添加到正确的路径 (/WEB-INF/lib/)。
如您所料,ShrinkWrap 提供了简单的方法来实现复杂的功能。但您的应用程序如果远不止一个 Hello World 示例呢?ShrinkWrap 单独是不够的。这引出了下一个主题。
4.3.5. 使用 Maven 解析器添加复杂依赖项
Maven 解析器 是另一个强大的实用工具类,允许你向存档添加重量级选项。它旨在补充你已经了解的 ShrinkWrap 实用工具类。我们只会简要介绍其基本使用方法;你可以在项目的网站上获得更深入的见解,github.com/shrinkwrap/resolver。
使用解析器很简单,最好通过一个示例来描述:
import org.jboss.shrinkwrap.resolver.api.maven.Maven; *1*
....
String hibernate = "org.hibernate:hibernate-core:5.2.3.Final"; *2*
File[] files = Maven.resolver().resolve(hibernate) *3*
.withTransitivity() *4*
.asFile(); *5*
WebArchive war = ShrinkWrap.create(WebArchive.class, "my.war")
.addAsLibraries(files); *6*
-
1 导入 Maven 实用工具类
-
2 定义使用 groupId:artifactId:version (GAV) 格式要解析的 Maven 坐标
-
3 解析定义的 GAV 坐标
-
4 此外,检索主工件的所有临时(相关)依赖项
-
5 将完整的依赖项列表作为一个文件数组添加
-
6 将所有依赖项添加到 WAR 存档中
如果你知道资源的类型,解析器可以返回不仅仅是文件。除了 GAV,你还可以指定包类型(P,用于如 WAR 或 JAR 的存档)和分类器(C,用户定义的名称)。以下是写作时的完整资源类型列表:
Maven.resolver().resolve("G:A:V").withTransitivity()
.as(File.class);
Maven.resolver().resolve("G:A:V").withTransitivity()
.as(InputStream.class);
Maven.resolver().resolve("G:A:V").withTransitivity()
.as(URL.class);
Maven.resolver().resolve("G:A:V").withTransitivity()
.as(JavaArchive.class);
Maven.resolver().resolve("G:A:P:V").withoutTransitivity()
.asSingle(WebArchive.class);
Maven.resolver().resolve("G:A:P:C:V").withTransitivity()
.as(MavenCoordinate.class);
MavenCoordinate.class 提供了有关指定工件详细元信息。如果你不确定工件提供了哪些资源,可以使用这个类来发现详细信息。
注意
你负责关闭检索到的 InputStream 类型。该流是直接连接到仓库工件。
尽管解析器功能强大,但你可能已经注意到有一个小的潜在缺点:工件版本号在 GAV 坐标字符串中是硬编码的。对于少量固定的依赖项,保持它们同步可能不是问题;但如果你的应用程序一直在发展,这可能会成为一个问题。不用担心:API 提供了一个解决方案。解析器理解 Maven pom.xml 文件 (maven.apache.org/pom.html)。你可以从任何 pom.xml 文件位置导入依赖项,所以它可以是你的项目 POM,也可以是完全独立于你的构建的:
Maven.resolver().loadPomFromFile("/path/to/pom.xml") *1*
.importRuntimeAndTestDependencies() *2*
.resolve().withTransitivity().asFile(); *3*
-
1 指定要加载的 pom.xml 文件路径
-
2 定义要导入的范围(有多种方法可以覆盖所有范围)
-
3 将完整的依赖列表解析为一个文件数组
4.3.6. 添加服务实现
服务提供者实现(SPIs,mng.bz/i7QX)是一种以标准方式扩展应用程序的方法。ShrinkWrap 理解这种机制,并且可以移除添加已知 SPI 到存档所需的大部分标准样板代码。
各种 addAsServiceProvider 方法对于让 ShrinkWrap 为指定的接口和类创建服务文件非常有用。以下代码将创建 javax.enterprise.inject.spi.Extension 文件,将服务类的名称添加到其中,然后将文件放置在标准的 META-INF 文件结构中:
Archive jar = ShrinkWrap.create(JavaArchive.class, "service-provider.jar")
.addAsServiceProviderAndClasses(Extension.class, CdiExtension.class); *1*
System.out.println(jar.toString(true));
/*
service-provider.jar:
/javax/
/javax/enterprise/
/javax/enterprise/inject/
/javax/enterprise/inject/spi/
/javax/enterprise/inject/spi/Extension.class *2*
/org/
/org/example/
/org/example/shrinkwrap/
/org/example/shrinkwrap/CdiExtension.class
/META-INF/
/META-INF/services/
/META-INF/services/javax.enterprise.inject.spi.Extension
*/
Archive jar2 = ShrinkWrap.create(JavaArchive.class, "service-provider-2.jar")
.addClass(CdiExtension.class)
.addAsServiceProvider(Extension.class, CdiExtension.class); *3*
System.out.println(jar2.toString(true));
/*
service-provider-2.jar:
/org/
/org/example/
/org/example/shrinkwrap/
/org/example/shrinkwrap/CdiExtension.class
/META-INF/
/META-INF/services/
/META-INF/services/javax.enterprise.inject.spi.Extension
*/
-
1 添加了 SPI 所需的结构
-
2 有时,附加的接口是由容器提供的。
-
3 通常最好使用 addAsServiceProvider 方法并单独添加类。
提供两种添加 SPI 的方法为你提供了添加任何实现所需的所有灵活性。
4.4. 编写一次,重用你的代码
现在你已经了解了如何创建存档,你可能想知道为什么你要费这么大的劲,因为你的构建工具可以为你做所有这些。除了显而易见的答案——“这就是 Arquillian 的做法”——还有许多很好的理由。最重要的理由是 Arquillian 显著降低了执行集成和功能测试的门槛。即使你没有选择使用 Arquillian,你仍然需要做以下事情:
-
创建一个可以部署的存档。
-
将存档部署到正在运行的容器/应用程序服务器。
-
在已部署的存档中运行测试。
-
从容器中检索测试结果。
-
取消部署存档。
-
关闭容器。
Arquillian 以透明的方式为你处理这些。为所有测试创建一个抽象基类以扩展、覆盖和重用是微不足道的:
public class MyTest extends MyAbstractTest {
....
@Deployment
public static WebArchive createDeployment() {
WebArchive w = createMyBaseDeployment(); *1*
w.addClass(MyExtra.class);
return w;
}
....
}
- 1 调用一个静态父方法或实用工具方法,该方法提供了一个现成的 WebArchive 以添加额外的需求
这个基类概括了你的应用程序,测试类只需要添加额外的需求。你甚至可以为此创建自己的实用工具类。
小贴士
大多数,如果不是所有,应用程序都有可以提取到抽象类中的共同基础。在测试方面也是如此。识别出你测试策略中通用的代码,并做完全相同的事情——创建一个抽象测试类。这将在长期内为你节省时间。
你的构建可能有助于创建存档,但你需要确保添加了所有的测试依赖项,这通常需要相当多的额外配置。创建这些较小的部署,微部署,有助于隔离测试与其他应用程序部分。这也帮助你从最小的可测试工作单元的角度思考。
微部署还允许你跳过完整的构建,这既是使用它们的原因,也是副作用。如果你的完整构建需要一分钟来创建可测试的存档,那么你将需要额外一分钟来运行测试!当你进行测试时,这段时间会迅速增加,尤其是如果你是在本地运行测试。副作用是,你不仅跳过了构建,如果你使用的是增量编译器,你也会跳过一个隐式的编译阶段。这可以节省大量时间!
4.5. 修改构建脚本
你现在已经很好地掌握了添加到基本 JUnit 测试中以使其成为 Arquillian 测试的元素,但这些元素都是从哪里来的呢?答案是:依赖项!
由于存在众多构建环境,因此几乎不可能涵盖所有内容。我们在示例项目中使用了两个最流行的系统:Maven 和 Gradle。详细说明这两个系统将为您提供足够的信息来配置任何其他系统,因为它们与 Maven GAV 坐标系统紧密相关,并且允许在需要时指定打包和分类器:
groupId:artifactId:packaging:classifier:version
Arquillian、ShrinkWrap 和 Resolver 都提供了 Maven 物料清单(BOM)pom.xml 文件。1 BOM 定义了您将需要的所有依赖关系,包括版本号。这消除了需要大量单个依赖关系声明。Arquillian BOM 导入了 ShrinkWrap 和 Resolver BOM,因此您只需在项目 pom.xml 的 <dependencyManagement> 部分包含 Arquillian BOM 即可。
¹
请参阅“依赖机制简介”,Apache Maven 项目(
mng.bz/OR0F)。
4.5.1. 定义 Maven 依赖关系
如果您使用 Maven 进行构建,添加 Arquillian 的依赖关系非常简单。在项目 pom.xml 的 <dependencyManagement> 部分使用 import 范围声明 BOM。您可以在以下列表中看到提供了一个 WildFly (wildfly.org) BOM。BOM 非常有用!
列表 4.2. code/game/pom.xml: <dependencyManagement>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.wildfly.swarm</groupId>
<artifactId>bom</artifactId>
<version>${version.wildfly.swarm}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.jboss.arquillian</groupId>
<artifactId>arquillian-bom</artifactId>
<version>1.1.13.Final</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
注意
BOM(Bill of Materials)用于定义项目中可能使用的特定工件版本和临时依赖关系。在多模块构建中,<dependencyManagement> 应该位于父 pom.xml 中,并作为所有子模块的合同。实际的依赖关系定义在 <dependencies> 部分,其中省略了 <version> 定义。
您还需要将 Arquillian 容器适配器和实现添加到项目的 <dependencies> 部分。此示例使用 Red Hat 的 WildFly 容器适配器(www.redhat.com),但可以是您喜欢的任何适配器实现,例如 Apache TomEE 适配器(mng.bz/O5fo)。
列表 4.3. code/game/pom.xml: <dependencies>
<dependency>
<groupId>org.jboss.arquillian.junit</groupId>
<artifactId>arquillian-junit-container</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wildfly.swarm</groupId>
<artifactId>arquillian</artifactId>
<scope>test</scope>
</dependency>
提示
仅仅在测试类路径上有一个适配器实现就足以让 Arquillian 找到并使用它。您可以使用 Maven 构建配置文件定义多个实现——我们将在第五章中进一步讨论这一点。
您可以通过在互联网上搜索 “[容器名称]+arquillian” 来始终找到您应用程序服务器的适配器。请注意,定义的 <scope> 是 test,因为您应该只使用 Arquillian 进行测试。
注意
arquillian-junit-container 艺术品会拉入 ShrinkWrap 和 resolvers 作为临时依赖。如果您想在您的应用程序中使用 ShrinkWrap 或 resolvers,您需要单独定义它们,使用所需的范围。
4.5.2. 定义 Gradle 依赖
Gradle 默认不支持 BOM 的概念。幸运的是,一些插件启用了此功能——否则,您必须单独添加所有依赖项。您仍然需要添加一些额外的依赖定义,但 BOM 会为您声明所有版本和临时依赖。
注意
我们已经为本书格式化了长坐标行,因此请将 o.j.s 替换为 org.jboss.shrinkwrap,将 o.j.a 替换为 org.jboss.arquillian,将 o.g 替换为 org.glassfish。如有疑问,请检查示例代码。
以下列表展示了从 aggregatorservice 应用程序(code/aggregator/build.gradle)中提取的示例 build.gradle 文件。
列表 4.4. 示例 build.gradle 文件
plugins { *1*
id "io.spring.dependency-management" version "0.6.1.RELEASE"
}
dependencyManagement { *2*
imports {
mavenBom 'o.j.a:arquillian-bom:1.1.13.Final'
mavenBom 'o.j.s:shrinkwrap-bom:1.2.6'
mavenBom 'o.j.s.resolver:shrinkwrap-resolver-bom:2.2.4'
}
}
apply plugin: 'war'
group = 'org.gamer'
version = '1.0-SNAPSHOT'
war.archiveName = "gameaggregatorservice.war"
sourceCompatibility = 1.8
targetCompatibility = 1.8
dependencies { *3*
testCompile 'junit:junit:4.12'
testCompile group: 'o.j.a.junit',name: 'arquillian-junit-container' *4*
testCompile 'o.j.a.container:arquillian-tomcat-embedded-8:1.0.0' *5*
testCompile 'o.j.s:shrinkwrap-api' *6*
testCompile 'o.j.s:shrinkwrap-spi' *6*
testCompile 'o.j.s:shrinkwrap-impl-base' *6*
testCompile 'o.j.s.resolver:shrinkwrap-resolver-api' *7*
testCompile 'o.j.s.resolver:shrinkwrap-resolver-spi' *7*
testCompile 'o.j.s.resolver:shrinkwrap-resolver-api-maven' *7*
testCompile 'o.j.s.resolver:shrinkwrap-resolver-spi-maven' *7*
testCompile 'o.j.s.resolver:shrinkwrap-resolver-api-maven-archive' *7*
testCompile 'o.j.s.resolver:shrinkwrap-resolver-impl-maven' *7*
testCompile 'o.j.s.resolver:shrinkwrap-resolver-impl-maven-archive' *7*
compile 'o.g.jersey.containers:jersey-container-servlet:2.22.2'
compile 'o.g.jersey.core:jersey-client:2.22.2'
compile 'o.g:javax.json:1.0.4'
compile 'o.g:jsonp-jaxrs:1.0'
}
-
1 定义了依赖管理插件,允许您使用 Maven BOM 导入
-
2 使用插件导入 Arquillian、ShrinkWrap 和 resolver 依赖
-
3 声明应用程序所需的全部依赖
-
4 添加 Arquillian 依赖。因为 BOM 定义了所有必需的版本号,所以您不需要在这里包含它们(除非需要覆盖)。
-
5 添加任何容器实现。在这里,您使用嵌入式的 Apache Tomcat 容器。
-
6 添加 BOM 中声明的 ShrinkWrap 依赖
-
7 添加 BOM 中声明的 Resolver 依赖
4.6. 覆盖默认的 Arquillian 配置
我们现在已经涵盖了运行标准 Arquillian 单元测试所需的所有元素。这包括使用必需的注解、打包和部署应用程序存档,以及在构建脚本中包含所有必需的依赖项。如本章开头所述,所有这些都依赖于尚未公开的默认配置。
有时候,默认配置可能需要定制。例如,您可能需要在一个不同于默认端口的端口上启动容器(以避免端口冲突)或以 HTTPS 模式(以测试安全性),或指定选项,如密钥库文件。
Arquillian 会检查类路径根目录下名为 arquillian.xml 的配置设置文件。如果此文件存在,则将其加载;否则,使用默认值。到目前为止,Arquillian 一直在后台透明地使用默认的配置选项集。这些默认选项由类路径上找到的特定供应商的容器适配器实现提供。
在典型的 Maven 项目中,此文件通常位于 [project]/src/test/resources/arquillian.xml。此测试资源目录中的文件仅在运行时才可在测试类路径上使用,这通常是必需的场景。以下列表展示了示例。
列表 4.5. 包含两个容器的示例 arquillian.xml 文件
<?xml version="1.0" encoding="UTF-8"?>
<arquillian> *1*
<!-- Configuration of container one -->
<container qualifier="widlfly-remote"> *2*
<configuration>
<property name="javaVmArguments">
-Dprop=val
</property>
</configuration>
</container>
<!-- Configuration of container two -->
<container qualifier="widlfly-managed" default="true"> *3*
<configuration>
<property name="javaVmArguments">
-Dprop=val *4*
</configuration>
</container>
</arquillian>
-
1 在遵循标准 XML 声明之后是
,带有模式引用的主体定义。 -
2 通常只有一个
;我们将在稍后解释为什么这里有两个。 -
3 第二个容器,标记为默认
-
4 容器属性定义——可以有多个属性
此配置是 XML 格式,因此在布局上没有惊喜。让我们更深入地看看最重要的配置选项。
注意
因为每个容器都是供应商特定的,所以 arquillian.xml 文件中的一些内容将包含供应商特定的选项。以列表 4.5 中的信息为例,但请确保你使用你选择的容器的正确选项。我们将在本节中突出需要关注的区域。
4.6.1. 容器定义
在第 4.5.1 节中,你学习了如何使用 Maven 坐标向测试类路径添加默认容器适配器。<container qualifier="[name]"> 是你为将要测试的特定运行时容器做出声明的位置。如果 Arquillian 只找到一个容器定义,它将始终用作默认值。
我们将在第七章中深入讨论使用多个容器。简而言之,如果存在多个容器定义,Arquillian 会寻找两个东西:
-
带有
default="true"属性的容器。这个容器将被用作默认值——这是有道理的。 -
系统属性
arquillian.launch,其中包含用于当前运行时的容器名称。
可以使用 Maven 配置文件定义或覆盖 arquillian.launch 系统属性。
4.6.2. 指定容器属性
容器属性很简单:<property name="[name]">[value]</property>。你可以在 <configuration> 元素中添加任意数量的容器属性。正如我们在本章前面警告的那样,这些属性是供应商特定的。
容器属性最常见的用途是定义一个要绑定的服务器端口,以防止冲突。一些提供商允许使用随机端口,这是一个很酷的功能,因为它允许测试并行运行。示例列表 4.5 为 WildFly 容器定义设置了系统属性 javaVmArguments。你将在本节后面看到这一点的重要性。正如你可以想象的那样,在这里列出所有可用的容器实现的所有可用属性是不可能的。表 4.1 给出了一些;请花时间找到你选择的容器可用的属性。
表 4.1. 常见容器和属性
| 容器 | 属性 | 值 |
|---|---|---|
| Apache TomEE | httpPort | -1 (随机) |
| Apache TomEE | stopPort | -1 (随机) |
| WildFly | serverConfig | server.xml (文件) |
| GlassFish | bindHttpPort | [端口号] |
小贴士
当你使用嵌入式容器(远程容器适配器总是这样命名)时,你也可以通过maven-surefire-plugin设置系统属性。参见mng.bz/cI7V。
4.7. 使用 Arquillian REST 扩展
REST 网络服务现在很常见,而且越来越不可能有一个基于 Web 或微服务应用今天不使用这项技术。这并不意味着微服务不能使用不同的协议(远非如此);但这将是一个边缘情况,并且特定于业务范围。在这里,我们将关注 HTTP/S 上的 REST,以及 Arquillian 如何在构建测试环境时减轻负担。
有两个模块可用。REST 客户端扩展旨在用于黑盒测试环境:客户端完全与应用程序隔离,并且仅将端点视为真实远程客户端。Warp REST 模块用于更复杂的测试,其中你想要拦截传入和传出的 REST 请求和响应。接下来的两个部分将介绍这些模块。
4.7.1. Arquillian REST 客户端扩展
有时你需要在一个黑盒环境中测试 REST 应用程序,测试行为就像是一个真实客户端,以确保当由外部源调用时,你的端点按预期工作。当你知道接口(合同)并且你有明确定义的输入,你还知道从资源调用中期望得到什么结果时,Arquillian REST 客户端扩展将对你很有用。让我们看看一个简洁的例子(code/comments/src/test/java/book/comments/boundary/CommentsResourceTest.java)。
列表 4.6. 使用 Arquillian REST 客户端扩展
@RunWith(Arquillian.class) *1*
public class CommentsResourceTest {
@Deployment(testable = false) *2*
public static WebArchive createDeployment() {
final WebArchive webArchive = ShrinkWrap
.create(WebArchive.class)
.addPackage(CommentsResource.class.getPackage())
.addClass(MongoClientProvider.class)
.addAsWebInfResource("test-resources.xml","resources.xml")
.addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml")
.addAsLibraries(Maven.resolver()
.resolve("org.mongodb:mongodb-driver:3.2.2")
.withTransitivity().as(JavaArchive.class));
System.out.println("webArchive = " +
webArchive.toString(true));
return webArchive;
}
@Test
public void getCommentsOfGivenGame(
@ArquillianResteasyResource final CommentsResource resource) *3*
throws Exception {
Assert.assertNotNull(resource);
final Response game = resource.getCommentsOfGivenGame(1); *4*
Assert.assertNotNull(game);
}
}
-
1 现在非常熟悉的@RunWith(Arquillian.class)测试注解
-
2 当你知道一个类中的所有测试都将作为黑盒客户端执行时,使用 testable = false 选项,而不是@RunAsClient。这里的打包添加了 REST CommentsResource.class 和所需的依赖项。
-
3 使用@ArquillianResteasyResource 注解直接将 CommentsResource.class REST 资源接口注入到测试方法中
-
4 在测试方法中直接使用 CommentsResource.class REST 资源接口
如果你曾经在使用基于 REST 的测试中使用过 JAX-RS ClientBuilder,那么在这里使用@ArquillianResteasyResource注解替换了多少样板代码应该很清楚。可以说,这个扩展已经移除了一大部分访问端点所需的代码。在底层,执行真实的 HTTP 请求和响应,但现在接口充当了一个简化的代理。
小贴士
你会在本书的代码中找到几个使用 JAX-RS ClientBuilder测试资源的测试。对于更精细的测试,它比@ArquillianResteasyResource更有用。你可以决定走哪条路——两者都有其优点。
如果你想在你的测试中使用 REST 扩展,你需要在你的构建脚本中添加以下依赖项:
org.jboss.arquillian.extension:arquillian-rest-client-api:1.0.0.Alpha4
org.jboss.arquillian.extension:arquillian-rest-client-impl-3x:1.0.0.Alpha4
注意
在撰写本文时,我们使用了最新可用的版本。它被标记为 alpha 版本,但非常稳定。请尽可能检查并使用最新版本。
4.7.2. Warp REST 扩展
Arquillian Warp REST 扩展允许你在服务器端测试你的 RESTful 应用程序。此扩展提供了拦截执行服务状态的实用工具,并在服务调用之前或之后可能执行的容器测试中提供状态。它支持包括 1.1 和 2.0 在内的 JAX-RS 主要版本以及最流行的实现。
这个主题是高级的,需要很好地理解底层协议才能有用。以下测试在端点调用后添加了一个检查(code/comments/src/test/java/book/comments/boundary/CommentsWarpTest.java)。内部,一个 REST 端点由一个理解客户端/服务器协议的 servlet 调用。可以在方法上使用 Warp 注解@AfterServlet和@BeforeServlet来访问当前的协议状态。
列表 4.7. 在端点之后添加检查
@WarpTest *1*
@RunWith(Arquillian.class)
public class CommentsWarpTest {
@BeforeClass
public static void beforeClass() { *2*
RegisterBuiltin.register(ResteasyProviderFactory
.getInstance());
}
@Deployment
@OverProtocol("Servlet 3.0") *3*
public static WebArchive createDeployment() {
final WebArchive webArchive = ShrinkWrap.create(WebArchive
.class).addPackage(CommentsResource.class
.getPackage()).addClass(MongoClientProvider.class)
.addAsWebInfResource("test-resources.xml",
"resources.xml").addAsWebInfResource
(EmptyAsset.INSTANCE, "beans.xml")
.addAsLibraries(Maven.resolver().resolve("org" +
".mongodb:mongodb-driver:3.2.2")
.withTransitivity().as(JavaArchive.class));
System.out.println("webArchive = " + webArchive.toString
(true));
return webArchive;
}
private CommentsResource resource;
@ArquillianResource
private URL contextPath; *4*
@Before
public void before() { *5*
final ResteasyClient client = new ResteasyClientBuilder()
.build();
final ResteasyWebTarget target = client.target(contextPath
.toExternalForm());
resource = target.proxy(CommentsResource.class);
}
@Test
@RunAsClient *6*
public void getCommentsOfGivenGame() {
Warp.initiate(() -> { *7*
final Response commentsOfGivenGame = resource
.getCommentsOfGivenGame(1);
Assert.assertNotNull(commentsOfGivenGame);
}).inspect(new Inspection() {
private static final long serialVersionUID = 1L;
@ArquillianResource
private RestContext restContext; *8*
@AfterServlet *9*
public void testGetCommentsOfGivenGame() {
assertEquals(HttpMethod.GET, restContext
.getHttpRequest().getMethod()); *10*
assertEquals(200, restContext.getHttpResponse()
.getStatusCode());
assertEquals("application/json", restContext
.getHttpResponse().getContentType());
assertNotNull(restContext.getHttpResponse()
.getEntity());
}
});
}
}
-
1 使用 @WarpTest 注解将测试类标记为 Warp 启用测试
-
2 确保 REST 环境对测试类可用
-
3 使用 @OverProtocol 注解定义底层协议
-
4 注入用于访问服务器的 REST 上下文路径
-
5 配置 REST 客户端代理到服务器资源
-
6 使用 @RunAsClient 注解在黑盒环境中隔离测试方法
-
7 启动 REST 调用并添加检查
-
8 提供 REST 调用上下文以访问协议信息
-
9 指示该方法应在 servlet 调用之后直接调用
-
10 访问协议状态允许测试。
如果你想在你的测试中使用 REST Warp 扩展,你需要在你的构建脚本中添加以下依赖项:
org.jboss.arquillian.extension:arquillian-warp-api:[version]
org.jboss.arquillian.extension:arquillian-rest-warp-impl-jaxrs-[version]
注意
如前所述,在撰写本文时,我们使用了代码中可用的最新版本。尽管这被标记为 alpha 版本,但它对测试来说非常稳定。请尽可能检查并使用最新版本。
4.8. 使用 Arquillian 测试 Spring 应用程序
我们在第一章中提到的微服务架构的关键优势之一是能够将服务开发委托给多个团队,使用多种技术。在多技术环境中,唯一重要的方面是资源组件可以通过定义的协议访问,在这种情况下是通过 HTTP 上的 REST。从团队到团队的角度来看,实际服务的实现是不相关的。为了演示这个原则,书中演示应用程序的视频服务是一个 Spring Boot 应用程序。您仍然需要像测试任何其他应用程序一样测试这个应用程序。
Arquillian 提供了一个 Spring v4+扩展,可用于辅助测试。
4.8.1. Arquillian Spring 框架扩展
Spring 框架扩展的工作方式类似于@SpringJUnit4ClassRunner,但它保持在 Arquillian 测试环境上下文中,并且启动测试时需要更少的样板代码。如果您已经熟悉 Spring JUnit 集成,我们建议您查看这种方法,并在将其摒弃之前进行有根据的评价。如果您不熟悉,那么这可能是您开始时最容易的方法,因为设置比 Spring 运行器更简洁。扩展支持以下实现:
-
将 Spring bean 注入测试类
-
从 XML 和 Java 配置中配置
-
向测试中注入配置在 Web 应用程序中(例如,
DispatcherServlet)的 bean,这些测试使用了@SpringWebConfiguration注解 -
支持 Spring(
@Autowired、@Qualifier、@Required)和 JSR 330(@Inject、@Named)注解 -
支持 bean 初始化(
@PostConstruct) -
自动打包
spring-context和spring-web组件
要将 Spring 扩展添加到您的项目中,您需要在构建脚本中添加以下组件(使用来自arquillian.org/modules/spring-extension的最新版本):
<dependency> *1*
<groupId>org.jboss.arquillian.extension</groupId>
<artifactId>arquillian-service-integration-spring-inject</artifactId>
<version>${arquillian.spring.version}</version>
<scope>test</scope>
</dependency>
<dependency> *2*
<groupId>org.jboss.arquillian.extension</groupId>
<artifactId>arquillian-service-integration-spring-javaconfig</artifactId>
<version>${arquillian.spring.version}</version>
<scope>test</scope>
</dependency>
-
1 必需的基本组件*
-
2 可选组件,当使用基于注解的配置时需要(推荐)*
我们不会查看基于文件的配置,因为我们确信即使是经验最丰富的 Spring 大师现在也在使用基于注解的配置。让我们看看一个包含所有相关元素的基本测试(code/video/i-tests/src/test/java/book/video/YoutubeVideosArquillianTest.java)。
列表 4.8. 使用 Spring 框架扩展
@RunWith(Arquillian.class)
@SpringAnnotationConfiguration(classes = *1*
{YoutubeVideosArquillianTest.class, ControllerConfiguration *1*
.class, ThreadExecutorConfiguration.class}) *1*
@Configuration
public class YoutubeVideosArquillianTest {
@Bean *2*
public JedisConnectionFactory jedisConnectionFactory() {
final JedisConnectionFactory jcf = new
JedisConnectionFactory();
jcf.setHostName("localhost");
return jcf;
}
@Primary
@Bean
public YouTubeVideos getYouTubeVideos() {
return new YouTubeVideos();
}
@Deployment *3*
public static JavaArchive createTestArchive() {
return ShrinkWrap.create(JavaArchive.class, "spring-test" +
".jar").addClasses(YouTubeVideos.class);
}
@Autowired *4*
private YouTubeVideos youtubeVideos;
@Test *5*
public void test() {
Assert.assertNotNull(this.youtubeVideos);
}
}
-
1 列出所有产生
@Bean实现的类(Spring 对 Java EE@Produces注解的等效)* -
2 典型的
@Bean生产者——如果需要,这是一个创建模拟的理想位置* -
3 常规的 Arquillian 部署归档*
-
4 使用
@Autowired注解注入实现* -
5 可以使用注入资源的标准测试*
这涵盖了您需要了解的所有关于测试标准 Spring 应用程序的知识。还有一些更高级的注释,这些注释超出了本书的范围,几乎需要 Spring 教程;访问扩展网站 mng.bz/Y4J8 以更好地了解可用的内容。以下注释受支持,并且 Spring 开发者会感到熟悉:
-
@SpringWebConfiguration—用于 MVC 测试 -
@EnableWebMvc—也用于 MVC 测试 -
@ComponentScan—扫描带有@Component注释的 Bean,而不是@Bean生产者 -
@OverProtocol—用于定义底层协议
4.8.2. 测试 Spring Boot 应用程序
Spring Boot 是一种迅速增长的流行技术。在撰写本文时,只有基于 Spring Boot 应用程序的 Spring 框架扩展的实验性 Arquillian 集成。
有可能创建一个忽略或绕过定义@SpringBootApplication注释的测试,但这将是作弊行为。目前,了解和理解如何测试旨在作为微服务运行的 Spring Boot 应用程序是有帮助的。该应用程序由一个带有@SpringBootApplication注释的Main类组成。这清楚地表明了 Spring Boot 应用程序的开始,并启动了类路径扫描和发现。这就是我们所说的绕过——您可以为经验丰富的 Spring 开发者创建一个模拟@SpringBootApplication执行的自动配置的 Arquillian 测试。我们将把这个留作经验丰富的 Spring 开发者的练习。
这里是书中视频服务项目使用的Main类。
列表 4.9. code/video/src/main/java/book/video/Main.java
package book.video;
import org.springframework.boot.actuate.system
.ApplicationPidFileWriter;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.scheduling.annotation.EnableAsync;
import java.util.HashMap;
import java.util.Map;
@SpringBootApplication *1*
@EnableAsync
public class Main {
public static void main(final String[] args) { *2*
final Map<String, Object> map = new HashMap<>();
map.put("endpoints.shutdown.enabled", true);
map.put("endpoints.shutdown.sensitive", false);
new SpringApplicationBuilder(Main.class).listeners(new
ApplicationPidFileWriter("./video.pid"))
.logStartupInfo(true).properties(map).run(args);
}
}
-
1 将 Main 类标记为——您猜对了——Spring Boot 应用程序
-
2 一个可运行的 Java 应用程序类的标准静态 void main 方法
下一步是告诉测试类在哪里可以找到Main类。这是通过添加指向当前类的@SpringApplicationConfiguration来完成的(code/video/i-tests/src/test/java/book/video/YoutubeVideosTest.java)。
列表 4.10. 添加@SpringApplicationConfiguration
@RunWith(SpringJUnit4ClassRunner.class) *1*
@SpringBootTest(classes = {Main.class}) *2*
public class YoutubeVideosTest {
private static RedisServer redisServer;
@BeforeClass
public static void beforeClass() throws Exception {
redisServer = new RedisServer();
redisServer.start();
}
@AfterClass
public static void afterClass() {
redisServer.stop();
}
@Rule
public RedisRule redisRule
= new RedisRule(newManagedRedisConfiguration().build());
@Autowired *3*
YouTubeVideos youtubeVideos;
@Test
@UsingDataSet(loadStrategy = LoadStrategyEnum.DELETE_ALL) *4*
@ShouldMatchDataSet(location = "expected-videos.json")
public void shouldCacheGamesInRedis() {
youtubeVideos.createYouTubeLinks("123", Collections
.singletonList("https://www.youtube.com/embed/7889"));
}
}
-
1 使用 Spring JUnit 类初始化单元测试,而不是 Arquillian
-
2 指向 Spring Boot 应用程序类,Main.java
-
3 注入一个 Bean 类
-
4 从现在开始,测试将完全按照您预期的那样工作。
4.9. 更复杂的 Arquillian 测试示例
接下来的几节将简要介绍一些示例测试,这些测试基于我们刚刚解释的内容。如果您看到尚未讨论的内容,请不要担心:记下来,并尝试通过阅读代码来找出它是做什么的。Arquillian 试图尽可能容易理解,但丰富的插件有时会增加学习曲线。以下章节在介绍这些高级插件功能的同时进行。
4.9.1. 测试远程组件
远程组件是您的微服务领域组件接触任何可能被认为是远程资源的地方。这不会包括像远程数据库这样复杂的东西,因为那属于持久化范围,并且被 ORM 层或其他传统 API 所掩盖。更多地考虑那些您必须专门编写客户端访问代码的情况——您的远程 API。
您想要测试微服务领域组件与远程组件之间的交互;而不是远程组件与实际远程资源之间的交互——这将被视为合同测试,它包含在第六章(chapter 6)中。在组件测试中,您不允许在本地机器之外进行调用。
以下列表是一个代码重用的示例(code/game/src/test/java/book/games/arquillian/ArquillianAbstractTest.java)。您将把它用作一些测试的基础类:创建基本部署存档和模拟远程调用。快速浏览一下,然后我们将进行解释。
列表 4.11. 远程组件的测试
...
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
...
public abstract class ArquillianAbstractTest {
@Before
public void before() {
stubFor(get(anyUrl()).withQueryParam("search", equalTo *1*
("The" + " Legend of Zelda: Breath of the Wild")) *1*
.willReturn(aResponse().withStatus(200).withHeader *1*
("Content-Type", "application/json") *1*
.withBody("[{\"id\":7346,\"name\":\"The " + *1*
"Legend of Zelda: Breath of the " + *1*
"Wild\"}]"))); *1*
}
@Rule *2*
public WireMockRule wireMockRule = new WireMockRule(8071);
public static WebArchive createBaseDeployment(final String name) { *3*
return ShrinkWrap.create(WebArchive.class, name + ".war")
.addClasses(GamesResource.class,
ExecutorServiceProducer.class, GamesService
.class, IgdbGateway.class,
SearchResult.class, Games.class, Game
.class, ReleaseDate.class)
.addAsResource("test-persistence.xml",
"META-INF/persistence.xml")
.addAsWebInfResource(EmptyAsset.INSTANCE, "beans" +
".xml");
}
}
-
1 在每个测试之前(@Before)对
www.igdb.com/API 远程调用进行 WireMock 模拟 -
2 WireMock 规则定义了要监听的端口。该规则可以做的不仅仅是定义端口。
-
3 创建用于测试的最小存档的实用方法。您可以在实现中使用此存档,并使用 ShrinkWrap 方法向其中添加更多项目。
WireMock 基本上是一个真实的 HTTP 代理服务器,它监听一个定义的端口(使用 WireMockRule)。IGDB API 定义良好且版本稳定。它是一个 REST API,就像微服务资源组件所描述的那样。这意味着您可以在定义的端口上拦截远程调用并模拟运行时响应。您不需要在运行时类中更改任何一行代码!
在 列表 4.11 中,stubFor 是一个综合用例,它拦截了对已知 API 路径 /games/search 的 GET 调用,使用查询参数 q 的值为 "Zelda",以及 HTTP Authorization 标头等于“Token ...”。如果一切匹配,它将返回一个包含特定正文内容的 JSON 响应,正文内容由 IGDB API 定义(mng.bz/xVF4)。
WireMock 的力量
在 列表 4.11 中,WireMock (wiremock.org) 进行了一些相当强大的操作。在应用程序中,您会找到一个名为 IgdbGateway.class 的类——这是微服务远程资源组件 GamesService.class 所封装/使用的网关组件,用于对 www.igdb.com API 进行真实的远程调用。
这是一个没有接口的具体类,被注入到应用程序中。如果你想要模拟这个类,你会怎么做?如果没有提取接口并分离实现,以便可以使用虚拟实现进行测试,你会遇到困难。在过去,这正是你可能会做的事情;或者你可能需要想出另一个复杂的解决方案,比如在测试中使用 CDI 替代方案,只是为了运行单元测试。
另一种方法可能是使用类似于 Mockito 的when([方法调用]).thenReturn([响应]),这对于模拟接口和某些对象来说非常好。我们说某些,因为模拟一个具体对象可能会产生许多未知的副作用,这需要适当的分析和对当前类的良好理解。即使你做到了这一点,当这个模拟对象被打包并部署到容器中时会发生什么?简单的答案是,试试看。长答案超出了本书的范围。
为了看到stubFor WireMock 拦截器的重要性,让我们看看一个使用它的测试:ArquillianRemoteTest类(code/game/src/test/java/book/games/arquillian/ArquillianRemoteTest.java)。
列表 4.12. 在测试中使用 WireMock 拦截器
@RunWith(Arquillian.class)
public class ArquillianRemoteTest extends ArquillianAbstractTest { *1*
@Deployment *2*
public static WebArchive createDeployment() {
return createBaseDeployment(ArquillianRemoteTest.class
.getName()).addClass(ArquillianRemoteTest.class)
.addClass(ArquillianAbstractTest.class)
.addAsLibrary(Maven.resolver().resolve("com.github"
+ ".tomakehurst:wiremock-standalone:2.2.1")
.withoutTransitivity().asSingleFile());
}
@Inject *3*
private IgdbGateway gateway;
@Test *4*
public void testSearchGames() throws Exception {
Assert.assertNotNull(this.gateway);
final JsonArray json = gateway.searchGames("The Legend of "
+ "Zelda: Breath of the Wild");
Assert.assertNotNull(json);
final JsonObject game = json.getJsonObject(0);
Assert.assertEquals("Unexpected id", 7346, game
.getJsonNumber("id").intValue());
Assert.assertEquals("Unexpected name", "The Legend of " +
"Zelda: Breath of the Wild", game.getString("name"));
}
}
-
1 测试类扩展了基类,并按照常规方式进行注解。
-
2 使用实用方法创建存档,并添加特定于此测试的元素——特别是 WireMock 独立库
-
3 注入未更改的具体 IgdbGateway 类
-
4 指导对具体类方法的测试,该方法被 WireMock 代理拦截
注意
在此阶段,你可以注入并测试GamesService.class,这在实际意义上是微服务远程资源组件。示例中相反地注入了IgdbGateway.class来演示对具体类的 WireMock 存根。
你可能会想知道IgdbGateway类是如何知道调用代理的。你可能记得在讨论arquillian.xml文件时,我们提到了javaVmArguments属性的的重要性。此属性使用标准的 Java 命令行选项-D定义 JVM 系统属性。IgdbGateway定义了IGDB_API_KEY属性来设置 API 密钥,以及IGDB_HOST属性来设置主机 URL,以访问真实服务:
...
<property name="javaVmArguments">
-DIGDB_API_KEY=dummyKey -DIGDB_HOST="http://127.0.0.1:8071/"
</property>
...
当测试运行时,Arquillian 启动容器并通过配置提供参数。IgdbGateway类读取属性,并使用虚拟密钥和 URL 进行远程调用。这些真实远程调用被本地的 WireMock 代理拦截,并返回构造的响应。这有多酷?你将在本书的其余部分看到更多关于 WireMock 的内容。
4.9.2. 测试资源组件
资源组件是你通过 RESTful 端点公开微服务功能的地方——就像你自己的 API。为了正确测试这个组件,你必须像客户端应用程序一样思考。客户端会如何调用野外端点的资源?
让我们尽可能保持这个测试的简单性。类似于之前的测试,这个测试通过扩展ArquillianAbstractTest来重用存档和 WireMock 代理。让我们首先看看测试,然后我们将解释正在发生的事情(code/game/src/test/java/book/games/arquillian/ArquillianResourceTest.java)。
列表 4.13. 资源组件的测试
@RunWith(Arquillian.class)
public class ArquillianResourceTest extends ArquillianAbstractTest *1*
{
@Deployment(testable = false) *2*
public static WebArchive createDeployment() {
return createBaseDeployment(ArquillianResourceTest.class
.getName());
}
@Test
@RunAsClient *3*
public void testSearch(@ArquillianResource final URL url) *4*
throws Exception {
final Client client = ClientBuilder.newBuilder().build(); *5*
final WebTarget target = client.target(url.toExternalForm()
+ "?query=The Legend of Zelda: Breath of the Wild");
final Future<Response> futureResponse = target.request() *6*
.async().get(); *6*
final Response response = futureResponse.get(5, TimeUnit *6*
.SECONDS); *6*
final List<SearchResult> results
= response.readEntity(new GenericType<List<SearchResult>>(){ *7*
});
Assert.assertEquals("Unexpected title", "The Legend of " +
"Zelda: Breath of the Wild", results.get(0).getName
());
}
}
-
1 通过扩展提供基线测试环境的基础类来重用代码
-
2 新的注解选项,用于创建部署存档
-
3 新的 @RunAsClient 注解用于测试方法
-
4 新的注解参数 @Arquillian-Resource,用于注入 URL
-
5 使用注入的 URL 构建 JAX-RS 目标进行调用
-
6 从服务器获取并等待异步响应
-
7 从响应中检索强类型集合以进行测试
在这里有几个新的元素需要讨论。首先,我们将讨论@Deployment(testable = false)和@RunAsClient注解,因为它们或多或少意味着相同的事情。将testable设置为false定义的存档将完全与测试类隔离。这是一个黑盒测试,Arquillian 对存档包含的内容一无所知,并按原样部署存档。你无法在测试类中注入或使用存档中的任何内容,这使得这个类成为当前用例的完美示例。使用@Deployment(testable = false)实际上意味着这个类中的所有测试方法都将作为一个独立的客户端运行。
为什么需要在测试方法上使用@RunAsClient注解?你不需要!这个例子中包含它只是为了缩短练习。@RunAsClient基本上意味着,“从这个测试中独立运行这个特定的测试方法。”你通常会在测试类中使用一种或另一种方法,但不会同时使用两种。@RunAsClient的优势因此变得明显。你会在具有多个混合模式测试方法的测试类中使用它,其中一些运行独立,而另一些则使用注入的资源。
下一个新注解@ArquillianResource可以用作字段注解,或者,如你所见,用作方法注解。你可以编写自己的ArquillianResource提供程序,以便几乎可以通过这个注解注入任何内容。在这里描述它将超出范围;如果你想了解更多,请在互联网上搜索“arquillian-extension-producer”。
默认情况下,你可以注入一个与测试范围相关的资源 URL。你已经知道如何配置服务器和更改监听端口,例如。但是你的独立客户端测试如何知道这一点呢?你可以尝试扫描并读取配置,但这会容易出错,如果容器配置为使用随机端口,这也会变得毫无用处。现在你知道你可以使用@ArquillianResource注解来注入你的 URL。
4.9.3. 测试领域组件
从测试的角度来看,描述领域组件是有些棘手的,不是因为测试它很难,而是因为它特定于你的应用程序。需要记住的主要点是无论怎样你都需要对其进行测试。Arquillian 可能对这个任务有用也可能没有用。
你应该始终遵循以下基本规则:
-
使用单独的单元测试。
-
永远不要跨越边界。
-
如果可能的话,被测试的类应该是测试中唯一的具体类。
4.9.4. 测试持久化组件
你将要编写的几乎每个应用程序都将包含某种持久化层。这可能只是一个简单的属性文件来存储应用程序设置,或者,在相反的一端,像 SAP 持久化服务。通常情况下,你会使用数据库。如今,尤其是在 Java EE 中,你可能会想要在对象关系映射(ORM)系统后面隐藏本地数据库实现。我们假设你已经熟悉诸如 Java 持久化 API(JPA)和 SQL 等主题。如果你不熟悉,请在继续到第五章之前,花些时间了解这些重要主题,其中详细介绍了在完全集成测试中测试持久化组件。
你知道你不允许在本地机器之外跨越远程边界,那么你如何应对这个挑战并使用数据库呢?你不想改变持久化上下文的语义,因此你可以模拟EntityManager或针对嵌入式数据库进行测试。对于需要测试持久化的组件测试来说,模拟EntityManager并不是一个好主意,因为它绕过了持久化。因此,使用嵌入式数据库是我们的首选选项。
Java 生态系统中存在几个嵌入式数据库,一些在 SQL 空间,其他在 NoSQL 生态系统中。其中一些数据库使用磁盘存储,而其他则使用易失性的内存存储。由于它们的高性能,如果可能的话,我们建议你使用内存数据库进行测试。表 4.2 列出了 Java 生态系统中提供嵌入式/内存数据库的几个供应商。
表 4.2. 数据库供应商
| 类型 | 项目 | 网站 |
|---|---|---|
| SQL | HyperSQL | hsqldb.org |
| SQL | H2 | www.h2database.com/html/main.html |
| SQL | Derby | db.apache.org/derby |
| NoSQL MongoDB | Fongo | github.com/fakemongo/fongo |
| NoSQL Neo4J | Neo4J | neo4j.com/ |
| NoSQL Infinispan | Infinispan | infinispan.org/ |
注意,目前,在 SQL 领域,你可以在开箱即用的基础上从几个选项中进行选择。对于 NoSQL 生态系统,你需要依赖供应商对嵌入式模式的版本支持。
根据我们的经验,用于测试目的的最佳 SQL 嵌入式内存数据库是 H2。它为读写操作都提供了良好的性能,并为 IBM DB2、MS SQL Server、MySQL、Oracle 和 PostgreSQL 等主要 SQL 供应商提供了兼容模式。所有这些加上仅 1 MB 的小体积使其难以被超越。
示例 Gamer 应用程序使用一个 SQL 数据库和两个 NoSQL 数据库(Redis 和 MongoDB)。不幸的是,没有嵌入式 Redis 的支持;在无法以光速运行组件测试的情况下,我们建议你跳过它们,而是编写一个集成测试。你将在第五章(kindle_split_014_split_000.xhtml#ch05)中了解更多关于这一点。
要使用 H2,你需要使用你选择的构建工具将 com.h2database:h2:<version> 依赖项导入你的项目,范围设置为 test。下一步是使用 H2 特定的参数配置 JDBC 驱动程序;例如:
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">
<persistence-unit name="pu" transaction-type="RESOURCE_LOCAL">
<properties>
<property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
<property name="javax.persistence.jdbc.url" value="jdbc:h2:mem:test"/> *1*
<property name="javax.persistence.jdbc.user" value="sa"/>
<property name="javax.persistence.jdbc.password" value=""/>
</properties>
</persistence-unit>
</persistence>
- 1 最后的 URL 参数(test)用作数据库标识符或名称。
要使用 H2,你需要将 H2 JDBC 驱动程序设置为 org.h2.Driver,并将连接的 URL 设置为 jdbc:h2:mem:test。此 URL 定义了 mem 关键字,它告诉 H2 驱动程序以内存模式工作。URL 的最后一个参数是数据库标识符,它用于在同一个 JVM 中启动多个内存数据库时避免冲突。最后,你需要设置用户名和密码(对于测试,可以使用 sa 作为用户名,空密码)。按照这些步骤重新配置持久层(边界)以使用 H2 数据库而不是为生产配置的数据库。
应用程序中所有使用 SQL 数据库的服务都应该使用 H2 为持久边界编写组件测试,这将确保测试性能不受影响。使用 ShrinkWrap 微部署功能将测试持久性配置包含到部署文件中:
return ShrinkWrap.create(WebArchive.class, "test.war")
.addAsResource(
"test-persistence.xml",
"META-INF/persistence.xml");
ShrinkWrap 将 src/test/resources 中的 test-persistence.xml 复制并添加到微部署文件中的位置为 META-INF/persistence.xml。
评论服务在持久层使用 MongoDB 数据库。MongoDB 也不是嵌入式解决方案,所以每次需要使用它时,你都需要启动一个 mongod 进程。为了避免在测试期间出现这个问题,Fongo 项目被建立。
Fongo 是 MongoDB 的内存中 Java 实现。它拦截了对标准mongo-java-driver的查找、更新、插入、删除和其他几个有用方法的调用。尽管它有一些限制,但它支持使用 MongoDB 所需的大部分常见操作。要使用 Fongo,你需要将com.github.fakemongo:fongo:<version>依赖项导入你的项目中,范围设置为test,使用你选择的构建工具。
下一步是实例化com.github.fakemongo.Fongo类,并获取一个com.mongodb.DB对象引用,在这种情况下是一个 Fongo 自定义实现:
Fongo fongo = new Fongo("test server"); *1*
com.mongodb.DB db = fongo.getDB("mydb"); *2*
com.mongodb.MongoClient mongo = fongo.getMongo();
-
1 创建一个 Fongo 实例
-
2 获取覆盖的 MongoDB 驱动程序类
最重要的一步是创建 Fongo 实例。之后,你可以从 Java 驱动程序中获取覆盖的 MongoDB 类,然后将它们传递给边界而不是真实类。
在第五章中,我们将更深入地探讨这个主题,并突出你在编写持久性测试时可能遇到的一些问题,以及如何解决这些问题。你刚刚学到的东西和第五章中的讨论之间的唯一区别是,这个示例使用了一个内存中的嵌入式数据库,而后来你将使用一个真实的远程数据库。概念是相同的。
当你阅读第五章时,请记住在这里学到的内容。同时,仔细查看示例代码,并将其作为你自己的测试模板使用。
练习
将本章内容付诸实践可能现在看起来有些令人畏惧,所以从小处着手,利用 ShrinkWrap 实用工具和 Arquillian 测试框架逐步建立信心。作为一个简单的练习,为了开始,找到示例代码中所有ShrinkWrap.create的使用;然后,使用生成的归档toString方法,将内容打印到控制台。这样做将帮助你理解归档是如何组成的,以及执行有用的 Arquillian 测试所需的内容。
对于一个更高级的任务,回顾游戏服务项目中的ArquillianResourceTest。你会看到它使用ClientBuilder来创建 REST 客户端。对于一个测试方法来说,这有很多样板代码,但你知道 Arquillian 可以在这里让你轻松一些。尝试将这个测试转换为使用 Arquillian REST 客户端,并看看你可以移除多少样板代码。
摘要
-
使用 Arquillian 工作流尽可能自动化你的测试。
-
使用 Arquillian 特定的注解注释一个标准测试类,以删除样板代码。
-
在基类或工具类中定义部署归档的重复元素——编写一次,即可重用。
-
将可测试的元素注入到测试类中以进行测试。
-
使用 Maven 配置文件为插件创建针对多个容器的灵活测试环境。
-
在 arquillian.xml 文件中覆盖默认的容器选项。
-
使用 Arquillian 扩展提供功能丰富的测试环境。
第五章. 集成测试微服务
本章涵盖
-
在微服务架构背景下理解集成测试
-
区分集成测试和组件测试
-
为持久层和网关层编写集成测试
-
在 Arquillian 中操作多个部署
在前面的章节中,你学习了如何为基于微服务架构编写单元和组件测试。还有集成测试,在微服务世界中,其含义与其他架构略有不同。
集成测试检查不同模块(或类)之间的交互,通常属于同一个子系统,以验证它们在提供高级功能时是否按预期协作。集成测试还检查通过子系统执行的所有通信路径是否正确,并检测每个模块可能对同伴如何行动的预期有任何不正确的假设。
注意
通常,在面向对象语言中,一个模块被认为是一个类。
通常来说,集成测试本身并不慢,但它们的速度取决于逻辑以及与被测试子系统模块的交互,这些交互会影响测试性能。由于这样的子系统通常需要一个或多个容器来运行(如 servlet 容器、CDI 容器、Spring 容器等),因此集成测试比单元测试慢,因为它们需要启动和停止所需的容器。
图 5.1 展示了集成测试可能的样子。如图所示,测试至少涉及调用一个模块(或类)及其所有相关联的同伴。此类测试的目的是验证的不是每个模块的行为,而是模块之间的通信。
图 5.1. 涉及不同模块的集成测试

在对集成测试的概念进行简要介绍之后,让我们看看集成测试如何应用于微服务架构。
5.1. 微服务架构中的集成测试
你已经了解了集成测试是什么;在本节中,你将看到如何将集成测试概念应用于微服务架构。集成测试可以编写来测试任何子系统。对于微服务架构,集成测试通常集中在验证负责与外部组件(如数据存储和/或其他(微)服务)通信的子系统之间的交互。
微服务中集成测试的目标是验证子系统(或具体模块)可以正确通信,而不是测试外部元素。因此,这些测试应仅涵盖子系统与外部组件之间集成的基本成功和错误路径。
图 5.2 展示了微服务场景中集成测试的架构。你可以看到测试是如何验证一个模块(或类)可以与外部组件通信,但又不测试外部组件。
图 5.2. 微服务中的集成测试

注意
由于你正在测试微服务所属的子系统与外部服务之间的通信是否可行,因此理想的情况是不使用任何测试替身(test doubles)来模拟外部组件。
请记住以下重要事项:
-
集成测试验证内部模块与外部组件(如数据库或其他(微)服务)之间的连接,而不是其他内部模块。
-
集成测试使用真实的外部组件来验证与真实服务的通信是否可行。
-
准备测试执行的环境可能会很困难且/或繁琐。
在我们查看具体示例之前,以下部分回顾了在微服务架构中应该使用集成测试测试微服务解剖学的哪些部分。在图 5.3 中,它们被虚线包围。
图 5.3. 在微服务架构中编写集成测试的层

5.1.1. 网关组件层
网关层包含连接到外部服务的逻辑,通常使用 HTTP/S 客户端。它们连接到系统中的另一个微服务或部署在你本地基础设施之外的服务。
集成测试负责验证服务连接以及检测任何协议问题,例如缺少 HTTP 头部、错误的 SSL 处理或请求/响应体不匹配。必须测试所有错误处理情况,以确保在出现此类条件时,服务和协议客户端的行为符合预期。
有时很难验证外部组件的异常行为,如超时或缓慢的响应。在第九章(kindle_split_018_split_000.xhtml#ch09)中,你将学习如何在不使用测试替身的情况下引发这些情况。
5.1.2. 数据映射器和仓库
仓库通过连接到数据源、在它们上执行查询以及将输出适配到领域对象模型来充当数据源的门径。集成测试负责验证数据源模式是否与代码期望的一致。在 NoSQL 数据库的情况下,这个假设尤为重要。因为它们没有模式,所以被认为是无模式的。你需要确保代码知道如何处理这种情况。当你使用 JPA 等对象关系映射(ORM)工具时,这些测试也让你有信心,任何在实体上配置的映射都是正确的。
现在你已经知道了如何将单元测试技术应用到微服务架构中,让我们看看你可以使用哪些工具来编写这些测试。
注意
许多用于编写集成测试的工具都可用,但本章我们将探讨的工具是目前 Java 社区最广泛采用和接受的。以下几节假设你已经阅读了 第四章,并且至少理解了 Arquillian 框架的基本知识。
5.2. 使用 Arquillian 持久化扩展进行持久化测试
为持久化层编写集成测试比简单的单元测试要复杂一些。因为存储系统中的结果都是持久的,任何测试的执行都可能通过改变数据而影响后续测试的执行。这可能会引起担忧:测试不再完全 隔离。它们通过数据相互连接。
在所有但少数边缘情况下,测试应该独立于外部因素以及彼此,因为一个测试执行的数据更改可能会以通常难以诊断的微妙方式导致其他测试失败。让我们看看一个简单的持久化测试中这种情况是如何发生的。假设你有一个具有两个方法的对象:一个用于创建新电影,另一个用于查找所有电影。一个可能的测试类可能如下所示:
@Test
public void shouldInsertAMovie() {
Movie insertedMovie = movieRepository.insert(movie);
assertThat(insertedMovie.getId(), notNullValue()); *1*
//You can add more assertions here
}
@Test
public void shouldFindAllMovies() {
movieRepository.insert(movie);
List<Movie>; movies = movieRepository.findAllMovies();
assertThat(movies, hasSize(1)); *2*
}
-
1 根据独特的约束条件,这个测试可能在第二次执行时失败。
-
2 根据执行顺序,可能已经插入了一个或多个电影。
如你所见,这个测试是不可预测的。尽管存储库代码可能编写正确,但测试可能会根据测试执行顺序而失败。如果 shouldInsertAMovie 测试方法最后执行(没有保证任何执行顺序),那么 shouldFindAllMovies 测试方法将失败,因为数据库中没有存储电影,而预期有一个。这证实了每个测试执行在数据方面相互隔离的重要性。图 5.4 说明了导致 shouldFindAllMovies 测试失败的测试执行。
图 5.4. 测试执行失败

图 5.5 展示了一个假阳性场景,其中所有测试都通过。一个 假阳性 可能会让你产生一种虚假的安全感。你可以看到这个测试在任意一次运行中都有可能失败,但你能否立刻发现原因?
图 5.5. 测试执行成功

使用正确的工具很容易解决这个问题:你只需要在每个测试方法执行之前将数据库数据回滚到已知和可预测的状态。这意味着在每次执行之前,数据库必须通过清理/回滚之前的修改来修改;然后通过为测试插入一些已知/预期的数据来重新创建数据库到一个已知的状态。遵循此策略,每个测试都将始终找到一个具有相同数据的数据库,独立于测试执行顺序,如图 5.6 所示。
图 5.6. 安全持久化测试的生命周期

在每次测试执行之前,数据库将清除之前所做的任何数据更改。然后,插入测试预期所需的所有数据;例如,一个测试可能验证一些预期的查询结果。在此回滚之后,你可以认为数据库处于一个已知状态。它只包含你期望存在的数据,每个测试都可以以一致的方式进行执行。
即使这个解决方案也有两个方面使其不完美并且可以改进:
-
维护数据库到一个已知状态涉及大量的样板代码,这些代码将在每个持久化测试之前重复。
-
没有标准机制来插入数据,因此你可能不得不反复编写 SQL 语句。
幸运的是,Arquillian 持久化扩展极大地改善了使用 Arquillian 编写持久化测试。Arquillian 持久化扩展可以帮助你正确地编写持久化测试。它提供了以下功能:
-
每个测试都包含在一个独立的交易中
-
使用 DbUnit 在类或方法级别进行数据库初始化
-
支持 SQL、JPA、NoSQL 等更多
-
支持 XML、Excel(XLS)、YAML 和 JSON 数据集格式
-
在每个测试方法执行之前清理数据库
-
使用 SQL 文件直接进行自定义
-
使用已知最终数据集在每次测试结束时比较数据库状态,这也支持列排除
-
统一程序性(DSL)和声明性(注解)的数据初始化方式
此扩展可以帮助你避免为每个测试编写大量的样板代码。
5.2.1. 声明式方法
使用 Arquillian 持久化扩展编写的测试可能看起来像以下这样:
@RunWith(Arquillian.class) *1*
public class GamesPersistenceTest {
@Deployment *2*
public static WebArchive createDeploymentPackage() {}
@EJB *3*
Games gamesService;
@Resource
javax.transaction.UserTransaction userTransaction;
@Test
@UsingDataSet("datasets/games.yml") *4*
public void shouldFindAllGames() {}
@Test
@ApplyScriptBefore("scripts/drop-referential-integrity.sql") *5*
@ShouldMatchDataSet("datasets/expected-games.yml") *6*
public void shouldInsertGames() {}
}
-
1 将测试定义为 Arquillian 测试
-
2 定义要使用的 ShrinkWrap 部署文件
-
3 注入任何 EJB 或甚至 UserTransaction
-
4 在执行测试方法之前用指定数据填充数据库
-
5 在执行测试方法之前应用给定的 SQL 脚本
-
6 验证在执行测试方法之后数据库内容是否与文件中定义的一致
注意,Arquillian 持久化扩展严重依赖于注解,这意味着你不需要在测试中编写任何样板代码。只需让扩展为你维护数据库到一个已知状态。
在示例中,使用 YAML 格式来填充和验证数据库。对于 YAML 数据集,文档最左侧的元素是表名。此元素可以包含一个元素列表(或数组)。YAML 中每个元素的列表用连字符(-)符号表示。每个元素是数据库中的一行。最后,每个元素包含表示列名和值的属性:
table1:
- column1: value1
column2: value2
- column1: value3
column2: value4
table2:
此示例定义了两个表。在 table1 中插入两行,向 column1 和 column2 添加值。
注意
您可以使用 null 关键字显式地为给定列设置 null 值。例如,producedBy: "[null]"。
在 图 5.7 的时序图中,您可以看到当执行 Arquillian 持久化扩展测试时内部发生的情况。首先,启动您选择的应用程序服务器,并且通过 ShrinkWrap 打包由部署方法定义的存档并部署。然后,测试开始执行,并且以下步骤为每个测试方法发生:
图 5.7. Arquillian 持久化扩展生命周期

1. 数据库被清理,以确保之前的执行不会影响当前的测试方法。
2. 整个测试在事务中执行。
3. 如果存在
@UsingDataSet注解,数据库将使用已知数据集进行初始化。4. 执行测试主体。
5. 如果存在
@ShouldMatchDataSet注解,则验证数据库的最终状态。
在执行所有测试之后,部署文件将被卸载,并且应用程序服务器将被终止。
您现在知道如何为 SQL 系统编写持久化测试。让我们看看如何为 NoSQL 数据库执行此操作。
5.2.2. 编程方法
您已经看到了如何通过使用注解来使用一些数据填充您贫血的环境。Arquillian 持久化扩展还允许您使用编程方法来填充数据。这种方法仅在以客户端模式使用 Arquillian(或使用 standalone 依赖项)时有效。编程方法的一个重要方面是您可以使用 Arquillian 运行器(@RunWith(Arquillian.class))或者,作为替代,注册一个 JUnit 规则(@Rule public ArquillianPersistenceRule arquillianPersistenceRule = new ArquillianPersistenceRule();)。
注意
在撰写本文时,注解方法只能与 DbUnit 一起使用。
标准关系型数据库管理系统
支持两种不同的技术来填充关系型数据库中的数据。第一种是前面章节中介绍的 DbUnit,第二种是 Flyway,这是一个用于描述数据库迁移的 Java 工具。
让我们通过 DbUnit 的一个示例来查看:
@RunWith(Arquillian.class)
public class DbUnitTest {
@DbUnit *1*
@ArquillianResource
RdbmsPopulator rdbmsPopulator;
@Test
public void should_find_all_persons() {
rdbmsPopulator.forUri(URI.create("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")) *2*
.withDriver(Driver.class)
.withUsername("sa")
.withPassword("")
.usingDataSet("test.json")
.execute(); *3*
// Testing part
rdbmsPopulator.forUri(URI.create("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"))
.withDriver(Driver.class)
.withUsername("sa")
.withPassword("")
.usingDataSet("test.json")
.clean(); *4*
}
}
-
1 设置 DbUnit 作为后端的 DbUnit 注解
-
2 配置 JDBC 连接,使用提供的数据集更新数据库
-
3 使用提供的数据集更新数据库
-
4 在下一次执行之前清理数据库
如你所见,这个测试与任何其他 Arquillian 测试非常相似,只有一个细微的差别:测试通过添加一个RdbmsPopulator实例来丰富。该实例负责为所需的 RDBMS 后端填充数据集。DbUnit 填充器被注入到测试中,并且位于类路径根目录的 test.json 文件被用来初始化给定的 SQL 数据库实例。以类似的方式,你可以通过注解一个字段为@Flyway来使用 Flyway,它指向数据集迁移目录。
NoSQL 系统
接下来,让我们看看如何使用相同的方法来填充 NoSQL 数据库的数据集。Arquillian 持久化扩展在底层使用NoSQLUnit来处理数据集格式和填充数据。(我们将在本章后面更深入地介绍这个主题。)目前,你需要知道的是,NoSQLUnit 类似于 DbUnit,但适用于 NoSQL 数据库。
目前,Arquillian 持久化扩展支持以下 NoSQL 数据库:Couchbase、MongoDB、Vault 和 Redis。以下示例使用 MongoDB:
@RunWith(Arquillian.class)
public class MongoDbTest {
@MongoDb *1*
@ArquillianResource
NoSqlPopulator populator;
@Test
public void should_populate_mongodb() {
populator.forServer("localhost", 27017) *2*
.withStorage("test")
.usingDataSet("books.json")
.execute(); *3*
// Testing part
populator.forServer(hostIp, port)
.withStorage("test")
.clean(); *4*
}
}
-
1 设置 MongoDB 作为后端的 MongoDB 注解
-
2 配置 MongoDB 连接
-
3 使用提供的数据集更新数据库
-
4 在下一次执行前清理数据库
这个测试与 SQL 数据库的测试类似。测试通过添加一个NoSqlPopulator实例来丰富,该实例负责为所需的 NoSQL 后端填充数据集。MongoDB 填充器被注入到测试中,并且位于类路径根目录的 books.json 文件被用来初始化指定的 NoSQL 数据库实例。
以类似的方式,你可以配置你的测试以使用任何其他支持的 NoSQL 数据库。
注意
每个支持的填充器都有自己的工件。例如,要使用 MongoDB 填充器,你需要在构建脚本中注册org.arquillian.ape:arquillian-ape-nosql-mongodb工件。
REST 服务
你已经学会了如何在执行测试之前将数据填充到持久存储中,以及如何在执行后清理环境。在微服务架构中,服务 A 通常调用服务 B 以获取数据是很常见的。在这种集成测试中,你需要在测试服务 A 的网关之前向服务 B 填充一些数据。Arquillian 持久化扩展也支持这种测试场景,使用与前面章节相同的方法。
对于 REST 服务,Postman (www.getpostman.com) Collection Format v2 (schema.getpostman.com/json/collection/v2.0.0/docs/index.html) 是用于设置要执行的操作数据集的格式。
关于 Postman
Postman 是一个 GUI 程序,允许您开发并测试(REST)API。它还支持从不同的格式(如 RAML 和 Swagger)导入 REST 定义。
在 Postman 中,集合是存储单个请求和响应的主要地方,以便可以组织以准确反映 API。从用户的角度来看,一个集合是一组可以逐个或全部执行的请求。一个集合可以导出为遵循schema.getpostman.com中定义的 JSON 模式的 JSON 文件。
Arquillian 持久性扩展用于 REST 服务读取遵循 Collection v2 格式的 JSON 文件,并使用所有定义的请求对指定的服务进行响应。要创建此文件,您可以遵循两种可能的方法:
-
从头开始创建一个遵循 Collection v2 模式的 JSON 文件。
-
使用 Postman 创建一个集合,并导出它(见图 5.8)。我们推荐使用这种方法,因为它是最简单、最快创建文件的方式。
图 5.8. 导出集合的示例

让我们看看一个使用 Postman 集合向服务填充 Hello 消息的示例。
列表 5.1. PostmanTest
@RunWith(Arquillian.class)
public class PostmanTest {
@Postman *1*
@ArquillianResource
RestPopulator populator;
@Test
public void should_get_messages() {
populator.forServer("example.com", 8080) *2*
.usingDataSets("message.json")
.execute(); *3*
// Testing part
}
}
-
1. 将 Postman 设置为后端以填充数据
-
2. 配置要更新的服务位置
-
3. 向配置的服务发送 REST 调用
此测试类似于数据库的测试。它通过一个负责为所需服务填充数据集的RestPopulator实例进行了丰富。Postman 填充器被注入到测试中,并且位于类路径根目录的 message.json Postman 集合文件被用来对配置的服务重放文件中定义的所有请求。
下一个展示的是 message.json 文件。
列表 5.2. message.json
{
"variables": [],
"info": {
"name": "Messenger",
"_postman_id": "1803c743-318a-8751-b982-4f9475a00cea",
"description": "",
"schema": "https://schema.getpostman.com/json/collection/v2.0.0/collectio
n.json"
},
"item": [
{
"name": "My message",
"request": {
"url": "http://localhost:8080/message",
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "text/plain",
"description": ""
}
],
"body": {
"mode": "raw",
"raw": "Message in a Bottle"
},
"description": ""
}
}
]
}
这个集合只包含一个请求(但可以包含多个列表),名称为My message。这将在指定的url上创建一个带有Content-Type头为text/plain和正文内容Message in a Bottle的POST请求,该请求在调用RestPopulator类的execute方法时执行。
注意
在此示例中,URL 字段指向localhost。同时,测试指向 example.com。这是可以的,因为 Arquillian 持久性扩展会根据RestPopulator中配置的来适配请求的url字段(主机和端口)。因此,在 message.json 请求中,运行时将 http://localhost:8080/message 替换为 http://example.com:8080/message。
现在您已经看到了如何使用 Arquillian 持久性扩展,让我们更深入地了解 NoSQLUnit 是如何工作的。
5.2.3. 使用 NoSQLUnit 进行持久性测试
在 NoSQL 数据库中,你面临与 SQL 数据库相同的问题,包括隔离问题。由于 NoSQL 数据库从测试生命周期的角度来看是持久的(尽管分布式缓存通常不是持久的),这些测试仍然需要管理数据库,以便将其恢复到已知状态。
要为 NoSQL 数据库编写持久化测试,有一个与 DbUnit 相当的项目,称为 NoSQLUnit。NoSQLUnit 是一个 JUnit 扩展,它帮助你按照 DbUnit 采取的相同方法编写 NoSQL 持久化测试,但适应了常见的 NoSQL 数据库。它具有以下功能:
-
管理数据库的生命周期,包括启动和停止周期。
-
在类或方法级别上初始化数据库。由于 NoSQL 空间的异构性,每个实现都有自己的格式。
-
在每个测试方法执行之前清理数据库。
-
使用已知的最终数据集比较每个测试结束时的数据库状态,并支持列排除。
-
支持 Cassandra、Couchbase、CouchDB、Elasticsearch、HBase、Infinispan、MongoDB、Vault、Neo4J 和 Redis。
再次强调,NoSQLUnit 可以帮助你避免为每个测试编写大量的样板代码。以下列表显示了一个使用 NoSQLUnit 编写的示例测试。
列表 5.3. CommentsPersistenceTest.java
public class CommentsPersistenceTest {
@ClassRule
public static InMemoryMongoDb inMemoryMongoDb =
newInMemoryMongoDbRule().build(); *1*
@Rule
public MongoDbRule embeddedMongoDbRule = newMongoDbRule()
.defaultEmbeddedMongoDb("test"); *2*
@Test
@UsingDataSet(locations="initialData.json") *3*
@ShouldMatchDataSet(location="expectedData.json") *4*
public void shouldInsertComment() {}
}
-
1 启动 MongoDB(FongoDB 项目)的内存实例
-
2 配置用于测试的数据库连接
-
3 在每个测试之前将 JSON 文档填充到 MongoDB 实例中
-
4 验证测试方法执行后数据库的内容是否符合文件定义
对于 MongoDB,NoSQLUnit 预期一个具有以下模式的 JSON 文件来填充和验证数据:
{
"collection1":{ *1*
"indexes":[ *2*
{
"index":{ *3*
"code":1
}
}
],
"data":[ *4*
{
"id":1,
"code":"JSON dataset"
},
{
"id":2,
"code":"Another row"
}
]
}
}
-
1 指定文档存储的集合
-
2 为集合注册索引
-
3 为代码字段定义一个索引
-
4 存储原始 JSON 文档的数组
任何根元素都代表一个可以插入文档的集合;在这种情况下,定义了集合 collection1。之后,你定义一个包含两种文档的数组:一种定义在集合中创建哪些索引,以及存储在集合中的原始 JSON 文档数组。
注意 NoSQLUnit 在执行每个测试方法之前清理数据库,然后使用给定数据填充数据库的方法与 Arquillian 持久化扩展类似。它还可以验证在测试方法执行后数据库的状态是否符合预期。
注意
没有任何阻止你在 Arquillian 运行器之外使用 NoSQLUnit 独立版本(使用 NoSQLUnit JUnit 运行器)。但我们鼓励你将 Arquillian 持久化扩展与 NoSQLUnit 一起使用。
现在您已经看到了如何使用 NoSQLUnit 与 MongoDB 一起使用。但每个 NoSQL 数据库都是不同的,很容易看出,与 Neo4J 这样的图数据库相比,MongoDB 的数据模型完全不同:一个处理节点及其之间的关系,而另一个在“桶”中存储数据。
接下来,让我们看看 NoSQLUnit 如何与 Redis 一起工作。第一步是配置一个 Redis 连接。与上一个示例中使用嵌入式连接不同,您将直接连接到一个已经运行的 Redis 实例。
列表 5.4. RedisPersistenceTest.java
public class RedisPersistenceTest {
@Rule *1*
public RedisRule redisRule = new RedisRule(
RemoteRedisConfigurationBuilder.newRemoteRedisConfiguration()
.host("192.168.1.1")
.build()); *2*
@Test
@UsingDataSet(locations="initialData.json") *3*
public void shouldGetData() {}
}
-
1 定义连接细节的规则
-
2 连接到一个已经运行的 Redis 服务
-
3 填充给定数据
当使用 Redis 时,NoSQLUnit 期望一个 JSON 文件,其模式如下,用于填充和验证数据:
{
"data":[
{"simple": [ *1*
{
"key":"key1",
"value":"value1"
}
]
},
{"list": [{ *2*
"key":"key3",
"values":[
{"value":"value3"},
{"value":"value4"}
]
}]
},
{"sortset": [{ *3*
"key":"key4",
"values":[
{"score":2, "value":"value5" },
{"score":3, "value":1 },
{"score":1, "value":"value6" }]
}]
},
{"hash": [ *4*
{
"key":"user",
"values":[
{"field":"name", "value":"alex"},
{"field":"password", "value":"alex"}
]
}
]
},
{"set":[{ *5*
"key":"key3",
"values":[
{"value":"value3"},
{"value":"value4"}
]
}]
}
]
}
-
1 简单的键/值
-
2 键和值列表
-
3 按分数排序的键和值集合
-
4 包含键/值元素的哈希
-
5 包含唯一值的键集合
根元素必须命名为data。根据您需要存储的结构化数据类型,根元素后面应跟一个或多个子元素。key字段设置元素的键,value字段用于设置值。您可以存储以下类型的元素:
-
simple—包含一个简单的键/值条目数组。 -
list—包含一个key字段用于键名和一个value字段,其中包含一个值数组。 -
set—在集合中存储一个键(不允许重复)。结构与list元素相同。 -
sortset—在排序集合中存储一个键。每个值还有一个score字段,其类型为Number,用于指定其在排序集合中的顺序。 -
hash—在字段/值映射中存储一个键。field字段设置字段名称,value设置字段的值。
显然,根据定义的 NoSQL 数据库,数据填充文件可能非常不同。
现在我们已经介绍了两种用于编写数据映射器和存储库测试的技术,让我们看看另外两种将帮助您执行网关的集成测试。
5.2.4. 使用 Arquillian 多部署进行持久性测试
到目前为止,本书中的每个测试类都有自己的部署方法。但当你编写网关组件的集成测试时,您可能需要部署两个存档:一个包含网关类及其相关类,另一个包含网关连接到的服务。
当然,只有当您能够将测试部署到其他微服务时,您才能有多个部署。例如,如果您正在与一个位于您项目控制之外的外部服务通信,例如 YouTube 或 IGDB 网站,您需要直接使用该外部服务。
此外,有时一个服务无法使用 Arquillian 进行部署;可能是因为它不是用 Java 编写的,或者它是由外部团队编写的,您没有访问权限,因此无法部署它。在这些情况下,您需要使用一个已部署服务的预发布服务器,以测试网关对真实服务的测试。
到目前为止的示例都使用了单个部署进行测试。Arquillian 允许每个测试类有多个部署,并且还允许您在每个测试方法上操作多个部署。@Deployment注解有一个在多部署使用中必需的属性:name。为存档设置自己的名称将覆盖默认的DEFAULT值。order属性在针对多个部署进行测试时也可能很有用;它定义了部署发送到容器的顺序,如果顺序很重要。您还需要使用@OperateOnDeployment Arquillian 注解来指定测试使用的部署上下文。此注解接受一个字符串,该字符串必须与@Deployment注解中指定的名称匹配。
这里是一个多部署的例子:
@Deployment(name = "X", order = 1) *1*
public static WebArchive createDep1() {}
@Deployment(name = "Y", order = 2) *2*
public static WebArchive createDep2() {}
@ArquillianResource @OperateOnDeployment("X") *3*
private URL firstDeployment
@Test @OperateOnDeployment('X') *4*
public void test(@ArquillianResource @OperateOnDeployment("Y")
URL secondDeployment) {} *5*
-
1 部署第一个部署文件
-
2 部署第二个部署文件
-
3 首次部署的 URL
-
4 在 X 部署上下文中运行的测试
-
5 第二次部署的 URL
在这里,两个存档被部署到应用程序服务器。随后,您可以使用@OperateOnDeployment注解通过它们部署的URL引用每个存档,或者为测试选择部署上下文。
警告
Arquillian 多部署仅在您为所有部署工件使用相同容器时才有效。您不能在同一个测试中混合两个或更多不同的容器。如果您为每个微服务使用不同的应用程序服务,这是一个限制;如果您使用相同的应用程序服务,则可以使用这种方法为网关应用程序编写集成测试。
注意
您可以使用 Arquillian 多部署来测试集群功能或集群的配置。
Arquillian 多部署对于编写验证两个服务之间通信的集成测试非常有用。但另一个 Arquillian 特性可能有助于您测试网关组件对其他服务的测试:一个 Arquillian 序列。
5.2.5. 使用 Arquillian 序列进行持久性测试
有时候,当您为与另一个服务通信的网关编写测试时,您可能想要设置集成测试的执行顺序。一个典型的例子是身份验证:在访问外部服务之前,您可能需要对其进行身份验证。因此,似乎登录服务的测试应该在任何其他测试之前执行。测试依赖于执行顺序。
有许多不同的策略来解决这个问题,但在 Arquillian 中,您可以使用 InSequence 注解。InSequence 强制 JUnit 测试的方法执行顺序。它接收一个整数作为参数,该参数表示每个测试方法的执行顺序:
@Test
@InSequence(1) *1*
public void login() {}
@Test
@InSequence(2)
public void logout() {}
- 1 设置执行顺序
此示例使用 @InSequence 首先执行 login 测试,然后执行 logout 测试。
注意
您可以从您的 IDE 中单独运行 login 和 logout 测试。当完整测试类执行时,InSequence 注解非常有用。
现在您已经熟悉了集成测试工具,让我们看看您需要做什么才能开始使用它们。
5.2.6. 构建脚本修改
要使用 Arquillian Persistence Extension 或 NoSQLUnit,您需要在您的构建脚本中将它们定义为测试依赖项。在 Gradle 的情况下,此信息位于 dependencies 块中。
要使用 Arquillian 多部署或 @InSequence 注解,您不需要添加额外的依赖项——只需 Arquillian 依赖项,因为此功能来自 Arquillian 核心。正如您在 第四章 中所看到的,要使用 Arquillian,您需要注册一个 Arquillian BOM 文件和一个 Arquillian JUnit 依赖项;如果您使用 WildFly Swarm,您需要添加 WildFly Swarm Arquillian 扩展。在添加 Arquillian 作为依赖项后,您就可以开始编写 Arquillian 测试并使用多部署和序列功能。
如果您需要为 SQL 持久层编写集成测试,您必须添加 Arquillian Persistence Extension 依赖项:
dependencies {
testCompile group: 'org.jboss.arquillian.extension',
name: 'arquillian-persistence-dbunit',
version: '1.0.0.Alpha7'
}
最后,为了编写 NoSQL 数据库的集成测试,您需要添加 NoSQLUnit 依赖项。因为 NoSQL 数据库根据定义是异构的,每个 NoSQLUnit 支持的数据库都有自己的依赖项。这是 MongoDB 的依赖项:
dependencies {
testCompile 'com.lordofthejars:nosqlunit-mongodb:1.0.0-rc.5'
}
这是 Redis 的依赖项:
dependencies {
testCompile 'com.lordofthejars:nosqlunit-redis:1.0.0-rc.5'
}
一旦在构建脚本中注册了依赖项,您就可以开始编写存储库和网关的集成测试。
5.3. 为 Gamer 应用程序编写集成测试
在本节中,您将为 Gamer 应用程序编写集成测试。通常,单元测试是在事实上的测试目录 src/test/java 中创建的。对于集成测试,您可以遵循三种不同的策略:
-
在 src/test/java 中创建一个特殊的包,例如,
integration-tests。 -
在项目中创建另一个源文件夹,例如,src/integrationTests/java。
-
在主项目中创建一个新的模块/子项目,例如,
integration-tests。
这些策略中的每一个都有其优缺点,可能需要您修改您的构建脚本。我们没有关于哪种策略最好的强烈意见;这取决于您的团队感到舒适的程度。在这本书中,您将使用第一种和第三种策略,因为它们是最常见且最容易与构建工具集成的。
5.3.1. 测试 Comments 类
让我们看看如何编写 book.comments.boundary.Comments 类的集成测试。这个简单的类,如下一列表所示,实现了在 MongoDB 中存储评论和从 MongoDB 获取带评分的评论的逻辑(code/comments/src/main/java/book/comments/boundary/Comments.java)。它还从 resources.xml 文件中提供的配置创建 MongoDB 连接。
列表 5.5. Comments 类
@Dependent
public class Comments {
private static final String COMMENTS_COLLECTION = "comments";
private static final String MATCH = "{$match:{gameId: %s}}";
private static final String GROUP = "{ $group : " + " " +
" { _id : \"$gameId\", " + " comments: { " +
"$push: \"$comment\" }, " + " rate: { $avg: " +
"\"$rate\"} " + " count: { $sum: 1 } " + " " +
" }" + "}";
@Resource(name = "mongodb")
private MongoClientProvider mongoDbProvider;
private MongoCollection<Document>; commentsCollection;
@PostConstruct
public void initComentsCollection() {
commentsCollection = mongoDbProvider.getMongoClient()
.getDatabase(mongoDbProvider.getDatabase())
.getCollection(COMMENTS_COLLECTION);
}
public String createComment(final Document comment) {
commentsCollection.insertOne(comment);
return comment.getObjectId("_id").toHexString();
}
public Optional<Document>; getCommentsAndRating(final int gameId) {
final AggregateIterable<Document>; result =
commentsCollection.aggregate
(createAggregationExpression(gameId));
return Optional.ofNullable(result.first());
}
private java.util.List<Document>; createAggregationExpression
(final int gameId) {
return Arrays.asList(Document.parse(String.format(MATCH,
gameId)), Document.parse(GROUP));
}
}
列表 5.6 展示了对 createComment 逻辑的测试(code/comments/i-tests/src/test/java/book/comments/CommentsTest.java)。在这个持久性测试中,你需要三个元素:
-
一个正在运行的 MongoDB 实例。它可以手动启动或使用构建脚本启动。在 第八章 中,你将了解另一种使用 Docker 的方法。
-
一个空的 MongoDB
comments集合,用于插入数据。在空集合中创建一个文档很重要,以避免任何约束违规的可能性。 -
预期数据集,以验证
comments集合具有预期数据。
列表 5.6. 测试 createComment 逻辑
@RunWith(Arquillian.class) *1*
public class CommentsTest {
@Deployment
public static WebArchive createDeployment() {
final WebArchive webArchive = ShrinkWrap.create(WebArchive
.class).addClasses(Comments.class,
MongoClientProvider.class).addAsWebInfResource
(EmptyAsset.INSTANCE, "beans.xml").addAsLibraries( *2*
Maven.resolver().resolve("org" +
".mongodb:mongodb-driver:3.2.2", "com" +
".lordofthejars:nosqlunit-mongodb:0.10.0")
.withTransitivity().as(JavaArchive.class))
.addAsWebInfResource("resources-test.xml",
"resources.xml") *3*
.addAsWebInfResource("expected-insert-comments" +
".json",
"classes/book/comments/expected-insert" +
"-comments.json"); *4*
return webArchive;
}
@Rule *5*
public MongoDbRule remoteMongoDbRule = new MongoDbRule(mongoDb
().databaseName("test").host("localhost").build());
@Inject *6*
private Comments comments;
@Test
@UsingDataSet(loadStrategy = LoadStrategyEnum.DELETE_ALL) *7*
@ShouldMatchDataSet(location = "expected-insert-comments.json") *8*
public void shouldInsertAComment() {
final Document document = new Document("comment", "This " +
"Game is Awesome").append("rate", 5).append
("gameId", 1);
comments.createComment(document);
}
}
-
1 将测试设置为 Arquillian 测试
-
2 添加 NoSQLUnit 库存档,因为测试是在容器上执行的
-
3 将 MongoDB 测试配置文件 resources-test.xml 重命名为正确的名称和位置
-
4 将预期数据集添加到包中
-
5 注册 NoSQLUnit MongoDB 规则
-
6 将注释边界类注入到测试中
-
7 在执行测试之前清理 MongoDB 数据库
-
8 在注释集合中设置预期数据
关于持久性测试需要注意以下事项:
-
这是一个正常的 Arquillian 测试,它执行 NoSQLUnit JUnit 规则。
-
测试是在容器端执行的,这意味着你必须将所有需要的添加到
WebArchive的类路径中。 -
UsingDataSet和ShouldMatchDataSet通过断言测试始终处于已知/期望的状态来简化持久性状态的管理。
预期数据集如下所示(code/comments/i-tests/src/test/resources/expected-insert-comments.json)。
列表 5.7. 数据集
{
"comments": [
{
"comment" : "This Game is Awesome",
"rate": 5,
"gameId": 1
}
]
}
在这里,你设置测试执行后 MongoDB 数据库的期望状态。在这种情况下,数据库应包含一个名为 comments 的集合,该集合包含一个具有以下三个属性的文档:
-
comment,其值为"This Game is Awesome"。 -
rate,其值为5 -
gameId,其值为1
执行测试后,如果 MongoDB 数据库处于给定的状态,则测试将成功;如果不处于该状态,则测试将失败。
5.3.2. 测试 CommentsGateway 类
现在,让我们看看如何为book.aggr.CommentsGateway类编写集成测试。这个类,如列表 5.8 所示,是一个简单的boundary类,充当聚合服务与评论服务之间的网关(code/aggregator/src/main/java/book/aggr/CommentsGateway)。它负责与评论服务通信,以创建新评论或获取有关它们的详细信息。
注意
HK2 是一个轻量级、动态的 DI 框架,可以与 Jersey 一起使用。
列表 5.8. CommentsGateway类
@Service *1*
public class CommentsGateway {
public static final String COMMENTS_SERVICE_URL =
"COMMENTS_SERVICE_URL";
private Client client;
private WebTarget comments;
public CommentsGateway() { *2*
String commentsHost = Optional.ofNullable(System.getenv
(COMMENTS_SERVICE_URL)) *3*
.orElse(Optional.ofNullable(System.getProperty
("COMMENTS_SERVICE_URL")).orElse
("http://localhost:8282/comments-service/"));
initRestClient(commentsHost);
}
void initRestClient(final String host) {
this.client = ClientBuilder.newClient().property("jersey" +
".config.client.connectTimeout", 2000).property
("jersey.config.client.readTimeout", 2000);
this.comments = this.client.target(host);
}
public Future<Response>; createComment(final JsonObject comment)
{ *4*
return this.comments.path("comments").register
(JsonStructureBodyWriter.class) *5*
.request(MediaType.APPLICATION_JSON).async() *6*
.post(Entity.entity(comment, MediaType
.APPLICATION_JSON_TYPE)); *7*
}
public Future<JsonObject>; getCommentsFromCommentsService(final
long gameId) {
return this.comments.path("comments/{gameId}")
.resolveTemplate("gameId", gameId).register
(JsonStructureBodyReader.class).request
(MediaType.APPLICATION_JSON).async().get
(JsonObject.class);
}
@PreDestroy
public void preDestroy() {
if (null != client) {
client.close();
}
}
}
// end:test[]
-
1 标注在要自动添加到 HK2 的类上
-
2 建立与评论服务的连接
-
3 获取连接到评论服务的 URL
-
4 创建一条评论
-
5 注册一个 marshaller 以将 JsonObject 转换为 JSON
-
6 执行一个异步调用,该调用返回 java.util.concurrent.Future 类
-
7 执行一个 POST 请求
让我们为createComment方法编写一个测试:请参阅列表 5.9(code/aggregator/i-tests/src/test/java/book/aggr/CommentsGatewayTest.java)。请注意,在这种情况下,你需要执行以下操作:
1. 在 Tomcat 中部署评论服务。
2. 启动一个 MongoDB 实例,因为它是该服务使用的存储服务器。
3. 从聚合服务执行针对
CommentsGateway类的测试。
理想情况下,这个测试应该部署两个工件:评论服务部署在其应用程序服务器(Apache Tomcat)中;从聚合器来看,CommentsGateway应该部署在其应用程序服务器(Apache TomEE)中。这似乎非常适合 Arquillian 的多部署功能。
问题在于在这种情况下你不能使用该功能。聚合服务运行在 Apache Tomcat 服务器上,而评论服务运行在 Apache TomEE 服务器上——Arquillian 多部署仅在所有部署使用相同服务器时才有效。尽管存在这种限制,你可以在客户端模式下使用 Arquillian,并在自己的服务器(Apache TomEE)上部署整个评论服务,同时也可以在客户端模式下使用CommentsGateway而不是部署它。
这个测试具有以下功能:
-
一个带有
testable属性设置为false的@Deployment方法,以便测试以客户端模式运行 -
部署整个应用程序,而不是微部署
-
使用
CommentsGateway类的测试
列表 5.9. 测试createComment方法
@RunWith(Arquillian.class)
public class CommentsGatewayTest {
@Deployment(testable = false) *1*
public static WebArchive createCommentsDeployment() {
return ShrinkWrap.create(EmbeddedGradleImporter.class,
CommentsGatewayTest.class.getName() + ".war") *2*
.forProjectDirectory("../../comments")
.importBuildOutput().as(WebArchive.class);
}
@ArquillianResource *3*
private URL url;
@Test
public void shouldInsertCommentsInCommentsService() throws
ExecutionException, InterruptedException {
final JsonObject commentObject = Json.createObjectBuilder()
.add("comment", "This Game is Awesome").add("rate",
5).add("gameId", 1234).build();
final CommentsGateway commentsGateway = new CommentsGateway
(); *4*
commentsGateway.initRestClient(url.toString());
final Future<Response>; comment = commentsGateway
.createComment(commentObject);
final Response response = comment.get();
final URI location = response.getLocation();
assertThat(location).isNotNull();
final String id = extractId(location);
assertThat(id).matches("[0-9a-f]+"); *5*
}
}
-
1 将测试设置为客户端模式
-
2 通过在评论服务中运行 Gradle assemble 任务来构建一个工件
-
3 注入评论服务部署的 URL
-
4 实例化一个新的 CommentsGateway 类,并执行创建方法
-
5 断言返回的 ID 是 MongoDB 对象 ID
这个测试在客户端模式下运行。这意味着 Arquillian 将测试存档“原样”部署到容器中,并在与测试运行器相同的 JVM 中运行测试。
测试还演示了另一种使用 ShrinkWrap 项目的途径。在前一章中,你看到了如何使用 ShrinkWrap 创建微部署(只包含应用程序部分的应用程序部署文件)。但在这个例子中,你通过使用 EmbeddedGradleImporter 动态创建项目。使用 EmbeddedGradleImporter,你设置 build.gradle 文件所在的根目录,ShrinkWrap 负责调用 Gradle 任务来组装项目,并将其作为有效的存档返回。默认情况下,它忽略测试步骤,以避免可能的无穷递归。
如果项目将由 Maven 管理,你也可以使用 MavenImporter 类。
小贴士
你可以使用 Maven 解析器 从你的工件仓库中解析工件,而不是每次都构建它。这样的调用示例是 Maven.resolver().resolve("org.gamer:game-aggregator:war:1.0.0").withTransitivity().as(WebArchive.class);。
集成测试还应涵盖意外行为,如超时、外部通信缓慢响应和内部错误。这些情况可能难以重现,因此最佳方法是使用 WireMock 的外部组件的模拟版本,我们在第四章中介绍了它。最好将这些边缘测试案例放在一个不同于验证网关在正常条件下工作的测试类的类中。将测试类分为预期和异常行为提供了几个优点:
-
你可以并行运行这些测试。
-
对于外部读者来说,更容易理解该类在正常情况下的工作方式。
-
你不会混合基础设施代码。在一个案例中,你部署的是真实服务;在另一个案例中,你部署的是模拟服务。
以下集成测试检查 CommentsGateway 类(code/aggregator/i-tests/src/test/java/book/aggr/CommentsNegativeTest.java)的异常情况。
列表 5.10. 测试意外行为
public class CommentsNegativeTest {
@Rule
public WireMockRule wireMockRule = new WireMockRule(8089); *1*
@Test
public void
shouldReturnAServerErrorInCaseOfStatus500WhenCreatingAComment()
throws ExecutionException, InterruptedException {
stubFor( *2*
post(urlEqualTo("/comments")).willReturn(aResponse
().withStatus(500).withBody("Exception " +
"during creation of comment")));
CommentsGateway commentsGateway = new CommentsGateway();
commentsGateway.initRestClient("http://localhost:8089"); // *3*
final JsonObject commentObject = Json.createObjectBuilder()
.add("comment", "This Game is Awesome").add("rate",
5).add("gameId", 1234).build();
final Future<Response>; comment = commentsGateway
.createComment(commentObject);
final Response response = comment.get();
assertThat(response.getStatus()).isEqualTo(500);
assertThat(response.getStatusInfo().getReasonPhrase())
.isEqualTo("Server Error"); *4*
}
@Test
public void shouldThrowAnExceptionInCaseOfTimeout() throws
ExecutionException, InterruptedException {
stubFor(post(urlEqualTo("/comments")).willReturn(aResponse
().withStatus(201).withHeader("Location",
"http://localhost:8089/comments/12345")
.withFixedDelay(1000) *5*
));
CommentsGateway commentsGateway = new CommentsGateway();
commentsGateway.initRestClient("http://localhost:8089");
final JsonObject commentObject = Json.createObjectBuilder()
.add("comment", "This Game is Awesome").add("rate",
5).add("gameId", 1234).build();
final Future<Response>; comment = commentsGateway
.createComment(commentObject);
try {
comment.get();
} catch (Exception e) {
assertThat(e).isInstanceOf(ExecutionException.class); *6*
final Throwable processingException = e.getCause();
assertThat(processingException).isInstanceOf
(ProcessingException.class);
assertThat(processingException.getCause()).isInstanceOf
(SocketTimeoutException.class);
}
}
}
-
1 启动 WireMock 服务器
-
2 记录模拟执行
-
3 配置 CommentsGateway 使用 WireMock 模拟
-
4 验证错误代码和消息使用内部服务器错误类型
-
5 设置响应时间为 1 秒
-
6 验证是否抛出超时异常
在这种情况下,你正在测试两种场景:当外部服务返回服务器错误时,网关的行为,以及当外部服务返回响应所需时间超过预期时,网关的行为。对于后一种情况,你配置了 1 秒的固定延迟:这已经足够了,因为 CommentsGateway 配置了半秒的超时。
你可以用类似的方式测试其他异常情况。这个测试演示了最常见的使用案例。
这是我们将向您展示的最后一个集成测试。正如您所学的,您可以编写集成测试来验证与服务的其他外部元素(如其他服务或持久化服务器)的通信。
5.4. 练习
在阅读本章之后,您就可以为位于聚合服务中的book.games.boundary.IgdbGateway编写测试了。这个网关连接到一个外部公开服务:IGDB 网站。
对于这次测试,我们建议您使用在第四章中学到的关于 Arquillian 和 WildFly Swarm 的知识:将网关类部署到 WildFly Swarm 中,因为网关连接的服务已经部署,并测试这些方法。为了测试异常行为,使用 WireMock,就像您在本章早期测试评论网关时做的那样。
还尝试编写一个针对视频服务中的book.video.boundary.YouTubeVideos的测试。这是一个使用 Redis 作为后端存储的持久化测试。使用您在第四章和第 5.2 节中学到的关于 Spring Boot 测试框架的知识。
概述
-
集成测试负责测试微服务与其数据存储或外部组件之间的通信。
-
您可以为隔离执行的 SQL 和 NoSQL 数据库开发持久化测试。
-
网关类的测试可能需要与外部服务进行通信。
-
您可以在测试中从工件存储库解析工件,或者在执行测试之前构建它们。
-
集成测试的主要困难在于外部组件和数据存储的部署、启动和运行。例如,您可能需要在测试环境中安装生产中使用的存储,或者以与生产相同的方式部署外部服务。
-
Arquillian 提供了一个多部署功能,但它有限,因为您需要在同一应用服务器上运行部署。一个可能的解决方案是使用 Arquillian 和 ShrinkWrap 解析器部署整个应用程序,并使用容器外的网关类。这是一个有效的方法,但您仍然受限于网关和外部组件实现中使用的技术。
第六章. 合约测试
本章涵盖
-
理解和编写合约测试
-
使用消费者驱动的合约
-
与 Pact JVM 合作
-
与 Arquillian 集成
到目前为止,在这本书中,你已经学习了单元测试、组件测试和集成测试。它们共同的一点是它们不测试整个应用程序,而是测试其隔离的部分。使用单元测试,被测试的单元只包含一个或几个类;使用集成测试,你测试边界是否可以连接到真实的服务。这是你将第一次编写测试来理解整个应用程序的章节。在本章中,你将了解为什么使用合约测试来验证整个系统很重要,以及如何编写它们。
6.1. 理解合约
微服务架构涉及微服务之间的大量交互。在本书的 Gamer 示例中,你看到了聚合服务、视频服务、评论服务等的交互。这些交互实际上形成了服务之间的合约:这个合约包括对输入和输出数据的期望以及前置和后置条件。
对于每个从其他服务消费数据的 消费 数据的服务,都会形成一个合约,该服务基于第一个服务的需求提供(或 生产)数据。如果生成数据的服务可以随时间变化,那么确保与每个消费其数据的服务的合约继续满足期望是很重要的。合约测试 提供了一种机制,可以显式验证组件是否满足合约。
6.1.1. 合约与单体应用程序
在单体应用程序中,服务是在同一个项目中,并排开发的。使它们看起来不同的地方是,每个服务都是在独立的模块或子项目中开发的,在相同的运行时下运行。
在这类应用中,你不需要担心服务之间合约(或兼容性)的破坏,因为有一个无形验证器,称为 编译器。如果一个服务更改了其合约,编译器将因为编译错误而拒绝该构建。让我们看看一个例子。这是 serviceA.jar:
public class ServiceA {
void createMessage(String parameterA, String parameterB) {}
}
这是 serviceB.jar:
public class ServiceB {
private ServiceA serviceA;
public void callMessage() {
String parameterA;
String parameterB;
// some logic for creating message
serviceA.createMessage(parameterA, parameterB);
}
}
这两个服务,服务 A 和服务 B,是在两个不同的 JAR 文件中开发的。服务 A 通过调用其方法 createMessage 来调用服务 B,该方法要求你传递两个 String 参数。正是这个方法构成了两个服务之间的合约。
但如果服务 A 将其合约更改为以下内容呢?
public class ServiceA {
void createMessage(String parameterA, Integer parameterB) {}
}
方法签名已更改,以接收一个 String 和一个 Integer。这破坏了与服务 B(服务的消费者)的兼容性。在单体应用程序中这不是问题,因为你会得到一个编译错误,告诉你方法 createMessage 需要的是 (String, Integer),但找到的是 (String, String)。因此,当合约被破坏时,检测起来既快又简单。
从测试的角度来看,可以通过使用 new 关键字实例化它们或在具有容器支持(如上下文依赖注入(CDI)或 Spring 控制反转(IoC))的情况下,使用 Arquillian 或 Spring 测试框架来设置模块在测试逻辑中。但在微服务架构中,事情变得更加复杂,也更难检测。如果两个服务之间的合同被破坏,可能需要相当长的时间才能检测到。
6.1.2。合同和微服务应用
每个微服务都有自己的生命周期,在自己的运行时中部署,并且与其他微服务远程运行。在这种情况下,对某个服务合同的任何更改都无法被编译器捕获。图 6.1 说明了每个服务如何在不同的运行时中运行。
图 6.1。示例应用程序的大图概览

服务之间的兼容性破坏可能发生,并且很难检测。由于你没有直接的反馈来确认某些东西已经被破坏,因此破坏兼容性更容易。由于你运行的是(或缺少)测试的类型,你可能在(预)生产环境中发现问题,因此检测你已破坏兼容性更难。
通常,每个服务都是由不同的团队开发的,如果没有良好的团队间沟通,兼容性问题就更加难以检测。根据我们的经验,最常见的问题来自提供方的一处更改,使得消费者无法与提供方交互。
最常见的问题如下:
-
一个服务重命名了其端点 URL。
-
一个服务添加了一个新的必填参数。
-
一个服务更改/删除了现有的参数。
-
一个服务更改了输入参数的验证表达式。
-
一个服务更改了其响应类型或状态码。
考虑一个例子,其中你有两个服务,生产者和消费者 A。生产者服务以 JSON 格式公开一个博客文章资源,该资源被消费者 A 消费。
该文档的一个可能的表示可能是这样的:
{
"id" : 1,
"body" : "Hello World",
"created" : "05/12/2012",
"author" : "Ada"
}
消息包含四个属性:id、body、created 和 author。消费者 A 仅与 body 和 author 字段交互,忽略其他字段。这总结在图 6.2 中。
图 6.2。生产者和消费者 A 之间的数据交换

经过一段时间后,一个新的消费者开始使用生产者资源 API。这个新的消费者,消费者 B,需要 author 和 body 字段,以及一个新的字段(author id),它是博客文章作者的标识符。
在这一点上,生产者服务的维护者可以采取两种不同的方法:
-
在根级别添加一个新字段。
-
使用
author字段创建一个复合对象。
第一种方法是在文档中添加一个新的字段,称为 authorId,与 author 字段处于同一级别。该文档的一个表示可能如下所示:
{
"id" : 1,
"body" : "Hello World",
"created" : "05/12/2012",
"author" : "Ada",
"authorId" : "123"
}
这种改变满足了消费者 B 的需求。如果消费者 A 遵循Postel 定律,它将能够继续从生产者服务消费消息。
鲁棒性原则
鲁棒性原则,也称为Postel 定律,来源于 Jon Postel。他撰写了 TCP 协议的早期规范,并提出了以下观点:
在行动上要保守,在接受他人时要宽容。
Jon Postel
当应用于 HTML 时,这是一个糟糕的原则,因为它导致了现在主要由更严格的 HTML5 规范在很大程度上解决的荒谬的浏览器之战。但对于有效载荷解析来说,这个原则仍然适用。换句话说,适应我们的情况,生产者和消费者应该忽略任何对他们不重要的有效载荷字段。
图 6.3 显示,两个消费者仍然可以消费来自提供者的消息。但假设维护者决定采用第二种方法,并从authorInfo字段创建一个组合对象:
{
"id" : 1,
"body" : "Hello World",
"created" : "05/12/2012",
"author" : {
"name" : "Ada",
"id" : "123"
}
}
图 6.3. 生产者与消费者 A 和 B 之间的数据交换

这种改变满足了消费者 B 的需求,但破坏了与消费者 A 的兼容性。在图 6.4 中,你可以看到,尽管消费者 B 可以处理来自提供者的消息,但消费者 A 不能,因为它期望一个类型为string的author字段。
图 6.4. 更新的数据交换方案

如果你使用的是单体方法,这个问题会在编译时被发现,但在这个情况下,你不会立即知道它。从生产者的角度来看,即使所有测试都通过,你仍然不知道合同已被破坏。
当新的生产者服务部署到包含所有服务正常运行的全环境时,这个问题会在消费者服务中出现。此时,消费者将开始错误地运行,因为合同已被破坏。应该为所有已适应生产者 API 但现在失败的消费者服务开发一个新的补丁。
你发现错误的时间越晚,修复它就越困难——并且,根据应用程序部署的阶段,紧迫性可能会非常严重。假设你在生产环境中发现了这个错误。在这个阶段,你需要将新的生产者服务回滚到旧版本,以及所有已更新的消费者,以使环境重新运行。然后你需要花费大量时间确定部署失败的原因并修复它。图 6.5 显示了在项目不同阶段发现错误的成本。
图 6.5. 在特定开发阶段修复错误的成本

使用微服务架构意味着改变服务测试的方式,以便在部署新的生产者服务之前检测到这些问题。理想情况下,应该在 CI/交付运行期间的测试阶段检测到错误。
弃用方法
您也可以通过弃用author字段而不是删除它来混合两种解决问题的方法。新的文档看起来像这样:
{
"id" : 1,
"body" : "Hello World",
"created" : "05/12/2012",
"author" : "Ada",
"authorInfo" : {
"name" : "Ada",
"id" : "123"
}
}
已创建一个新的字段authorInfo,而author仍然有效但已弃用。这种方法不会替换任何测试,因为无论何时决定删除弃用的字段,你都会遇到相同的问题。但至少有一个过渡,并且消费者维护者可能有时间被告知更改并适应它。
6.1.3. 使用集成测试进行验证
在第五章中,你看到您使用集成测试来测试一个系统是否可以与另一个系统通信。用合约术语表达,你正在测试给定消费者中的边界或网关类是否可以正确与提供者通信以获取或发布一些数据。
你可能会认为集成测试涵盖了合约被破坏的使用场景。但这种方法存在一些问题,使得对服务进行此类测试变得困难。
首先,消费者必须知道如何启动提供者。其次,消费者可能依赖于多个提供者。每个提供者可能有不同的要求,例如数据库或其他服务。因此,启动提供者可能意味着启动多个服务,并且在不经意间将集成测试转换为端到端测试。
第三个也是最重要的问题是,您需要在生产者和所有消费者之间创建直接关系。当对生产者进行任何更改时,必须运行与该生产者相关的所有消费者的集成测试,以确保它们仍然可以与提供者通信。这种安排难以维护,因为对于每个新的消费者,您需要通知生产者团队并提供一组新的运行测试。
尽管集成测试可能是一种验证一个生产者的消费者可以连接的解决方案,但这些测试并不总是最佳的方法。
6.1.4. 什么是合约测试?
如前所述,合约是一系列协议,由充当客户端(或消费者)的服务与充当提供者的另一个服务之间达成。定义每个消费者与其提供者之间交互的合约的存在,解决了第 6.1.3 节中描述的所有问题。
在图 6.6 中,定义了消费者和供应商之间的合同——比如说,通过一个文件来描述它——因此供应商和消费者都有遵守合同的协议。现在,消费者和供应商之间的关系是间接的,因为从生产者的角度来看,你只需要验证它是否符合合同中描述的协议。供应商不需要运行消费者的集成测试;它只需要测试消费者能否根据合同消费请求并生成响应。
图 6.6. 供应商和消费者交互

在这种情况下,每个供应商与其提供数据的所有消费者之间都有一个/多个合同。对于对供应商所做的每个更改,都会验证所有合同以检测任何中断,而无需运行集成测试。
合同也在消费者端进行验证,验证其客户端类(网关)是否遵循合同。请注意,再次强调,你不需要知道生产者是如何启动的或启动任何可能依赖于它的外部依赖项,因为验证合同并不意味着启动生产者;你只是在验证消费者是否也符合合同。
验证合同的测试被称为合同测试。下一个大问题是,谁负责创建和维护合同文件?我们将在下一节中讨论这个问题。
6.1.5. 谁拥有合同?
正如你刚刚学到的,验证消费者和供应商能否正确且持续地通信的最佳方式是在它们之间定义一个合同。但我们还没有解决这个合同的拥有权问题:是消费者团队还是供应商团队。
供应商合同是什么?
如果合同的拥有权属于开发供应商的团队,这意味着他们不仅了解自己服务(供应商)的商业行为,还了解其服务支持的所有消费者的需求。这种合同被称为供应商合同,因为合同属于供应商,消费者只是它的查看者(参见图 6.7)。这种合同可能有益的例子之一是内部安全认证/授权服务,其中消费服务必须符合供应商合同。
图 6.7. 供应商合同

供应商合同定义了供应商将向消费者提供的内容。每个消费者都必须适应供应商提供的内容。自然地,这表明消费者与供应商耦合。如果供应商团队开发的合同不再满足消费者的需求,消费者团队必须与供应商服务的维护者开始对话,以解决这一缺陷。
消费者合同是什么?
另一方面,为了解决一刀切合同的问题,而不强迫提供商团队定义一个完整的合同,你可以通过让消费者服务的开发者定义他们所需的内容,并将该合同发送给提供商团队以实施,来改变合同的所有权。这种合同被称为消费者合同,因为它属于消费者。
消费者合同从消费者的角度定义了消费者的需求。因此,此合同仅适用于该单个消费者及其特定用例。消费者合同可以用来完成现有的提供商合同(如果有的话),或者它们可以帮助开发一个新的合同。图 6.8 显示,对于每个提供商-消费者关系都有一个消费者合同,而不是为所有消费者提供一个单一的合同。

例如,消费者合同可能对组织内部结账服务有益,其中服务的演变速度将由提供商控制,但从该服务提取的数据用于不同的上下文中。这些上下文可能各自演变,但存在一个内部的控制点和演变点。
什么是消费者驱动的合同?
一个消费者驱动的合同代表了一个提供商与其所有消费者之间所有合同的聚合(见图 6.9)。显然,只要满足对所有消费者的义务,提供商可以通过创建一个提供商合同来演变或扩展消费者的合同。
图 6.9. 消费者驱动的合同

消费者驱动的合同确立了一个服务提供商是从其消费者的角度开发的。每个消费者都会向其提供商传达满足该消费者用例的具体要求。这些要求在提供商一方产生义务,以满足所有消费者的期望。
理想情况下,合同测试是由消费者团队开发的,捆绑后发送给生产团队,以便他们可以开发生产服务。通过将消费者合同的所有权分配给消费者团队,你确保了提供商的消费者驱动的合同是消费者所需的,而不是提供商对消费者期望的解释。此外,当生产服务的维护者更改提供商-服务代码库时,他们知道他们的更改对消费者的影响,确保在部署提供服务的最新版本时运行时没有通信失败。
消费者端也有一些义务。提供者必须遵守消费者驱动的合同,而消费者必须确保他们遵守合同的一部分——不多也不少。消费者应该只从提供者端消费他们所需的内容。这样,消费者可以保护自己免受提供者合同因提供者添加内容而产生的演变。
消费者驱动合同可能有益的一个例子是,一个外部(对外部组织开放)或内部用户服务,该服务在整个组织中使用。数据从这个服务中提取用于多个其他上下文。这些上下文都各自发展,存在一个外部控制/演化的焦点。
到目前为止,在本章中,我们讨论了服务在不同运行时运行的问题,为什么集成测试不够,以及为什么消费者驱动合同有助于你修复在更新生产者服务时可能出现的通信问题。让我们看看如何实践这些原则,以及哪些工具可以帮助你使用消费者驱动合同模式为微服务架构。
6.2. 工具
我们已经解释了为什么在微服务架构中编写合同测试很重要,以避免在服务部署到生产环境时出现意外。接下来,让我们看看你可以用来编写合同测试的工具。这些是最受欢迎的三种工具:
-
Spring Cloud Contract——一个在 Spring 生态系统下开发的测试框架,使用 Groovy 编写。尽管它与 Spring 产品集成良好,但任何使用 JVM 语言开发的应用程序都可以使用它。
-
Pact——一组提供消费者驱动合同测试支持的测试框架。它为 Ruby、JVM 语言、.NET、JavaScript、Go、Python、Objective-C、PHP 和 Swift 语言提供了官方实现。
-
Pacto——一个用于开发消费者驱动合同测试和/或文档驱动合同的测试框架。它使用 Ruby 编写,尽管可以通过使用 Pacto 服务器与多种语言(如 Python 和 Java)一起使用。
在我们看来,Pact (docs.pact.io) 是合同测试场景中最广泛采用和最成熟的项目之一。其主要优势之一是它支持今天用于编写微服务的几乎所有主要语言;相同的概念可以独立于编程语言重用,从前端到后端。因此,我们坚信 Pact 是编写消费者驱动合同的通用解决方案。它很好地适应了在 Java 中开发的微服务架构。
下一节将深入探讨 Pact 的工作原理以及如何使用 Pact 编写合同测试。
6.2.1. Pact
Pact 框架通过提供模拟 HTTP 服务器和流畅 API 来定义从消费者到服务提供商的 HTTP 请求以及预期的 HTTP 响应,允许你在消费者端编写合同。这些 HTTP 请求和响应用于模拟 HTTP 服务器来模拟服务提供商。然后,这些交互用于生成服务消费者和服务提供商之间的合同。
Pact 还提供了验证合同与提供商端逻辑。消费者上发生的所有交互都在“真实”服务提供商上回放,以确保提供商为给定的请求产生消费者期望的响应。如果提供商返回了意外的内容,Pact 将交互标记为失败,并且合同测试失败。
任何合同测试都由两部分组成:一部分是消费者,另一部分是提供商。此外,合同文件从消费者发送到提供商。让我们使用 Pact 来查看合同测试的生命周期:
1. 消费者预期在模拟 HTTP 服务器上使用流畅 API 设置。消费者与模拟 HTTP 服务器进行通信,处理 HTTP 请求/响应,但从不与提供商交互。这样,消费者不需要知道如何部署提供商(因为这可能不是一件简单的事情,并且可能结果是在编写端到端测试而不是合同测试)。消费者验证其客户端/网关代码可以与定义的交互通信的模拟 HTTP 服务器。
当运行消费者测试时,所有交互都写入一个 pact 合同文件,该文件定义了消费者和提供商必须遵循的合同。
2. 将 pact 合同文件发送到提供商项目,以便在提供商服务上回放。合同在真实提供商上回放,并检查提供商的真实响应与合同中定义的预期响应是否一致。
如果消费者能够生成一个 pact 合同文件,并且提供商满足所有预期,那么合同已经由双方验证,他们就能够进行通信。
这些步骤在图 6.10 中进行了总结。
图 6.10. Pact 生命周期

总结来说,Pact 提供了以下功能:
-
一个模拟的 HTTP 服务器,这样你就不必依赖提供商。
-
一个 HTTP 客户端,用于自动回放预期。
-
在回放预期之前,将期望的状态从消费者端传达给提供商。例如,一个交互可能要求在回放预期之前,提供商数据库中必须包含一个名为Alexandra的用户。
-
Pact Broker 是合同的存储库,允许您在消费者和提供者之间共享 pact,对 pact 合同文件进行版本控制,以便提供者可以针对合同的固定版本进行验证,并为每个 pact 提供文档以及服务之间关系的可视化。
接下来,我们将探讨 Pact JVM:Pact 在 Java 虚拟机中的实现。
6.2.2. JVM 语言中的 Pact
Pact JVM 部分是用 Scala、Groovy 和 Java 编写的,但它可以与任何 JVM 语言一起使用。它与 Java、Scala、Groovy、Grails(提供用于定义合同的 Groovy DSL)和 Clojure 完美集成。此外,它还提供了与测试框架(如 JUnit、Spock、ScalaTest 和 Specs2)以及构建工具(如 Maven、Gradle、Leiningen 和 sbt)的紧密集成。本书侧重于 Java 工具,但请记住,如果您计划使用任何其他 JVM 语言,您仍然可以使用 Pact JVM 进行消费者驱动的合同测试。
让我们看看如何使用 Pact JVM 编写消费者和提供者测试。
使用 Pact JVM 进行消费者测试
Pact JVM 提供了一个模拟 HTTP 服务器和一个 Java 领域特定语言 (DSL),用于编写模拟 HTTP 服务器的预期。当消费者测试通过时,这些预期将实现为 pact 合同文件。
Pact JVM 与 JUnit 集成,提供 DSL 和基类,用于与 JUnit 一起构建消费者测试。使用 JUnit 编写消费者测试的第一件事是注册 PactProviderRule JUnit 规则。此规则执行以下操作:
-
启动和停止模拟 HTTP 服务器
-
配置具有定义预期的模拟 HTTP 服务器
-
如果测试通过,则从定义的预期生成 pact 合同文件
下面是一个示例:
@Rule
public PactProviderRule mockProvider =
new PactProviderRule("test_provider",
"localhost", 8080,
this);
第一个参数是当前消费者合同定义的提供者名称。此名称用于引用给定合同的提供者。接下来是两个可选参数:模拟 HTTP 服务器绑定的主机和监听端口。如果没有指定值,则分别使用 localhost 和 8080。最后,this 实例是测试本身。
接下来,您通过使用 au.com.dius.pact.consumer.Pact 注解一个方法来定义预期。此方法必须接收一个类型为 PactDslWithProvider 的类,并返回一个 PactFragment。PactDslWithProvider 是一个围绕 DSL 模式构建的 Java 类,用于描述当使用模拟 HTTP 服务器时预期接收到的请求。正如其名称所示,PactFragment 对象是合同的一部分。它用作模拟 HTTP 服务器中的预期,并用于生成 pact 合同文件,该文件用于验证提供者。片段可以是完整的合同,也可以只是其中的一部分。如果在同一测试类中定义了多个片段,则 pact 合同文件由所有片段的聚合组成。
@Pact 方法必须具有以下签名:
@Pact(provider="test_provider", consumer="test_consumer")
public PactFragment createFragment(PactDslWithProvider builder) {
//...
}
注意,在 @Pact 注解中,您设置了应遵循合同的提供者名称以及定义合同的消费者名称。这些信息对于提供者测试执行很重要,以确保提供者端测试针对所有提供数据的消费者执行。
下一个片段定义了一个请求/响应期望。PactDslWithProvider 有几个选项可以定义:
return builder
.uponReceiving("a request for something") *1*
.path("/hello") *2*
.method("POST")
.body("{\"name\": \"Ada\"}") *3*
.willRespondWith() *4*
.status(200) *5*
.body("{\"hello\": \"Ada\"}") *6*
.uponReceiving("another request for something")
.matchPath("/hello/[0-9]+") *7*
.method("POST")
.body("{\"name\": \"Ada\"}")
.willRespondWith()
.status(200)
.body("{\"hello\": \"Ada\"}")
.toFragment();
-
1 定义一个新的请求交互
-
2 对 HTTP POST 的 /hello 路径做出响应
-
3 请求体必须包含给定的 JSON 文档。
-
4 定义对先前请求的响应
-
5 返回 HTTP 状态码 200
-
6 响应体的内容是给定的 JSON 文档。
-
7 对以 /hello/ 开头的任何路径以及任何数字做出响应
此示例定义了两个期望。第一个请求发生在消费者使用 POST 方法在 /hello 发送请求时。消息体必须包含确切的 JSON 文档 {"name": "Ada"}。如果发生这种情况,则响应是 JSON 文档 {"hello": "Ada"}。第二个请求发生在路径以 /hello 开头并跟随着任何有效数字时。条件与第一个请求相同。
注意,您可以定义所需数量的交互。每个交互都以 uponReceiving 开头,后面跟着 willRespondWith 以记录响应。
小贴士
为了使您的测试尽可能可读和简单,并保持专注于“一种方法,一项任务”的方法,我们建议为所有交互使用多个片段,而不是定义一个返回所有内容的大的 @Pact 方法。
之前定义的一个重要方面是,正文内容必须与合同中指定的内容相同。例如,请求某物 有一个强烈的要求,即只有当 JSON 文档是 {"name": "Ada"} 时才提供响应。如果名称不是 Ada,则不生成响应。对于返回的正文也是如此。因为 JSON 文档是静态的,所以响应总是相同的。
这可能是在无法设置静态值的情况下的一种限制,尤其是在运行针对提供者的合同时。因此,构建器的 body 方法可以接受一个 PactDslJsonBody,它可以用来动态构建 JSON 正文。
PactDslJsonBody 类
PactDslJsonBody 构建器类实现了 DSL 模式,您可以使用它来动态构建 JSON 正文,以及为字段定义正则表达式和类型匹配器。让我们看看一些示例。
下面的片段生成一个没有数组的简单 JSON 文档:
DslPart body = new PactDslJsonBody()
.stringType("name") *1*
.booleanType("happy") *2*
.id()
.ipAddress("localAddress")
.numberValue("age", 100); *3*
-
1 定义一个名为 name 的字段,类型为字符串,其值不重要
-
2 定义一个名为 happy 的字段,类型为布尔型,其值不重要
-
3 定义一个名为 age 的字段,类型为数字,其特定值为 100
使用 xType 表单,你还可以设置一个可选的值参数,用于在返回模拟响应时生成示例值。如果没有提供示例,将生成一个随机的值。
之前的 PactDslJsonBody 定义将匹配任何这样的体:
{
"name" : "QWERTY",
"happy": false,
"id" : 1234,
"localAddress" : "127.0.0.1",
"age": 100,
}
注意,任何包含所需类型所有必需字段的文档,并且具有值为 100 的 age 字段都是有效的。
PactDslJsonBody 还提供了定义数组匹配器的方法。例如,你可以验证列表具有最小或最大大小,或者列表中的每个项目与给定的示例匹配:
DslPart body = new PactDslJsonBody()
.minArrayLike("products", 1) *1*
.id() *2*
.stringType("name")
.stringMatcher("barcode", "a\\d+", "a1234")
.closeObject()
.closeArray();
-
1 定义列表必须至少包含一个元素
-
2 指定列表中的每个文档必须包含一个 ID、一个名称和一个条形码
在这里,products 数组不能为空,并且每个产品都应该有一个标识符和一个类型为 string 的名称,以及一个匹配形式 "a" 加上一个数字列表的条形码。
如果元素的大小不重要,你可以这样做:
PactDslJsonArray.arrayEachLike()
.date("expireDate", "mm/dd/yyyy", date)
.stringType("name")
.decimalType("amount", 100.0)
.closeObject()
在这个例子中,每个数组必须包含三个字段:expireDate、name 和 amount。此外,在模拟响应中,每个元素将在 expireDate 字段中包含一个 date 变量值,在 name 字段中包含一个随机的 string,在 amount 中包含值 100.0。
如你所见,使用 DslPart 生成体让你可以定义字段类型而不是具体的特定字段/值对。这使得你的合约在提供者端的合约验证期间更具弹性。假设你在提供者验证阶段设置了 .body("{'name': 'Ada'}"):你期望提供者生成具有相同值的相同 JSON 文档。这在大多数情况下可能是正确的;但如果测试数据集发生变化,并且不是返回 .body("{'name':'Ada'}"),而是返回 .body("{'name':'Alexandra'}"),测试将失败——尽管从合约的角度来看,这两个响应都是有效的。
现在你已经看到了如何在消费者端使用 Pact 编写驱动型合约,让我们看看如何编写测试的提供者部分。
使用 Pact JVM 进行提供者测试
在执行测试的消费者部分并生成和发布 pact 合约文件后,你需要将合约回放到一个真实的提供者。这部分测试在提供者端执行,Pact 提供了几个工具来完成这项工作:
-
JUnit—在 JUnit 测试中验证合约
-
Gradle, Lein, Maven, sbt—用于验证运行中提供者的插件
-
ScalaTest—用于验证运行中提供者的扩展
-
Specs2—用于验证运行中提供者的扩展
通常,所有这些集成都提供了两种检索已发布合约的方式:通过使用 Pact Broker 和指定一个具体的位置(一个文件或一个 URL)。配置检索方法的方式取决于你选择如何回放合约。例如,JUnit 使用注解方法,而在 Maven 中,插件配置部分用于此目的。
让我们来看看如何使用 Maven、Gradle 和 JUnit 实现提供者验证。
使用 Maven 验证契约
Pact 为验证提供者与契约提供了 Maven 插件。要使用它,请将以下内容添加到 pom.xml 的 plugins 部分。
列表 6.1. 添加 Maven 插件
<plugin>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-maven_2.11</artifactId>
<version>3.5.0</version>
</plugin>
然后你需要配置插件,定义你想要验证的所有提供者以及你想要用来检查它们的消费者契约的位置。
列表 6.2. 配置 Maven 插件
<plugin>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-maven_2.11</artifactId>
<version>3.2.10</version>
<configuration>
<serviceProviders>
<serviceProvider> *1*
<name>provider1</name>
<protocol>http</protocol>
<host>localhost</host>
<port>8080</port>
<path>/</path> *2*
<pactFileDirectory>path/to/pacts</pactFileDirectory> *3*
</serviceProvider>
</serviceProviders>
</configuration>
</plugin>
-
1 要验证的提供者(或提供者)
-
2 提供者的名称(必须是唯一的)以及它部署的位置
-
3 存储所有 Pact 契约的目录
要验证契约,执行 mvn pact:verify。Maven 插件将加载给定目录中定义的所有 Pact 契约,并重新播放与给定提供者名称匹配的契约。如果所有契约都通过提供者验证,则构建将成功完成;如果不通过,则构建将失败。
使用 Gradle 验证契约
Gradle 插件使用与 Maven 类似的方法来验证契约与提供者。要使用它,请将以下内容添加到 .build.gradle 的 plugins 部分。
列表 6.3. 添加 Gradle 插件
plugins {
id "au.com.dius.pact" version "3.5.0"
}
然后配置插件,定义你想要验证的提供者以及你想要用来检查它们的消费者契约的位置。
列表 6.4. 配置 Maven 插件
pact {
serviceProviders {
provider1 { *1*
protocol = 'http'
host = 'localhost'
port = 8080
path = '/' *2*
hasPactsWith('manyConsumers') { *3*
pactFileLocation = file('path/to/pacts')
}
}
}
}
-
1 要验证的提供者(或提供者)
-
2 提供者的名称(必须是唯一的)以及它部署的位置
-
3 存储所有 Pact 契约的目录
要验证契约,执行 gradlew pactVerify。Gradle 插件将加载给定目录中定义的所有 Pact 契约,并重新播放与给定提供者名称匹配的契约。如果所有契约都通过提供者验证,则构建将成功完成;如果不通过,则构建将失败。
最后,让我们看看如何通过使用 JUnit 而不是依赖构建工具来验证提供者。
使用 JUnit 验证契约
Pact 为验证提供者与契约提供了 JUnit 运行器。此运行器提供了一个 HTTP 客户端,它将自动重新播放所有契约以针对配置的提供者。它还提供了使用注解加载契约的方便方法。
使用 JUnit 方法,你需要注册 PactRunner,使用 @Provider 注解设置提供者的名称,并设置契约的位置。然后,你创建一个类型为 au.com.dius.pact.provider.junit.target.Target 的字段,该字段带有 @TestTarget 注解,并实例化 au.com.dius.pact.provider.junit.target.HttpTarget 以将 pact 契约文件作为 HTTP 请求播放并断言响应,或者实例化 au.com.dius.pact.provider.junit.target.AmqpTarget 以将 pact 契约文件作为 AMQP 消息播放。
注意
高级消息队列协议 (AMQP) 是一种面向消息的中间件的应用层协议。它定义的功能包括消息导向、排队、路由、可靠性和安全性。
让我们看看一个使用HttpTarget的例子,来自 PactTest.java。
列表 6.5. 使用 JUnit 运行器
@RunWith(PactRunner.class) *1*
@Provider("provider1") *2*
@PactFolder("pacts") *3*
public class ContractTest {
@TestTarget *4*
public final Target target = new HttpTarget("localhost", 8332); *5*
}
-
1 注册 PactRunner
-
2 设置提供者的名称
-
3 设置合约文件的存储位置。在这种情况下,位置解析为 src/test/resources(pacts)。
-
4 设置测试的目标
-
5 配置提供者的位置
注意,没有使用@Test注解的测试方法。这不是必需的,因为这里不是单个测试,而是许多测试:消费者和提供者之间每个交互的一个测试。
当这个测试执行时,JUnit 运行器会从 pacts 目录获取所有合约文件,并针对在HttpTarget实例中指定的提供者位置重新播放其中定义的所有交互。
PactRunner自动根据测试类上的注解加载合约。Pact 为此提供了三个注解:
-
PactFolder—从项目文件夹或资源文件夹检索合约;例如,@PactFolder("subfolder/in/resource/directory")。 -
PactUrl—从 URL 检索合约;例如,@PactUrl(urls = {"http://myserver/contract1.json"})。 -
PactBroker—从 Pact Broker 检索合约;例如,@PactBroker (host="pactbroker", port = "80", tags = {"latest", "dev"})。 -
Custom—要实现自定义检索器,创建一个实现PactLoader接口的类,并具有一个默认空构造函数或一个参数类型为Class(代表测试类)的构造函数。像这样注解测试:@PactSource(CustomPactLoader.class)。
您也可以轻松实现自己的方法。
Pact 状态
在测试时,每个交互都应该在隔离的情况下进行验证,不包含之前交互的上下文。但是,在使用消费者驱动的合约时,有时消费者希望在交互运行之前在提供者端设置某些内容,以便提供者可以发送与消费者期望相匹配的响应。一个典型的场景是设置带有预期数据的数据源。例如,在测试认证操作的合约时,消费者可能要求提供者在交互发生之前将具体的登录名和密码插入到数据库中,以便当交互发生时,提供者逻辑可以适当地对数据进行响应。图 6.11 总结了消费者、状态和提供者之间的交互。
图 6.11. 消费者和提供者之间的交互

首先,消费者端定义了应该使用包含 JSON 主体的POST方法来完成认证过程:
{
"login": "John",
"password": "1234"
}
因为这个片段将在与提供商回放合约时使用,所以消费者需要提醒提供商在执行此交互之前,应该使用给定信息准备数据库。因此,消费者创建了一个包含所有必要数据的名为状态认证的状态。状态信息存储在合约中。
当合约与提供商回放时,在交互发生之前,状态数据被注入到测试中,以便测试可以准备合约验证的环境。最后,使用包含预期用户信息的数据库执行合约验证。
要从消费者端定义状态,您需要在定义合约时使用特殊方法 given:
@Override
protected PactFragment createFragment(PactDslWithProvider builder) {
Map<String, Object> parameters = new HashMap<>(); *1*
parameters.put("login", "John");
parameters.put("password", "1234")
builder
.given("State Authentication", parameters) *2*
.uponReceiving("")
....
-
1 定义状态中所需的数据
-
2 在合约中以名称和参数注册状态
要对提供商端的状态做出反应,您需要创建一个带有 @State 注解的方法:
@State("State Authentication") *1*
public void testStateMethod(Map<String, Object> params) { *2*
//Insert data
}
-
1 设置要响应的状态名称
-
2 该方法接收消费者定义的参数的 Map。
注意,使用状态,您可以在消费者和提供商之间共享信息,因此您可以在交互之前配置测试的状态。Pact 状态是从消费者端准备提供商状态的推荐方式。
Maven 和 Gradle 集成还提供了在提供商端设置状态的方法。在这些情况下,对于每个提供商,您指定一个用于更改提供商状态的状态更改 URL。此 URL 在每次交互之前通过 POST 方法接收来自 pact 合约文件的 providerState 描述。
6.2.3. 使用 Algeron 将 Pact JVM 集成到 Arquillian 生态系统
Arquillian Algeron 是一个 Arquillian 扩展,它将 Arquillian 与合约测试集成。它为将 Arquillian 与合约测试框架集成提供了一个共同的基础。
Arquillian Algeron Pact 是将 Arquillian 理念和扩展集成到使用 Pact JVM 核心的消费者驱动合约方法中。通过使用 Arquillian Algeron Pact,您可以兼得两者之优:您可以使用 Pact-JVM 方法验证消费者和提供商,并且可以使用 Arquillian 在类似生产的环境下运行测试。让我们看看 Arquillian Algeron Pact 如何适应消费者和提供商端。
要使用 JAX-RS 客户端实现消费者网关,代码仅使用 API 接口(实现通常由应用服务器提供)。要运行您的测试,您需要定义一个 JAX-RS 的实现;Apache CXF (cxf.apache.org) 是一个不错的选择。您可以在构建工具中提供实现,或者您可以编写一个 Arquillian 测试。在 Arquillian 中,测试和业务代码将与您在生产中使用相同的 JAX-RS 实现和版本一起部署和运行在应用服务器上。
在提供者端,你需要将合同回放到一个正在运行的提供者。你可以依赖构建脚本来打包和部署提供者应用程序,或者你可以使用 Arquillian 在测试中打包和部署应用程序,从而避免对构建工具的依赖。
小贴士
当你在验证提供者端时,你不需要使用真实数据库或真实的外部服务来运行提供者;你可以使用内存数据库或存根。因此,使用 Arquillian 微部署可以帮助你创建包含配置文件和指向内存数据库或存根实现的类的部署文件。
Arquillian Algeron 除了提供 Pact 和 Arquillian 之间的集成外,还提供其他功能:
-
发布者—在消费者端,你可以配置它将合同发布到指定的存储库,如果它们成功生成。目前支持的发布者是 文件夹、URL、Git 服务器 和 Pact Broker,但你也可以创建自己的。
-
检索者—与 JUnit 类似,你可以配置合同加载器。除了 Pact JUnit 已支持的那些(文件夹、URL 和 Pact Broker)之外,Arquillian Algeron 还支持 Git 服务器 和 Maven 软件包。
-
配置—发布者和检索者可以在 arquillian.xml 文件中进行配置,这样所有配置都在一个中心位置,而不是每个测试类中。请注意,检索者还支持基于注解的方法。
-
TestNG—由于 Arquillian 是测试框架无关的,你可以使用 Pact 和 TestNG。
之后,我们将深入探讨 发布者 和 检索者 以及如何使用它们。
小贴士
在消费者和提供者端使用 Arquillian Algeron Pact 不是强制性的。你可以在消费者或提供者端使用 Pact JVM 或任何语言的 Pact 实现,并在另一端使用 Arquillian Algeron Pact。
与 Pact JVM 类似,Arquillian Algeron Pact 被分为消费者和提供者两部分。
警告
由于 Arquillian Algeron 使用 Pact Core 而不是 Pact JUnit,因此注解是特定于 Arquillian Algeron 的,并且与 Pact JUnit 中的不同。
使用 Arquillian Algeron Pact 编写消费者端
下面是使用 Arquillian Algeron Pact 的消费者部分的示例,来自 ClientGatewayTest.java。
列表 6.6. Arquillian Algeron Pact,消费者端
@RunWith(Arquillian.class) *1*
@Pact(provider="provider", consumer="consumer") *2*
public class ClientGatewayTest {
@Deployment *3*
public static JavaArchive createDeployment() {
return ShrinkWrap.create(JavaArchive.class)
.addClasses(ClientGateway.class);
}
public PactFragment createFragment(PactDslWithProvider builder) {
return builder
...
.toFragment(); *4*
}
@EJB *5*
ClientGateway clientGateway;
@Test
@PactVerification("provider") *6*
public void should_return_message() throws IOException {
assertThat(clientGateway.getMessage(), is(....)); *7*
}
}
-
1 Arquillian 运行器
-
2 定义合同,你需要在方法或类上注解 org.arquillian.algeron.pact.consumer.spi.Pact 并设置提供者和消费者名称。
-
3 定义你想要部署到定义的容器中的内容
-
4 返回合同的一部分(可能是整个合同)
-
5 典型的 Arquillian 扩展
-
6 定义在执行此测试方法时验证哪个提供者
-
7 断言网关可以读取提供者发送的消息类型
在这里,您正在编写一个消费者合约测试,它定义了 consumer 和 provider 之间的合约。客户端网关使用 JAX-RS 客户端实现;它是一个 EJB,因此它知道在它将在生产中找到的相同运行时下运行该网关。因此,使用 Arquillian Algeron 而不是单独使用 Pact JVM 是一种很好的方法。
@Pact 注解定义了消费者和提供者之间的交互。该注解可以在类级别使用,这意味着在此测试中定义的所有合约都是针对同一提供者;或者,它可以在方法级别使用,这意味着您可以指定一个具体的 PactFragment 仅定义注解中定义的消费者-提供者元组的交互。因此,您可以在同一个消费者类中定义针对不同提供者的合约。当注解在方法级别定义时,它优先于在类级别定义的注解。
小贴士
在同一个测试中为多个提供者定义合约不是我们推荐的做法,因为这样做会打破一个类应该测试一个事物的模式。
最后,对于每个测试用例,您需要指定在运行测试时验证哪个提供者。您可以通过使用 @PactVerification 并设置提供者名称来实现。请注意,如果您在类级别使用 @Pact 注解,则设置提供者名称不是强制性的,因为提供者名称将从那里解析。
当您使用 Arquillian Algeron Pact 消费者测试时执行的步骤与标准 Arquillian 测试类似:
1. 启动所选的应用程序服务器。
2. 部署(微)应用程序文件。
3. 启动 Pact Stub HTTP 服务器。
4. 记录所有交互(
PactFragment)。5. 执行测试。
6. 对于成功的测试,生成合约文件。
7. 取消部署应用程序。
8. 停止应用程序服务器。
如果您正在实现多个返回 PactFragment 实例的方法,您需要在 @PactVerification(.. fragment="createFragment") 的 fragment 属性中使用,以指定针对该 @Test 方法正在测试哪个片段方法。
小贴士
您可以使用 Arquillian 独立版与 Arquillian Algeron 一起使用,如果您想跳过部署步骤。这在您使用不依赖于您运行时任何功能的客户端网关时很有用。
使用 Arquillian Algeron Pact 编写提供者端
现在,让我们看看如何使用 Arquillian Algeron Pact 编写提供者部分。因为 Arquillian Algeron 使用 Pact JVM,所以方法完全相同:它将合约中定义的所有请求回放至真实提供者,并验证响应是否为预期的。
列表 6.7. Arquillian Algeron Pact,提供者端
@RunWith(Arquillian.class) *1*
@Provider("provider") *2*
@ContractsFolder("pacts") *3*
public class MyServiceProviderTest {
@Deployment(testable = false) *4*
public static WebArchive createDeployment() {
return ShrinkWrap.create(WebArchive.class).addClass(MyService.class);
}
@ArquillianResource *5*
URL webapp;
@ArquillianResource *6*
Target target;
@Test
public void should_provide_valid_answers() {
target.testInteraction(webapp); *7*
}
}
-
1 Arquillian 运行器
-
2 设置测试中使用的提供者名称
-
3 配置获取 pact 合约文件的位置
-
4 测试必须作为客户端运行;将可测试性设置为 false。
-
5 应用程序部署的 URL
-
6 目标是一个类,它向提供者发送所有请求。Arquillian Algeron Pact 默认使用 HTTP 客户端目标。
-
7 执行对已部署应用的交互,并验证响应
这个测试验证提供者是否符合消费者定义的期望。因为你需要部署一个真实的提供者,这是一个很好的方法,因为你可以使用 Arquillian 功能来打包、部署和启动应用程序。
在提供者测试中,你首先需要指定你正在验证的提供者。你可以通过使用 org.arquillian.algeron.pact.provider.spi.Provider 并设置在消费者测试中给出的提供者名称来完成此操作。
Arquillian Algeron 支持两种检索合约的方式:通过使用注解或在 arquillian.xml 中配置检索器。后者将在本章后面的“注册发布者和检索者”部分中介绍。在这个测试中,合约是通过使用 org.arquillian.algeron.provider.core.retriever.ContractsFolder 注解从 pact 中检索的,但也支持其他注解:
-
org.arquillian.algeron.provider.core.retriever.ContractsUrl—从 URL 中检索合约 -
org.arquillian.algeron.pact.provider.loader.git.ContractsGit—从 Git 仓库中检索合约 -
org.arquillian.algeron.pact.provider.loader.maven.ContractsMavenDependency—从 Maven 艺术品中检索合约 -
org.arquillian.algeron.pact.provider.core.loader.pactbroker.PactBroker—从 Pact Broker 服务器检索合约
重要的是使这个测试作为一个客户端运行,这意味着测试不是在应用服务器中执行的。你可以使用 Arquillian 作为部署者;但是重放交互必须从容器外部进行,就像任何其他消费者一样。
最后,你需要丰富测试,包括应用部署的 URL 和 org.arquillian.algeron.pact.provider.core.httptarget.Target 的实例,后者用于通过调用 testInteraction 方法重放所有交互。
在 Arquillian Algeron Pact 提供者测试中执行的步骤类似于标准的 Arquillian 测试:
1. 启动了选定的应用服务器。
2. 部署了(微)部署应用程序文件。
3. 从给定位置检索合约。
4. 对于每个合约,Arquillian Algeron 提取每个请求/响应对,向提供者发送请求,并验证响应是否符合合约响应。
5. 应用程序被卸载。
6. 应用服务器被停止。
接下来,让我们看看如何使用 Arquillian Algeron 提供的自动发布合约并在提供者中检索合约的功能。
注册发布者和检索者
如前所述,Arquillian Algeron 提供了将合约发布到存储库并检索它们进行验证的可能性。发布者配置在名为 arquillian.xml 的 Arquillian 配置文件中。检索器可以使用注解配置,正如您在上一节中看到的,或者在 arquillian.xml 中配置。
在撰写本文时,Arquillian Algeron 默认定义了四个发布者:
-
Folders—将合约复制到给定的文件夹
-
Url—向给定的 URL 发送 POST 请求,将合约作为正文
-
Git—将合约推送到 Git 存储库
-
pact-broker—将合约存储在 Pact Broker 中的特定发布者
微部署
提供者也可以是其他服务的消费者,或者它可能依赖于数据源。这很麻烦,因为为了验证合约与提供者的兼容性,您可能必须启动其他提供者和数据源,这很困难,有时还会导致测试不稳定。
在以下图中,您可以看到提供者 1 也是一个两个服务的消费者,并需要数据库。

同时是提供者和消费者的提供者
避免测试中依赖项问题的解决方案取决于依赖项的类型。如果您正在处理另一个提供者,您可以使用 WireMock 的服务虚拟化方法,如第四章中所述。如果您正在使用数据源,您可以在数据库入口点使用自己的存根以及所需的数据,或者使用内存数据库,如第五章中所述。
但在所有情况下,您的部署文件都将与您在生产中使用的文件不同。它包含指向服务虚拟化实例的测试配置文件,并将它们配置为使用内存数据库或打包的替代类作为存根。在这种情况下,正如您在第四章中学到的,ShrinkWrap 和微部署有助于在测试中动态生成部署文件。
Arquillian Algeron 还提供了一个 SPI,以便您可以实现自己的发布者,但这个主题超出了本书的范围。有关更多信息,请参阅 Arquillian Algeron 文档。
重要的是要注意,Arquillian Algeron 的消费者默认不会发布合约。这是一个安全预防措施,以避免每次在本地运行消费者测试时都发布合约。要修改此行为,您需要将 publishContracts 配置属性设置为 true。您只有在发布消费者的新版本时才应该这样做,并且此操作应由您的持续(CI/CD)环境执行。
小贴士
您可以通过使用 ${system_property} 占位符或 ${env.environment_variable} 占位符来使用系统属性或环境变量配置 arquillian.xml 属性。您可以通过在变量名称后跟一个冒号(:)和值来添加默认值。
下面是如何在 arquillian.xml 中配置 Git 发布者的一个示例:
<extension qualifier="algeron-consumer">
<property name="publishConfiguration">
provider: git *1*
url: ${env.giturl} *2*
username: ${env.gitusername}
password: ${env.gitpassword}
comment: New Contract for Version ${env.artifactversion:1.0.0} *3*
contractsFolder: target/pacts *4*
</property>
<property name="publishContracts">${env.publishcontracts:false}</property>
</extension>
-
1 设置发布者为 Git
-
2 从 giturl 环境变量中检索 Git 仓库的 URL
-
3 提交的注释字段包含版本号,如果没有提供,则为 1.0.0。
-
4 设置生成合同的目录
此代码片段配置发布者将生成的合同推送到 Git 仓库。只有在环境变量publishcontracts设置为true时,才会执行发布过程;否则,合同将在本地目录中生成,但不会发布。
接下来,我们将探讨如何在 arquillian.xml 中配置检索器。在 arquillian.xml 中注册检索器的方法与注册发布者的方法相同。与第 6.2.3 节中提到的相同检索器,可以作为注解使用,这里也支持。
下面是如何配置检索器以从 Git 仓库获取合同的方法:
<extension qualifier="algeron-provider">
<property name="retrieverConfiguration">
provider: git
url: ${env.giturl}
username: ${env.gitusername}
password: ${env.gitpassword}
</property>
</extension>
格式与提供者部分的格式类似。显然,属性名和扩展名是不同的,因为检索器是提供者端的一个重要部分。
在对合同测试进行了彻底介绍之后,让我们探索如何将这些方法应用到书籍的示例中。
6.3. 构建脚本修改
如你所知,合同测试在消费者端和提供者端之间分配。每个都有自己的依赖项。让我们看看使用 Pact JVM 或 Arquillian Algeron 时每个依赖项的情况。
6.3.1. 使用 Pact JVM 进行合同测试
如果你使用 Pact JVM 进行消费者驱动的合同,你需要添加依赖项。对于消费者部分,请添加以下依赖项:
dependencies {
testCompile group: 'au.com.dius',
name: 'pact-jvm-consumer-junit_2.11',
version: '3.5.0'
}
对于提供者部分,请添加以下依赖项:
dependencies {
testCompile group: 'au.com.dius',
name: 'pact-jvm-provider-junit_2.11',
version: '3.5.0'
}
6.3.2. 使用 Arquillian Algeron 进行合同测试
要使用 Arquillian Algeron 与 Pact JVM,你需要至少添加两个依赖项:Arquillian Algeron Pact 和 Pact 本身。
对于消费者部分,请添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.algeron',
name: 'arquillian-algeron-pact-consumer-core',
version: '1.0.1'
testCompile group: 'au.com.dius',
name: 'pact-jvm-consumer_2.11',
version: '3.5.0'
}
如果你使用的是 Git 发布者,也需要添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.algeron',
name: 'arquillian-algeron-consumer-git-publisher',
version: '1.0.1'
}
对于提供者部分,请添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.algeron',
name: 'arquillian-algeron-pact-provider-core',
version: '1.0.1'
testCompile group: 'au.com.dius',
name: 'pact-jvm-provider_2.11',
version: '3.5.0'
}
如果你想与 AssertJ 集成,也请添加以下内容:
dependencies {
testCompile group: 'org.arquillian.algeron',
name: 'arquillian-algeron-pact-provider-assertj',
version: '1.0.1'
testCompile group: 'org.assertj',
name: 'assertj-core',
version: '3.8.0'
}
如果你使用的是 Git 检索器,请添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.algeron',
name: 'arquillian-algeron-provider-git-retriever',
version: '1.0.1'
}
如果你使用的是 Maven 检索器,请添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.algeron',
name: 'arquillian-algeron-provider-maven-retriever',
version: '1.0.1'
}
如果你使用的是 Pact Broker 检索器,也需要添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.algeron',
name: 'arquillian-algeron-pact-provider-pact-broker-loader',
version: '1.0.1'
}
在你在构建脚本中注册了依赖项之后,你就可以开始编写合同测试了。
6.4. 为 Gamer 应用程序编写消费者驱动的合同
让我们为当前应用程序中提供的唯一消费者编写一个合同测试:聚合服务。我们还将展示提供者端,它验证给定的合同。在这种情况下,测试是在主项目中的新模块/子项目中创建的,例如,称为 c-tests。
6.4.1. 评论服务的消费者端
聚合服务与游戏和评论等服务进行通信,因此它实际上是所有这些服务的消费者。让我们看看如何编写聚合服务与评论服务之间的合同。负责与评论服务通信的类是book.aggr.CommentsGateway。这是一个简单的boundary类,作为聚合服务与评论服务之间的网关。你将使用 Arquillian Algeron,以利用其发布功能。
首先,这是存储评论的合同(code/aggregator/c-tests/src/test/java/book/aggr/CommentsContractTest.java)。
列表 6.8. 存储评论
@RunWith(Arquillian.class) *1*
@Pact(provider = "comments_service", consumer =
"games_aggregator_service") *2*
public class CommentsContractTest {
private static final String commentObject = "{" + " 'comment' " +
": 'This Game is Awesome'," + " 'rate' : 5," + " " +
"'gameId': 1234" + "}";
private static final String commentResult = "{" + " 'rate': " +
"5.0," + " 'total': 1," + " 'comments': ['This Game" +
" is Awesome']" + "}";
public PactFragment putCommentFragment(PactDslWithProvider
builder) { *3*
final Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
return builder.uponReceiving("User creates a new comment")
.path("/comments").method("POST").headers(headers)
.body(toJson(commentObject)) *4*
.willRespondWith().status(201).matchHeader
("Location", ".*/[0-9a-f]+",
"/comments/1234").toFragment();
}
@Test
@PactVerification(fragment = "putCommentFragment") // *5*
public void shouldInsertCommentsInCommentsService() throws
ExecutionException, InterruptedException {
final CommentsGateway commentsGateway = new CommentsGateway();
commentsGateway.initRestClient(url.toString()); //
// *6*
JsonReader jsonReader = Json.createReader(new StringReader
(toJson(commentObject)));
JsonObject commentObject = jsonReader.readObject();
jsonReader.close();
final Future comment = commentsGateway
.createComment(commentObject);
final Response response = comment.get();
final URI location = response.getLocation();
assertThat(location).isNotNull();
final String id = extractId(location);
assertThat(id).matches("[0-9a-f]+");
assertThat(response.getStatus()).isEqualTo(201);
}
-
1 Arquillian 测试运行器
-
2 设置带有消费者和提供者名称的 Pact 注解
-
3 创建定义发布评论合同的 PactFragment
-
4 设置正文消息
-
5 将方法配置为 putCommentFragment 中定义的片段的合同验证器
-
6 连接到 HTTP 存根服务器
这个测试使用 Arquillian standalone,因为没有@Deployment方法。在这个阶段,你不需要将任何内容部署到容器中。向评论服务发送评论的合同定义在putCommentFragment中,它定义了带有预期正文和预设响应的合同。最后,还有验证CommentsGateway类按预期工作的断言。
现在,让我们编写获取特定gameId评论的合同(code/aggregator/c-tests/src/test/java/book/aggr/CommentsContractTest.java)。在这种情况下,你需要设置一个状态,告诉提供者当合同与它验证时,你期望返回哪些数据。
列表 6.9. 获取游戏的评论
@StubServer
URL url;
public PactFragment getCommentsFragment(PactDslWithProvider
builder) {
final Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
return builder.given("A game with id 12 with rate 5 and " +
"message This Game is Awesome") *1*
.uponReceiving("User gets comments for given Game")
.matchPath("/comments/12").method("GET")
.willRespondWith().status(200).headers(headers)
.body(toJson(commentResult)).toFragment();
}
@Test
@PactVerification(fragment = "getCommentsFragment")
public void shouldGetComentsFromCommentsService() throws
ExecutionException, InterruptedException {
final CommentsGateway commentsGateway = new CommentsGateway();
commentsGateway.initRestClient(url.toString());
final Future<JsonObject> comments = commentsGateway
.getCommentsFromCommentsService(12);
final JsonObject commentsResponse = comments.get();
assertThat(commentsResponse.getJsonNumber("rate")
.doubleValue()).isEqualTo(5); *2*
assertThat(commentsResponse.getInt("total")).isEqualTo(1);
assertThat(commentsResponse.getJsonArray("comments"))
.hasSize(1);
}
-
1 设置状态信息
-
2 断言响应
注意,合同的定义与上一个类似。最大的区别是使用了given方法来设置状态。在这种情况下,你正在设置在提供者端所需的数据。
最后,你需要配置 Arquillian Algeron,以便在共享位置发布合同,以便提供者可以检索和验证它们(code/aggregator/c-tests/src/test/resources/arquillian.xml)。为了简化,这里使用了文件夹方法,但在现实世界中你可能会使用 Git 仓库。
列表 6.10. 在共享位置发布合同
<?xml version="1.0"?>
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd"
>
<extension qualifier="algeron-consumer">
<property name="publishConfiguration"> provider: folder
outputFolder: /tmp/mypacts
contractsFolder: target/pacts
</property>
<property name="publishContracts">
${env.publishcontracts:true}
</property>
</extension>
</arquillian>
现在你已经为消费者端写好了合同,让我们看看你需要做什么来验证它是否符合提供者端。
6.4.2. 评论服务的提供者端
为了验证消费者端生成的合约,您需要在提供者项目中创建一个测试,该测试下载合约并在提供者的运行实例上回放它们(code/comments/c-tests/src/test/java/book/comments/boundary/CommentsProviderTest.java)。您需要部署真实的评论服务,因此使用 Arquillian Algeron 是一个不错的选择:它负责创建部署文件并将其部署到应用程序服务器。合约存储在与前一个部分中讨论的publishConfiguration属性定义的同一文件夹中。
列表 6.11. 在提供者端测试评论服务
@RunWith(Arquillian.class)
@ContractsFolder(value = "/tmp/mypacts") *1*
@Provider("comments_service") *2*
public class CommentsProviderTest {
static { *3*
System.setProperty("MONGO_HOME",
"/mongodb-osx-x86_64-3.2.7");
}
@ClassRule *4*
public static ManagedMongoDb managedMongoDb =
newManagedMongoDbRule().build();
@Rule *5*
public MongoDbRule remoteMongoDbRule = new MongoDbRule(mongoDb
().databaseName("test").host("localhost").build());
@Deployment(testable = false) *6*
public static WebArchive createDeployment() {
final WebArchive webArchive = ShrinkWrap.create(WebArchive
.class).addPackage(CommentsResource.class
.getPackage()).addClass(MongoClientProvider.class)
.addAsWebInfResource("test-resources.xml",
"resources.xml").addAsWebInfResource
(EmptyAsset.INSTANCE, "beans.xml")
.addAsLibraries(Maven.resolver().resolve("org" +
".mongodb:mongodb-driver:3.2.2")
.withTransitivity().as(JavaArchive.class));
return webArchive;
}
private static final String commentObject = "{" + " 'comment' " +
": '%s'," + " 'rate' : %d," + " 'gameId': %d" + "}";
@State("A game with id (\\d+) with rate (\\d+) and message (.+)")
public void insertGame(int gameId, int rate, String message)
throws MalformedURLException { *7*
RestAssured.given().body(toJson(String.format
(commentObject, message, rate, gameId)))
.contentType(ContentType.JSON).post(new URL
(commentsService, "comments")).then().statusCode(201);
}
@ArquillianResource
URL commentsService;
@ArquillianResource *8*
Target target;
@Test
@UsingDataSet(loadStrategy = LoadStrategyEnum.DELETE_ALL) *9*
public void should_provide_valid_answers() {
PactProviderAssertions.assertThat(target).withUrl
(commentsService).satisfiesContract();
}
-
1 设置合约位置
-
2 配置提供者
-
3 设置 MongoDB 主目录
-
4 使用 NoSQLUnit 管理的 MongoDB
-
5 配置 MongoDB 远程连接
-
6 确保测试以客户端模式运行
-
7 从合约定义中向提供者注入所需数据
-
8 注入用于回放合约的目标类
-
9 每次运行后清理数据库
警告
在运行测试之前,务必将合约位置和 MongoDB 主目录适配到您的环境中。
此测试通过启动 MongoDB 数据库和 Apache TomEE 来准备环境。然后它部署应用程序并针对配置的环境回放合约。请注意以下三个重要事项:
-
您使用 NoSQLUnit 来准备 MongoDB 环境。NoSQLUnit 可用于集成测试,正如您在第五章中看到的,以及任何其他类型的测试。
-
状态方法
insertGame仅由定义消费者端状态的合约部分使用。这是合约中验证从服务接收评论的部分。 -
在状态方法中,测试使用 POST 方法填充数据,因此您实际上正在使用评论服务端点将数据插入数据库。您使用
RestAssured测试框架来完成此目的。
图 6.12 总结了运行此测试时的生命周期。首先,Arquillian 测试使用 NoSQLUnit 启动系统属性/环境变量MONGO_HOME上安装的 MongoDB 的一个实例。然后,它启动 Apache TomEE 的一个实例并在其中部署评论服务。如果合约定义了一个形式为A game with id (\d+) with rate (\d+) and message (.+)的状态,则使用评论服务在 MongoDB 中填充一些数据。最后,测试回放每个合约,在每次执行前清理数据库。
图 6.12. 测试生命周期

6.5. 合约类型总结
以下表格总结了我们所讨论的合约类型。
表 6.1. 消费者、提供者和消费者驱动合约
| 合约 | 完成 | 数量 | 有界 |
|---|---|---|---|
| 提供者 | 是 | 单一 | 空间/时间 |
| 消费者 | 否 | 多个 | 空间/时间 |
| 消费者驱动 | 是 | 单一 | 消费者 |
提供者和消费者驱动的方案是完整的:它们提供了一套完整的功能。从系统可用的功能角度来看,消费者驱动的合同是不完整的。此外,提供者和消费者驱动的方案在表达业务功能方面是单一的;但消费者驱动的方案,每个消费者都有自己的合同。
练习
现在,你应该能够编写任何一对消费者/提供者测试。我们建议你尝试定义游戏服务的消费者端,然后是提供者端。
小贴士
请参阅第四章,其中介绍了使用 Arquillian 为 WildFly Swarm 编写测试。
摘要
-
使用消费者驱动的合同可以提供测试的更快执行。
-
你不会得到不可靠的测试,因为使用 HTTP 存根服务器,你总是收到可靠的响应。
-
测试在消费者和提供者之间分割,因此更容易识别失败的原因。
-
将消费者驱动的合同纳入是设计过程的一部分。
-
消费者驱动的合同并不意味着独裁者驱动的合同。合同是协作努力的起点,始于消费者端,但双方都必须参与其中。
-
使用合同测试,你可以避免从消费者方面知道如何打包和部署提供者端。消费者端只需要知道如何部署其部分。当你验证提供者的合同时,提供者知道如何部署自己以及如何模拟/存根自己的依赖。这与端到端测试有巨大差异,在端到端测试中,你必须启动一个完整的环境才能运行测试。
-
消费者驱动的合同不一定总是最佳的方法。通常是的,但在某些情况下(如第 6.1.5 节中描述的情况),你可能想使用提供者驱动的合同或消费者合同。
第七章. 端到端测试
本章涵盖
-
微服务应用的端到端测试
-
端到端测试的工具
-
为端到端测试设置 Arquillian
端到端测试建立在集成测试之上,而集成测试又建立在所有其他你已经了解的测试形式之上。正如其名所示,端到端测试旨在从开始到结束(或者如果你更喜欢,从上到下)测试你的应用程序。理论上,这些测试应该模拟你的应用程序的真实世界用户,或者至少执行真实用户的操作。在实践中,这些测试通常是最难编写的,并且消耗最多的开发时间。与其他类型的测试相比,端到端测试几乎总是较慢,因此它们通常与常规的开发过程隔离——例如,在 Jenkins 构建服务器上(jenkins.io)。
注意
学习有关持续集成和交付的所有内容超出了本书的范围,所以我们认为可能还有另一本小书在计划中。(对于那些熟悉这个主题的人来说,请原谅这个双关语。)话虽如此,为了让你开始,第十章提供了一个关于如何使用 Jenkins 设置部署管道的相当详细的讨论。
端到端测试理想情况下应提供一个尽可能接近你的生产环境的环境,同时又是隔离的,这样它就不会损害实际系统。这可能意味着为画廊应用程序提供简单的图像目录副本,或者为数据仓库应用程序提供企业数据库的快照等复杂的东西。有时这可能是不可能的——例如,拥有一个真实的 SAP 端点进行测试可能是一个过高的开销,你希望避免;因此,使用一个模拟的、一个模拟的或 WireMock 将是一个合法的解决方案。
7.1. 端到端测试在整体测试图景中的位置
端到端测试是必需的,以确保从前端入口到后端正确接收输入。真正的挑战是如何将所有独立的微服务在单个机器上串联起来。本书的例子中有四个微服务,其中一个是 Spring Boot 应用程序,还有一个调用这些服务的 UI 应用程序。为了测试 UI,微服务必须在 UI 启动之前可用。之后,你需要执行必要的测试,记录和收集结果,然后关闭并清理环境。从某种意义上说,你是在将单体应用程序重新组装起来以进行测试。
单个微服务也可能需要所有依赖的服务,如数据库,都处于运行状态并已填充数据。正如你可以想象的那样,这个需求列表可以无限延伸。
也可能需要重复这个过程进行进一步的测试,但我们建议你尽量保持一切运行直到所有测试完成。尽可能地将测试批量进行,因为重启环境在时间上代价很高。
7.2. 端到端测试技术
从原则上讲,有两种类型的端到端测试:水平和垂直。我们将在稍后描述它们。这两种类型都可以在白盒或黑盒环境中执行,或者结合使用。白盒环境是指测试应用程序的可见或外部元素,而黑盒环境测试的是实际的功能(在后台)。
为了将这个概念应用到实际情境中:假设你有一个最终用户必须与之交互的 UI。用户可以可视化和对暴露的应用程序执行操作。这些操作导致用户对结果的期望。从逻辑上讲,为了模拟用户与 UI 的交互,你必须为测试提供 UI。这是一个白盒环境,因为用户可以看到操作的发生。
在用户与 UI 交互的白盒场景的同一范围内,操作可能会在底层服务器上调用过程。这些后端过程对用户是不可见的,但可能会产生用户最终会遇到的结果。这是一个黑盒环境,因为操作是在黑暗中进行的,换句话说。
端到端测试通常结合了白盒和黑盒环境,尤其是在基于浏览器的应用程序中。白盒是浏览器:你可以看到,并且可能影响测试。黑盒是服务器:你的操作发送一个请求,这个请求被无形地处理,然后返回一个响应。
7.2.1. 垂直测试
垂直端到端测试旨在测试应用程序展示的功能深度。对于一个用户界面(UI),这意味着在用户执行操作时,从一个视图转换到另一个视图之前,需要执行正确的验证。这种验证可能需要特定的用户权限在 LDAP 中,例如,从数据库检索正确的设置。
你基本上是在上下查看你所看到的,并确保一切井然有序。所有元素都应该根据你指定的环境存在,如图图 7.1 所示。
图 7.1. 一个简单的白盒垂直测试

该图可能看起来很简单,但执行这些测试很重要。你的应用程序的用户有相关的用户权限;如果由于你的代码中对用户权限的错误解释,搜索按钮没有被渲染或输入文本框被禁用,用户会如何反应?
7.2.2. 水平测试
横向端到端测试旨在测试应用的全范围。对于一个 UI,这意味着测试用户执行操作时,一个视图过渡到另一个视图。垂直测试确保你准备好通过验证进行过渡;横向测试则检查操作是否发生,以及结果是否符合预期。
这里,你从左到右寻找从一个视图到下一个视图的正确过渡。你的操作是否导致了正确的预期?或者,换句话说,是否处理了无效操作(负面测试)?图 7.2 展示了示例。
图 7.2. 黑盒和白盒横向测试

白盒操作向黑盒服务器发送请求,服务器随后返回一个作为列表渲染的响应。你需要测试过渡并确保列表显示且内容正确。
7.3. 端到端测试工具简介
端到端测试在单体应用中编写起来非常困难和复杂。在微服务架构中,编写此类测试更加复杂,所以任何能帮助你的是个加分项。幸运的是,一系列优秀的工具和(你猜对了)Arquillian 扩展可用以帮助你完成这项任务。让我们看看其中的一些。
7.3.1. Arquillian Cube
Arquillian Cube (arquillian.org/arquillian-cube/) 是 Arquillian 测试框架的一个扩展,它使得管理 Docker 环境中的容器成为可能。你可以使用这个扩展来部署一个设计的 Docker 镜像,并对其进行测试或在其中测试。镜像上托管的环境可以像你希望的那样简单或复杂。因此,而不是开发者需要在测试代码中了解并尝试提供对所有协作者(如数据库或其他服务)的访问,你只需将开发者的测试发送到一个已经一切准备就绪的镜像——只有 DevOps 需要担心由镜像提供的不断变化的环境。
Docker 镜像通常应该托管一个应用服务器。Arquillian 按照常规方式打包你的应用,并将 WAR 或 EAR 文件发布到托管服务器。这与第 4.1 节中描述的生命周期相同;唯一的区别在于,这里不是部署到本地应用服务器,而是部署到托管环境中的服务器。
这是一个重要的主题,您将在第八章(kindle_split_017_split_000.xhtml#ch08)中找到您需要了解的所有内容,我们将讨论 Docker。现在,我们将专注于 基本 的端到端单元测试——这可能在术语上似乎有些矛盾,因为仍然有很多东西需要整合。我们按此顺序介绍内容,以便您了解构建环境所涉及到的挑战,并可以看到可能出错的地方。在您踩下油门之前,了解引擎盖下发生的事情会更好。
7.3.2. Arquillian Drone
Arquillian Drone (arquillian.org/arquillian-extension-drone/) 是 Arquillian 测试框架的一个扩展,它使得访问知名的 Selenium WebDriver (seleniumhq.github.io/docs) 成为可能,而 Selenium WebDriver 又被用于浏览器自动化。在测试基于 Web 的 UI 时,浏览器自动化是一个关键需求。它使得测试能够模拟真实用户浏览您的应用程序并输入或操作数据的行为。
为什么您要使用这个扩展,如果它只是 WebDriver 的包装器呢?好吧,任何使用裸 WebDriver API 编写测试的人都会很快告诉你,即使是相对简单的测试,也需要大量的样板代码。Drone 隐藏了大部分这些样板代码,让您能够专注于编写测试的核心部分。仍然有一些设置需要执行,但我们在本章后面的示例中会介绍这些内容。
7.3.3. Arquillian Graphene 2
Arquillian Graphene 2 (github.com/arquillian/arquillian-graphene) 是(正如其名称所示)第二代快速开发扩展,旨在补充 Selenium WebDriver。尽管它可以用来创建独立的 AJAX 启用测试,但 Graphene 与 Arquillian Drone 扩展一起使用效果最佳。
7.3.4. JMeter
JMeter (jmeter.apache.org) 是 Apache 软件基金会的一个项目,可以用来对几乎所有类型的端点进行负载测试。它是一个完全基于 Java 的解决方案,因此可以在所有支持的平台上使用。它能够模拟大量的网络流量,主要用于测试应用端点的弹性。您将使用它来创建一些简单的压力测试,以确保您的服务能够承受一定的负载。
7.3.5. Cukes in Space
Cukes in Space (github.com/cukespace/cukespace) 是一个 Arquillian 扩展,允许您使用常见的 Given-When-Then 规范在 Cucumber JVM (cucumber.io/docs/reference) 上运行测试。
7.4. 示例端到端测试
现在您已经有了工具,是时候进行一个示例端到端测试了。正如我们提到的,这样的测试很复杂,这个也不例外。您正在使用各种技术进行演示微服务应用程序,所以您必须在端到端测试中处理这种额外的复杂性。好处是您可以看到一系列解决方案,这应该有助于您在未来开发自己的测试。这里没有硬性规则——您使用您拥有的工具,以获得您需要的成果。现在放手一搏。
我们使用一个简单的应用程序作为前端 UI,以突出显示端到端过程;这里没有惊喜。在源代码根目录下打开命令行,并运行以下命令:
cd web
mvn clean install -DskipTests
这将构建 Web 应用程序并确保所有必需的依赖项都可用并缓存。我们在这里跳过测试,因为我们想首先详细解释它。
7.4.1. 构建微服务
您需要做的第一件事是确保您迄今为止看到的所有示例微服务代码都已构建并准备好供您使用。您将使用这些项目生成的真实 WAR 和 JAR 文件来创建一个更真实的端到端测试。
在源代码根目录下打开命令行,并运行以下命令:
cd comments
./gradlew war -x test
cd ../aggregator
./gradlew war -x test
cd ../game
mvn install -DskipTests
cd ../video
./gradlew build -x test
注意
您在前面章节中彻底测试了所有微服务应用程序,所以在这里为了简洁起见,您将跳过测试。此外,这些项目中的某些测试被设计为失败,以突出某个点或提供用户练习。
7.4.2. 添加构建依赖项和配置
接下来,您需要将相关的物料清单(BOM)导入添加到您的 Web UI 应用程序的构建脚本dependencyManagement部分(在 code/web/pom.xml 中)。只有一个新的导入项:arquillian-drone-bom工件。这个新的 BOM 确保 Drone 所需的所有依赖项都可用。
列表 7.1. 添加arquillian-drone-bom工件
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jboss.arquillian</groupId>
<artifactId>arquillian-bom</artifactId>
<version>${version.arquillian-bom}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.extension</groupId>
<artifactId>arquillian-drone-bom</artifactId>
<version>2.0.0.Final</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
UI 应用程序需要graphene-webdriver工件来注入 Selenium WebDriver。这个 WebDriver 是与浏览器通信以执行操作的东西。
列表 7.2. 添加graphene-webdriver工件
<dependency>
<groupId>org.jboss.arquillian.graphene</groupId>
<artifactId>graphene-webdriver</artifactId>
<version>2.1.0.Final</version>
<type>pom</type>
<scope>test</scope>
</dependency>
安装自动化浏览器驱动程序
您需要确保为测试浏览器安装了适当的浏览器驱动程序。我们使用 Chrome 浏览器作为默认的测试浏览器,因此请确保 ChromeDriver 二进制文件(mng.bz/VZig)已下载并且可以在测试配置中对 Arquillian 可访问。这定义在webdriver扩展元素中,使用项目arquillian.xml文件中的chromeDriverBinary属性。在撰写本文时,您应该能够从www.seleniumhq.org/download找到支持浏览器的大量当前驱动程序。
列表 7.3. 添加浏览器驱动程序
<extension qualifier="webdriver">
<property name="browser">${browser:chrome}</property> *1*
<!--https://sites.google.com/a/chromium.org/chromedriver/-->
<property name="chromeDriverBinary">/home/andy/dev/chromedriver
</property> *2*
</extension>
-
1 浏览器属性,默认值为 chrome
-
2 指向驱动二进制的 chromeDriverBinary 属性
提示
总是会有很多关于使用不同测试浏览器的信息在网上。理想的解决方案是为您希望测试的每个浏览器定义不同的构建配置文件,在运行时覆盖browser属性。
使用和定义 NoSQL 数据库
一些有用的依赖项是de.flapdoodle.embed.mongo和embedded-redis工件(code/web/pom.xml)
列表 7.4. 添加依赖项
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
<version>2.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.kstyrc</groupId>
<artifactId>embedded-redis</artifactId>
<version>0.6</version>
</dependency>
警告
有可能向构建脚本添加各种未定义的神奇插件以确保这些运行时依赖项启动。这不是推荐的,因为测试将不再自包含,并且无法由 IDE 直接运行。
列表 7.4 中的整洁库能够在测试中直接启动 MongoDB 实例和 Redis 实例,相对开销很小。由于 JUnit 测试的整体生命周期管理,@BeforeClass通常不足以启动这些需求。实现 JUnit 规则允许您在测试生命周期中更深入地包含它们。规则中的变量用于保持对进程的引用,以便在测试完成后进行清理。您将在稍后定义的规则中使用的库将为您下载并初始化 MongoDB 和 Redis 实例。您无需手动准备任何东西,并且您的测试保持自包含。
下面的列表(code/web/src/test/java/book/web/rule/MongodRule.java)中展示的简单 Mongod 规则启动了一个绑定到提供的宿主和端口的 Mongod 实例。对于您的测试,您不需要更多,但库 API 允许进行完整的配置。如果您需要,可以轻松修改规则以接受更多参数。
列表 7.5. Mongod 规则
package book.web.rule;
import de.flapdoodle.embed.mongo.MongodExecutable;
import de.flapdoodle.embed.mongo.MongodProcess;
import de.flapdoodle.embed.mongo.MongodStarter;
import de.flapdoodle.embed.mongo.config.MongodConfigBuilder;
import de.flapdoodle.embed.mongo.config.Net;
import de.flapdoodle.embed.mongo.distribution.Version;
import de.flapdoodle.embed.process.runtime.Network;
import org.junit.rules.ExternalResource;
import java.io.IOException;
public class MongodRule extends ExternalResource {
private final MongodStarter starter
= MongodStarter.getDefaultInstance();
private MongodExecutable mongodExe;
private MongodProcess mongodProcess;
private String host;
private int port;
public MongodRule(String host, int port) {
this.host = host;
this.port = port;
}
@Override
protected void before() throws Throwable { *1*
try {
mongodExe = starter.prepare(new MongodConfigBuilder()
.version(Version.Main.PRODUCTION)
.net(new Net(this
.host, this.port, Network.localhostIsIPv6())).build());
mongodProcess = mongodExe.start();
} catch (final IOException e) {
e.printStackTrace();
}
}
@Override
protected void after() { *2*
//Stop MongoDB
if (null != mongodProcess) {
mongodProcess.stop();
}
if (null != mongodExe) {
mongodExe.stop();
}
}
}
-
1 在准备阶段创建并执行 Mongod 进程。
-
2 在清理阶段确保进程被终止并清理。
Redis 规则基本上与(code/web/src/test/java/book/web/rule/RedisRule.java)相同,但它使用库提供的RedisServer。
列表 7.6. Redis 规则
package book.web.rule;
import org.junit.rules.ExternalResource;
import redis.embedded.RedisServer;
public class RedisRule extends ExternalResource {
private RedisServer redisServer;
private int port;
public RedisRule(int port) {
this.port = port;
}
@Override
protected void before() throws Throwable {
try {
redisServer = new RedisServer(this.port);
redisServer.start();
} catch (final Throwable e) {
e.printStackTrace();
}
}
@Override
protected void after() {
//Stop Redis
if (null != redisServer) {
redisServer.stop();
}
}
}
注意
向 Krzysztof Styrc (github.com/kstyrc) 和 Michael Mosmann (github.com/michaelmosmann) 及他们的团队致敬,感谢他们提供这些优秀的开源项目!
我们希望您能看出,使用 JUnit 规则机制来包装各种测试资源非常简单。正如我们提到的,您将在后面的示例测试中使用这些规则。
提供微服务运行时环境
我们为这个示例测试提供的首要运行时环境是 Apache TomEE (tomee.apache.org)。你可以选择任何 Java EE 环境来部署你的 WAR 文件,只要它是 EE 兼容的。你还有一个 Spring Boot 应用程序和一个 WildFly fat JAR,但稍后你将处理这些。
Apache TomEE 提供了一个 Maven 插件,允许你在目标目录中自动创建一个可运行的 server 目录 gamerwebapp。这是一个完整的 TomEE 服务器发行版,由插件下载并解压。每次运行构建时,此过程都会附加到 Maven validate阶段。将以下代码添加到 code/web/pom.xml 中。
列表 7.7. 添加 Apache TomEE 运行时环境
<plugin>
<groupId>org.apache.tomee.maven</groupId>
<artifactId>tomee-maven-plugin</artifactId>
<version>${version.tomee}</version>
<configuration>
<catalinaBase>target/gamerwebapp</catalinaBase>
<tomeeClassifier>plus</tomeeClassifier>
<deployOpenEjbApplication>true</deployOpenEjbApplication>
<removeDefaultWebapps>true</removeDefaultWebapps>
<removeTomeeWebapp>true</removeTomeeWebapp>
</configuration>
<executions>
<execution>
<id>gamerwebapp</id>
<phase>validate</phase>
<configuration>
<attach>false</attach>
<zip>false</zip>
</configuration>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
有一个注意事项:插件在 Maven 构建阶段将 TomEE 服务器下载到项目的一个本地目录。如果你只是运行构建脚本,这将是可行的。但如果你想在 IDE 中调试测试,你需要至少运行一次构建脚本:
cd web
mvn clean validate -DskipTests
第一次运行此操作时,需要一段时间,因为 TomEE 需要下载并解压到项目本地目录。后续构建将快得多。
然后你进行一点 Maven 魔法,使用<artifactId>maven-resources-plugin</artifactId>来创建之前创建的 gamerwebapp 目录的多个副本。以下代码会为每个你想要部署为独立微服务 WAR 文件的微服务 WAR 文件重复。再次提醒,打开你的 IDE 中的 pom.xml 文件以获取完整信息。
列表 7.8. 将 target/gamerwebapp 复制到 target/commentsservice
<execution>
<id>create-commentsservice</id>
<phase>validate</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>target/commentsservice</outputDirectory>
<includeEmptyDirs>true</includeEmptyDirs>
<resources>
<resource>
<directory>target/gamerwebapp</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
在 arquillian.xml 中添加一个组
你已经知道你可以在 arquillian.xml 配置文件中定义多个容器。这对于提供由 Arquillian 框架支持的多个微服务环境来说非常完美。
列表 7.9. 定义一个组
<group qualifier="tomee-cluster" default="true"> *1*
<container qualifier="gamerweb" default="true"> *2*
<configuration>
<property name="httpPort">8080</property> *3*
<property name="stopPort">-1</property>
<property name="ajpPort">-1</property>
<property name="classifier">plus</property>
<property name="appWorkingDir">target/gamerweb_work
</property>
<property name="dir">target/gamerwebservice
</property> *4*
</configuration>
</container>
<container qualifier="commentsservice" default="false">
<configuration>
<property name="httpPort">8282</property>
<property name="stopPort">-1</property>
<property name="ajpPort">-1</property>
<property name="classifier">plus</property>
<property name="appWorkingDir">target/
commentsservice_work</property>
<property name="dir">target/commentsservice
</property>
</configuration>
</container>
<container qualifier="gameaggregatorservice" default="false">
<configuration>
<property name="httpPort">8383</property>
<property name="stopPort">-1</property>
<property name="ajpPort">-1</property>
<property name="classifier">plus</property>
<property name="appWorkingDir">target/
gameaggregatorservice_work</property>
<property name="dir">target/gameaggregatorservice
</property>
<property name="properties">
com.sun.jersey.server.impl.cdi.
lookupExtensionInBeanManager=true
</property>
</configuration>
</container>
</group>
-
1 组定义,在此标记为默认
-
2 定义一个具有唯一限定符的容器
-
3 容器特定的属性,用于定义 HTTP 端口和协议端口。TomEE 使用-1 来指示应使用随机端口。
-
4 实际服务器目录的路径(如之前在 pom.xml 中定义/创建的)
如果你还在担心 Spring Boot 和 WildFly 微服务,不要担心。你几乎完成了;只需再有一个部分。
7.4.3. 在测试中添加@Deployment 和@TargetsContainer
测试类正在变得庞大——它有很多事情要做。我们将关注每个核心元素。你可以打开你选择的 IDE 中的测试以获取完整信息,但你已经在之前的章节中看到了很多。在示例范围之外,我们建议将大部分初始化和部署代码放在一个抽象类中,以便在其他测试中重用。
提示
如果可能,从一个微服务的 WAR 文件 ShrinkWrap 开始构建部署。然后向微服务所需的依赖项混合中添加provided范围的工件。这意味着测试尽可能接近真实的生产部署。
下一步你需要做的是确保所有你的微服务都对测试类可用。你将从简单的部署开始(code/web/src/test/java/book/web/EndToEndTest.java)。后面的章节将涵盖使用 JUnit 规则进行更复杂的部署。
列表 7.10. 添加@Deployment和@TargetsContainer
@Deployment(name = "commentsservice", testable = false)
@TargetsContainer("commentsservice")
public static Archive commentsservice() throws Exception {
return ShrinkWrap.create(ZipImporter.class, "commentsservice.war")
.importFrom(getFile
("comments/build/libs/commentsservice.war"))
.as(WebArchive.class).addAsLibraries(Maven.resolver()
.resolve("org.mongodb:mongodb-driver:3.2.2")
.withTransitivity().as(JavaArchive.class)).addClass
(MongoClientProvider.class)
.addAsWebInfResource("test-web.xml", "web.xml")
.addAsWebInfResource("test-resources.xml"
, "resources.xml");
}
@Deployment(name = "gameaggregatorservice", testable = false)
@TargetsContainer("gameaggregatorservice")
public static Archive gameaggregatorservice() throws Exception {
return ShrinkWrap
.create(ZipImporter.class, "gameaggregatorservice.war")
.importFrom(
getFile("aggregator/build/libs/gameaggregatorservice.war"))
.as(WebArchive.class).addAsLibraries(
Maven.resolver().resolve("org.mongodb:mongodb-driver:3.2.2")
.withTransitivity().as(JavaArchive.class))
.addClass(MongoClientProvider.class)
.addAsWebInfResource("test-web.xml", "web.xml")
.addAsWebInfResource("test-resources.xml", "resources.xml");
}
@Deployment(name = "gamerweb", testable = false) *1*
@TargetsContainer("gamerweb") *2*
public static Archive gamerWebService() throws Exception {
return ShrinkWrap.create(MavenImporter.class)
.loadPomFromFile("pom.xml")
.importBuildOutput().as(WebArchive
.class).addAsWebInfResource("test-web.xml", "web.xml");
}
-
1 定义一个唯一的部署名称,这在针对多个容器进行测试时很重要
-
2 @TargetsContainer(“[name]”)确保应用程序被部署到在 arquillian.xml 中定义的指定容器中。
在理论上,你可以在 arquillian.xml 文件中指定无限数量的容器,然后可以将它们绑定到测试中的无限数量的部署。
注意
TomEE 插件包括选项,可以直接将provided范围的工件添加到服务器运行时 lib 目录中(毕竟这就是provided的含义)。有关更多信息,请参阅插件文档(tomee.apache.org/maven/index.html)。
7.4.4. 跨源资源共享
你可能已经注意到,为了允许从不同主机访问 RESTful 端点,你在多个微服务上启用了跨源资源共享(CORS)。你应该只在完全理解其影响后在自己的环境中这样做。但在测试环境中,尤其是在多个服务绑定到同一本地机器上的多个端口时,这通常是必要的。
CORS 仅在服务从不同于服务主机的不同主机接收请求的场景中是必需的:例如,一个独立的微服务。配置需要在服务主机上进行,以允许指定的主机消费可用的服务。
CORS 配置因不同的应用程序服务器而异,因此你需要检查你选择的服务器的相关文档。以下示例展示了 Apache TomEE 的宽松配置(code/web/src/test/resources/test-web.xml)。
列表 7.11. 为 TomEE 或 Tomcat 启用 CORS
<web-app
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" version="3.0">
<filter>
<filter-name>CorsFilter</filter-name>
<filter-class>org.apache.catalina.filters.CorsFilter
</filter-class>
<async-supported>true</async-supported>
<init-param>
<param-name>cors.allowed.origins</param-name>
<param-value>*</param-value>
</init-param>
<init-param>
<param-name>cors.allowed.methods</param-name>
<param-value>GET,POST,HEAD,OPTIONS,PUT</param-value>
</init-param>
<init-param>
<param-name>cors.allowed.headers</param-name>
<param-value>
Content-Type,X-Requested-With,accept,Origin,
Access-Control-Request-Method,Access-Control-Request-Headers
</param-value>
</init-param>
<init-param>
<param-name>cors.exposed.headers</param-name>
<param-value>Access-Control-Allow-Origin,
Access-Control-Allow-Credentials</param-value>
</init-param>
<init-param>
<param-name>cors.support.credentials</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>cors.preflight.maxage</param-name>
<param-value>10</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CorsFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
</web-app>
你的大部分服务在生产中可能托管在同一个域中,并且不需要这样的宽松配置。
7.4.5. 使用@ClassRule 应对混合环境
在撰写本文时,Arquillian 无法默认在同一个运行时中混合不同的容器环境。当你需要在测试中使用这种情况时,这将会成为一个问题,因为你使用了多个环境。你可以创建自己的容器实现,它封装了多个环境,但这超出了本书的范围。
我们选择的解决方案简单,尽管有点冗长。你知道 Spring Boot 和 WildFly Swarm 项目会生成胖 JAR,并且这些 JAR 文件是可执行的。你也看到了如何定义 JUnit 规则来包装外部进程(Mongod 和 Redis)。有了这些知识,使用 JVM 的 ProcessBuilder 来执行这些服务并在规则中管理进程生命周期相对简单,如列表 7.12(code/web/src/test/java/book/web/rule/MicroserviceRule.java)所示。
但是,即使进程可能已经启动,你也不能在端点可访问之前使用该服务。为了解决这个问题,你可以在测试序列中使用一个简单的连接方法,等待指定端点的有效连接。
列表 7.12. code/web/src/test/java/book/web/rule/MicroserviceRule.java
package book.web.rule;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.junit.Assert;
import org.junit.rules.ExternalResource;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;
public class MicroserviceRule extends ExternalResource {
private final Logger log =
Logger.getLogger(MicroserviceRule.class.getName());
private final ReentrantLock lock = new ReentrantLock();
private final CountDownLatch latch = new CountDownLatch(1);
private final AtomicBoolean poll = new AtomicBoolean(true);
private final AtomicReference<URL> url =
new AtomicReference<>();
private File file;
private String[] args;
private ResolutionStrategy strategy =
new DefaultJavaResolutionStrategy();
private long time = 30;
private TimeUnit unit = TimeUnit.SECONDS;
public MicroserviceRule(URL url) {
this.url.set(url);
}
public MicroserviceRule(String url) {
try {
this.url.set(new URL(url));
} catch (MalformedURLException e) {
throw new RuntimeException("Invalid URL: " + url, e);
}
}
public MicroserviceRule withExecutableJar(File file,
String... args) { *1*
Assert.assertTrue("The file must exist and be readable: " + file,
file.exists() && file.canRead());
this.file = file;
this.args = args;
return this;
}
public MicroserviceRule withJavaResolutionStrategy(ResolutionStrategy
strategy) {
this.strategy = (null != strategy ? strategy : this.strategy);
return this;
}
public MicroserviceRule withTimeout(int time, TimeUnit unit) {
this.time = time;
this.unit = unit;
return this;
}
private Process process;
@Override
protected void before() throws Throwable {
Assert.assertNotNull("The MicroserviceRule requires a
valid jar file", this.file);
Assert.assertNotNull("The MicroserviceRule requires a
valid url", this.url.get());
this.lock.lock();
try {
ArrayList<String> args = new ArrayList<>();
args.add(this.strategy.getJavaExecutable().toString()); *2*
args.add("-jar");
args.add(this.file.toString());
if (null != this.args) {
args.addAll(Arrays.asList(this.args));
}
ProcessBuilder pb =
new ProcessBuilder(args.toArray(new String[args.size()]));
pb.directory(file.getParentFile());
pb.inheritIO();
process = pb.start(); *3*
log.info("Started " + this.file);
final Thread t = new Thread(() -> {
if (MicroserviceRule.this.connect(
MicroserviceRule.this.url.get())) { *4*
MicroserviceRule.this.latch.countDown();
}
}, "Connect thread :: " + this.url.get());
t.start();
if (!latch.await(this.time, this.unit)) { *5*
throw new RuntimeException("Failed to connect
to server within timeout: "
+ this.url.get());
}
} finally {
this.poll.set(false);
this.lock.unlock();
}
}
@Override
protected void after() {
this.lock.lock(); *6*
try {
if (null != process) {
process.destroy();
process = null;
}
} finally {
this.lock.unlock();
}
}
private boolean connect(final URL url) {
do {
try {
Request request = new Request.Builder().url(url).build();
if (new OkHttpClient().newCall(request)
.execute().isSuccessful()) { *7*
return true;
} else {
throw new Exception("Unexpected family");
}
} catch (Exception ignore) {
if (poll.get()) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
return false;
}
}
}
} while (poll.get());
return false;
}
}
-
1 使用构建器模式来设置参数,使得规则的内联使用变得简单。
-
2 使用默认的 ResolutionStrategy 来查找 Java 可执行文件(可覆盖)
-
3 启动微服务的胖 JAR 可执行进程
-
4 轮询指定的端点 URL 以建立成功连接
-
5 在指定时间段后等待连接或超时
-
6 使用锁来同步启动和关闭过程
-
7 你只关心端点的有效连接,而不是响应。
正如使用 before 来初始化资源一样,你使用 after 来清理它们。
注意
你实际上并不是在测试端点;你使用它们来确认你的端到端测试准备就绪。请随意编写自己的连接检查例程以满足你的需求。
现在规则已经实现,你所需要做的就是将它们用于测试。
警告
避免使用非静态的 JUnit @Rule 注解,因为它会停止和启动每个单独测试的 所有 规则进程!
列表 7.13. code/web/src/test/java/book/web/EndToEndTest.java - @ClassRule
@ClassRule
public static RuleChain chain = RuleChain *1*
.outerRule(new MongodRule("localhost", 27017)) *2*
.around(new RedisRule(6379)) *3*
.around(new MicroserviceRule("http://localhost:8899/?videoId=5123
&gameName=Zelda").withExecutableJar
(getFile("video/build/libs/video-service-0.1.0.jar"),
"--server.port=8899")
.withJavaResolutionStrategy
(new DefaultJavaResolutionStrategy()).withTimeout
(1, TimeUnit.MINUTES)) *4*
.around(new MicroserviceRule("http://localhost:8181?query=")
.withExecutableJar(getFile
("game/target/gameservice-swarm.jar"), "-Dswarm" +
".http.port=8181").withJavaResolutionStrategy
(new DefaultJavaResolutionStrategy()).withTimeout(1,
TimeUnit.MINUTES));
-
1 使用静态 JUnit RuleChain 确保规则尽可能早地按定义的顺序执行
-
2 以 MongodRule 开始
-
3 使用 RedisRule
-
4 RedisRule 在 MicroserviceRule 之后,如果它们不相互依赖,可以指定任何顺序。
如果你的任何服务相互依赖,你可以在定义启动顺序的地方这样做。
提示
定义超时在所有情况下几乎都不是确定性的。对于端到端测试,你必须出于明显的原因打破这个规则——连接轮询可能永远不会成功。尝试调整参数以尽可能地在你的环境中实现确定性。
7.4.6. 使用 @OperateOnDeployment 操作部署
你已经多次看到如何定义和使用多个部署。在 列表 7.13 中,@OperateOnDeployment 注解指向特定测试应使用的部署。你还在使用 @ArquillianResource 注解注入资源 URL。你使用 @InSequence 注解来提供测试的顺序,严格来说,这对于正常单元测试来说是一个大忌。对于端到端测试,你已经知道你必须逻辑上协调环境,因此几乎不可能允许测试任意运行。总会有一个操作顺序,但测试仍然应该执行一个工作单元。
列表 7.14. 定义多个部署
private static final AtomicReference<URL>
commentsservice = new AtomicReference<>();
private static final AtomicReference<URL>
gameaggregatorservice = new AtomicReference<>();
private static final AtomicReference<URL>
gamerweb = new AtomicReference<>();
@Test
@InSequence(1)
@OperateOnDeployment("commentsservice")
public void testRunningInCommentsService(@ArquillianResource final URL url)
throws Exception {
commentsservice.set(url);
Assert.assertNotNull(commentsservice.get());
assertThat(commentsservice.get().toExternalForm(),
containsString("commentsservice"));
}
@Test
@InSequence(2)
@OperateOnDeployment("gameaggregatorservice")
public void testRunningInGameAggregatorService(@ArquillianResource
final URL url) throws Exception {
gameaggregatorservice.set(url);
Assert.assertNotNull(gameaggregatorservice.get());
assertThat(gameaggregatorservice.get().toExternalForm(),
containsString("gameaggregatorservice"));
}
@Test
@InSequence(4)
@OperateOnDeployment("gamerweb")
public void testRunningInGamerWeb(@ArquillianResource final URL url)
throws Exception {
gamerweb.set(url);
Assert.assertNotNull(gamerweb.get());
assertThat(gamerweb.get().toExternalForm(), containsString("gamerweb"));
}
7.4.7. 介绍 @Drone、页面对象、@Location 和 WebDriver
WebDriver 可以通过 @Drone 注解直接注入到你的测试中。更好的方法是创建所有与浏览器相关的测试的 页面对象。页面对象不过是虚拟包装器,旨在表示你的 UI 应用程序的单个可查看页面或元素。它们应该只封装与当前页面或元素相关的特定逻辑。
以下列表显示了 Index 页面对象的示例(code/web/src/test/java/book/web/page/Index.java)。
列表 7.15. 一个页面对象示例
package book.web.page;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.Graphene;
import org.jboss.arquillian.graphene.page.Location;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
@Location("/") *1*
public class Index {
@Drone
private WebDriver browser; *2*
@FindBy(id = "tms-search") *3*
private WebElement search;
@FindBy(id = "tms-button")
private WebElement button;
@FindBy(className = "col-sm-3")
private List list; *4*
public void navigateTo(String url) { *5*
browser.manage().window().maximize();
browser.get(url);
}
public List searchFor(String text) {
search.sendKeys(text); *6*
Graphene.guardAjax(button).click(); *7*
return list; *8*
}
public String getSearchText() {
return search.getAttribute("value");
}
}
-
1 可选的 @Location 注解定义了页面在服务器上的位置。
-
2 使用 WebDriver 访问浏览器功能
-
3 使用 Selenium @FindBy 注解通过其物理 DOM 标识符定位的元素
-
4 提供一个嵌入式页面片段(稍后详细介绍)
-
5 以编程方式管理浏览器环境
-
6 向选定的 HTML 元素(在这个例子中是文本框)发送按键
-
7 使用 Graphene 等待(阻塞)列表响应
-
8 返回页面片段以供进一步使用
这里还使用了一个酷炫的功能:页面片段,它们是表示 UI 页面动态元素的对象。下一个列表显示了示例(code/web/src/test/java/book/web/page/List.java)。你可以使用页面片段为测试提供一个逻辑模型。它们可以嵌套,这使得它们对于定义 UI 转换非常有用。使用 Graphene 允许测试在 UI 执行转换时自动阻塞。
列表 7.16. 一个页面片段示例
package book.web.page;
import org.jboss.arquillian.graphene.Graphene;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import java.util.ArrayList;
import java.util.Collection;
public class List {
@FindBy(className = "list-group")
private WebElement list;
@FindBy(id = "detail-view")
private Detail detail; *1*
public Collection<String> getResults() { *2*
ArrayList<String> results = new ArrayList<>();
if (null != list) {
java.util.List<WebElement> elements = list.findElements
(By.cssSelector("a > p"));
for (WebElement element : elements) {
results.add(element.getText());
}
}
return results;
}
public Detail getDetail(int index) {
java.util.List<WebElement> elements = list.findElements(By
.cssSelector("a > p"));
if (!elements.isEmpty()) {
Graphene.guardAjax(elements.get(index)).click(); *3*
}
return detail; *4*
}
}
-
1 提供另一个嵌套页面片段
-
2 收集结果以用于测试中的验证
-
3 使用 Graphene 等待(阻塞)详细响应
-
4 返回详细片段以供进一步使用
注意
这个简单的例子打开了 Selenium 浏览器自动化的广阔、狂野世界。这个主题可以轻易填满另一本书;花些时间访问 www.seleniumhq.org/docs 的文档。
7.4.8. 在测试中使用页面对象
一旦您定义了一个页面对象,您就可以使用@Page注解将其注入到您的测试中,如下所示(code/web/src/test/java/book/web/EndToEndTest.java)。然后可以使用它来操作测试浏览器以执行测试。
列表 7.17. 注入页面对象
@Page
@OperateOnDeployment("gamerweb")
private Index page; *1*
@Test
@InSequence(7)
public void testTheUI() throws Exception {
System.out.println("gameaggregatorservice = " +
gameaggregatorservice.get().toExternalForm());
page.navigateTo(gamerweb.get().toExternalForm()); *2*
List list = page.searchFor("Zelda");
Assert.assertEquals("", "Zelda", page.getSearchText());
Assert.assertThat(list.getResults(),
hasItems("The Legend of Zelda: Breath of the Wild"));
Detail detail = list.getDetail(0);
Assert.assertTrue(detail.getImageURL().startsWith("http"));
if (null != System.getProperty("dev.hack")) { *3*
new ServerSocket(9999).accept();
}
}
-
1 页面对象使用@Page 注入,并且它将@OperateOnDeployment(“gamerweb”)。
-
2 使用页面对象方法
-
3 简单的开发者技巧来停止测试(更多内容请参阅“开发技巧”侧边栏)
注意,您在navigateTo方法中提供了 URL。这是使用@Location注解的程序化替代方案,有时它可能过于僵化。
开发技巧
使用ServerSocket来停止运行是一个简单的开发技巧,您可能会觉得很有用。实际上,通过在 IDE 或 Maven 中“运行”测试类,您此时已经启动了所有微服务。这项技术对于开发 UI 很有用,因为您知道所有服务端点的位置。向测试运行时提供-Ddev.hack=true系统属性将确保它无限期地等待 9999 端口的连接。
您可以将 Web 应用程序部署到 IDE 中的可调试容器中,并继续针对运行中的服务进行开发。要停止测试运行时,请执行简单的curl localhost:9999调用。
显然,这种方法对每个人来说可能都不适用,微服务通常由个别团队开发和部署,但这可能值得思考。
最后可能引起兴趣的是 Graphene 可用的配置选项。各种守卫方法会阻塞一个可配置的周期。默认配置可以通过命令行覆盖
-Darq.extension.graphene.waitAjaxInterval=3
或者,在 arquillian.xml 中,如下所示。
列表 7.18. 覆盖默认配置
<extension qualifier="graphene">
<property name="waitAjaxInterval">3</property>
</extension>
除了我们在这里提到的,还有许多其他使用石墨烯的方法,但同样,这个主题超出了本书的范围。如果您想了解更多关于可用性的信息,您可以在docs.jboss.org/author/display/ARQGRA2/Home找到项目文档。
7.4.9. 运行测试
在所有这些之后,让这个测试运行起来可能真的会是一场噩梦,对吧?错!运行测试应该像任何其他单元测试一样直观——毕竟,这就是目标。
注意
如果您还没有获取所需的 API 密钥并定义相应的环境条目,如第二章所述,请在运行测试之前这样做。因为这个是端到端测试,这个测试会使用这些密钥对 REST API 进行实际调用;测试还需要互联网连接。
这里是命令:
cd web
mvn clean install
就这些了。如果一切就绪,以下事情会发生:
1. 测试类规则部署并启动了 MongoDB 和 Redis 服务器。
2. 测试类规则启动了独立的微服务。
3. 启动了所有 Arquillian 管理的容器,并部署了相应的应用程序。
4. Arquillian Drone 启动了一个测试浏览器,并显示了用户界面。
5. 每个测试按顺序运行,以提供对微服务和 Web 应用程序的访问。
6. 环境被干净地终止——所有服务器都已关闭。
图 7.3 展示了示例。
图 7.3. 测试期间的 UI 自动化

警告
如果你遇到错误消息“目标存在,我们没有标记为覆盖它”,请执行maven clean周期。这个错误通常意味着最后一次测试运行没有正确完成,并且在终止时未能执行清理。
你会注意到这个测试非常慢。在端到端测试中,这通常难以避免,因为需要执行大量的连接操作。唯一的真正解决方案是在除开发机器之外的专用机器上执行端到端测试。我们建议在专用构建模块中创建所有端到端测试。然后,可以在仅在外部机器(如 Jenkins 或 Bamboo)上启用的构建配置文件中激活此模块。有关 Maven 构建配置文件的信息,请参阅mng.bz/JDcT。
7.5. 练习
你现在有一个基本的测试模板,其中包含你执行有效端到端测试所需的全部功能,使用多个微服务。我们知道它并不漂亮,但这主要是因为你没有使用纯种环境。你在这里的唯一真正任务是评估为测试提供的信息和环境。
@InSequence(7)测试从垂直测试断言开始,检查输入是否正确。然后通过触发搜索并检索结果List执行水平操作。显然,这个测试违反了执行单一工作单元的规则,但它这样做是为了演示目的。继续通过将断言提取到更进一步的@InSequence(x)测试方法中。
此外,尽管你已经启动了 Mongod 和 Redis 实例,但你实际上并没有用任何测试数据对数据库进行初始化。尝试跳回到第五章并添加一个填充器到测试中。世界是你的牡蛎!
摘要
-
端到端测试与其他所有形式的测试一样重要,如果不是更重要的话,并且应该在所有需要任何形式用户交互的应用程序上执行。额外的开销将在以后带来回报:这是一个承诺!
-
总是尽可能使用抽象和重用代码来构建自己的测试套件。
-
在 UI 应用程序的设计早期规划端到端测试,以减少后续的影响。
-
在您的应用程序中为特色标签添加逻辑上明确定义的 UI 属性,如
name和id,将确保它准备好接受通过 Selenium 驱动程序推广的自动化操作。尽量定义并坚持您自己的命名约定。
第八章. Docker 与测试
本章涵盖
-
处理高级测试的困难
-
理解 Docker 如何帮助你进行测试
-
创建可重复的测试环境
-
与 Arquillian Cube 一起工作
本书传达的一个反复出现的消息,也是大多数开发者会同意的一个消息,就是高级测试意味着测试速度较慢——并且通常需要大量的努力来准备运行测试的环境。你在第五章中遇到了这种情况,你学习了要编写针对数据库的集成测试,你需要一个包含你将在生产中使用的数据库的环境。同样的事情发生在第七章中,你看到了你可能需要部署一个或多个微服务及其数据库。对于 Web 前端,你可能还需要安装特定的浏览器。
当你想测试应用程序的整体情况时,你需要在你的环境中添加更多组件来使其运行。以下是一些:
-
数据库
-
其他微服务
-
多个浏览器
-
分布式缓存
问题在于首先,你需要定义你的测试环境;其次,你必须提供一个运行时来运行此配置。这很可能不是你的开发机器,这对开发者来说是一个缺点,因为你将失去在本地重现或调试任何给定问题的可能性。远程调试,尽管不是不可能,但更加复杂。
使用 Docker 可以缓解这些问题。Docker 允许开发者在其本地机器上运行测试环境。
8.1. Docker 生态系统中的工具
Docker 生态系统包括几个工具,如 Docker Compose、Docker Machine 以及与生态系统集成的库,如 Arquillian Cube。本节介绍了其中的一些概述。
8.1.1. Docker
Docker 是一个开源平台,它简化了在容器中创建、部署和运行应用程序的过程。想象一下,容器就像一个盒子,你可以在其中部署一个应用程序以及它需要的所有依赖项,整洁地打包。以这种方式打包应用程序,你可以确保应用程序将在一个定义良好的环境中运行(由容器提供),而不管底层操作系统是什么。这使得你可以将容器从一个机器移动到另一个机器——例如,从开发机器到生产机器——而不用担心外部配置。
你可以将 Docker 想象成一个虚拟机,但不需要你自己安装和设置整个操作系统。Docker 无论容器实际在哪里运行,都重新使用相同的 Linux 内核。这种方法给你的启动时间带来了显著的性能提升,并减少了应用程序的大小。
图 8.1 显示了虚拟机与 Docker 容器的比较。主要区别在于,每个应用程序不需要一个客户操作系统,容器是在机器操作系统之上运行的。
图 8.1. 虚拟机与容器的比较

要运行 Docker 容器,您至少需要三个组件:
-
Docker client—一个命令行界面(CLI)程序,将用户的命令发送到安装/运行 Docker 守护进程的主机。
-
Docker daemon—一个在主机操作系统上运行的程序,执行所有主要操作,如构建、运输和运行 Docker 容器。在开发和测试阶段,Docker 守护进程和 Docker 客户端可能运行在同一台机器上。
-
Docker registry—一个用于共享 Docker 镜像的工件存储库。在
hub.docker.com有一个公共 Docker 注册库。
注意
镜像和容器的区别在于,镜像包含了所有组件,如应用程序和配置参数。它没有状态且永远不会改变。另一方面,容器是 Docker 守护进程上运行的镜像的运行实例。
图 8.2 显示了 Docker 架构的方案。Docker 客户端与 Docker 守护进程/主机通信以执行命令。如果主机上没有所需的 Docker 镜像,它将从 Docker 注册库下载。最后,Docker 主机从给定的镜像实例化一个新的容器。
图 8.2. Docker 架构方案

使用 Docker 客户端从 Docker 注册库检索镜像并在 Docker 主机上运行可能如下所示:
docker pull jboss/wildfly *1*
docker run -ti -p 8080:8080 jboss/wildfly *2*
-
1 从 Docker 注册库将镜像拉取(下载)到 Docker 主机
-
2 在 Docker 主机上启动 JBoss/WildFly 容器
执行上述命令后,您可以在浏览器中导航到 http://
图 8.3. 欢迎使用 WildFly

现在您已经了解了 Docker 的基本知识,让我们看看一个名为Docker Machine的 Docker 工具。
8.1.2. Docker Machine
Docker Machine帮助您在 VirtualBox 和 VMware 等虚拟化平台上创建 Docker 主机。它还支持大多数流行的基础设施即服务(IaaS)平台,如亚马逊网络服务(AWS)、Azure、DigitalOcean、OpenStack 和谷歌计算引擎。除了在指定主机上安装 Docker 外,它还配置 Docker 客户端以与它们通信。
通常情况下,在开发机器上使用 VirtualBox 的虚拟化方法是最佳选择。请注意,在这种情况下,您需要安装 VirtualBox,因为 Docker Machine 不会执行此安装步骤。
要创建安装了 Docker 的 VirtualBox 镜像,请运行以下命令:
docker-machine create --driver virtualbox dev
经过一段时间,所有内容都将安装并准备好在本地机器上使用。最后的步骤是开始创建 VirtualBox 镜像,并配置客户端指向该实例:
docker-machine start dev *1*
eval $(docker-machine env dev) *2*
-
1 启动名为 dev 的主机
-
2 配置 Docker 客户端环境变量
本地机器上的 Docker
如果你想在本地机器上使用 Docker Machine,最佳入门方式是使用 Docker Toolbox。它适用于 Windows 和 macOS 系统。
Docker Machine 有一个安装程序,它安装以下工具:
-
Docker 客户端
-
Docker Machine
-
Docker Compose
-
Oracle VirtualBox
-
Kitematic (Docker GUI) 和预配置的 shell
8.1.3. Docker Compose
Docker Compose 是一个由两个元素组成的容器多管理工具:
-
一种 YAML 格式,描述了一个或多个容器,它们协同工作以形成应用程序。它还指定了容器如何相互交互,以及一些其他信息,如网络、卷和端口。
-
一个 CLI 工具,用于读取 Docker Compose 文件并创建由文件定义的应用程序。
默认情况下,任何 Docker Compose 文件都应命名为 docker-compose.yml。一个简单的 docker-compose.yml 文件可能看起来像以下这样。
列表 8.1. docker-compose.yml
tomcat: *1*
image: tutum/tomcat:7.0 *2*
ports: *3*
- "8081:8080"
links: *4*
- pingpong:pingpong
pingpong:
image: jonmorehouse/ping-pong
-
1 定义了一个容器名称
-
2 设置要使用的镜像
-
3 定义了一对绑定/暴露端口
-
4 定义了容器之间的链接,形式为 [service-name:alias]
当你在 列表 8.1 中的文件上从终端运行 docker-compose up 时,将启动两个容器:tomcat 和 pingpong。它们通过名为 pingpong 的 link 连接在一起。Tomcat 服务的端口 8081 对 Docker 主机的客户端暴露,将所有流量转发到内部容器端口 8080。如果未指定别名,容器可以通过与别名或服务名称相同的主机名访问。
Docker Compose 最好的测试相关特性之一是扩展功能。它允许你在不同的文件之间共享常见的配置片段。
让我们看看使用 extend 关键字的 Docker Compose 文件的一个示例。你应该首先在 Docker Compose 文件中定义一个常见的或抽象的容器定义,其他容器可以扩展它,默认情况下,该文件不应命名为 docker-compose.yml。你应该努力遵循命名约定,即名为 docker-compose.yml 的文件是运行系统将使用的 唯一 文件,因此让我们将此文件命名为 common.yml。
列表 8.2. common.yml
webapp:
image: myorganization/myservice
ports:
- "8080:8080"
volumes:
- "/data"
这里没有比之前定义的新内容,只是定义了一个名为 webapp 的新服务。现在,让我们定义 docker-compose.yml 文件以使用 common.yml,并为该服务设置新的参数。
列表 8.3. docker-compose.yml
web:
extends: *1*
file: common.yml *2*
service: webapp *3*
environment: # *4*
- DEBUG=1
-
1 开始一个扩展部分
-
2 设置可扩展文件的存储位置
-
3 设置要扩展的元素
-
4 设置/覆盖属性
你可以在终端中运行 docker-compose:
docker-compose -f docker-compose.test.yml up
如你所见,使用 Docker 和 Docker Compose,你可以轻松定义可以在任何需要运行测试的机器上使用的测试环境。使用这些工具结合的两个主要优势如下:
-
每个运行测试的环境都包含所需库、依赖项和/或服务器的确切版本。无论运行在哪些物理机器上:可能是开发、测试或预生产机器。它们都包含相同的运行组件。
-
除了 Docker 之外,你不需要在测试机器上安装任何东西。其他所有内容都在运行时解决。
注意
本章提供了一个关于 Docker Compose 的非常基本的介绍,我们鼓励你到 docs.docker.com/compose 学习更多。
8.2. Arquillian Cube
到目前为止,你已经了解到 Docker 和 Docker Compose 是测试的理想搭档。它们应该帮助你定义可靠且可重复的测试环境,这样每次执行测试时,你都知道环境已经正确设置。
问题在于你需要手动运行 docker-compose up 或在构建工具中添加一个步骤来启动它,然后再执行测试。你还需要处理 Docker 主机 IP 地址,它可能是 localhost——但并不一定是,因为它可能是一个 Docker 机器或远程 Docker 主机。
虽然手动启动 Docker Compose 可能是一个好的方法,但我们的观点是测试应该尽可能地自我执行,而不需要人工干预或复杂的运行时。你已经猜到了:有一个酷炫的 Arquillian 扩展可以帮助你实现目标。Arquillian Cube 是一个扩展,可以在 Arquillian 测试中管理 Docker 容器。它使用的方法类似于 Arquillian Core 用于应用服务器的方法,但进行了修改以适应 Docker 容器。Arquillian Cube 可以用于以下场景:
-
准备高级测试的测试环境
-
测试 Dockerfile 组合
-
验证 Docker Compose 组合
-
白盒和黑盒测试
如 图 8.4 所示,在执行测试之前,Arquillian Cube 读取 Docker Compose 文件并按正确顺序启动所有容器。然后 Arquillian 等待直到所有服务都启动并运行,以便它们能够接收传入的连接。之后,在测试环境中执行测试。执行完成后,所有正在运行的容器都会停止并被从 Docker 主机中移除。
图 8.4. Arquillian Cube 生命周期

使用 Arquillian Cube,您的基于 Docker 的测试将完全自动化。您可以使用 Docker Compose 格式定义您的测试环境,Arquillian 运行器将为您处理所有事情。这再次让您,作为开发者,有更多时间编写实际的测试。
注意
并非必须使用 Docker 进行生产。您也可以仅利用 Docker 准备一个有效的测试环境。
当在生产环境中使用 Docker 时,您可以使用 Arquillian Cube 编写测试来验证您的容器镜像是否正确创建,或者例如,容器是否可启动并且可以从 Docker 主机外部访问。显然,在生产环境中,您可以使用几乎与创建测试环境相同的 Docker Compose 文件,并验证所有定义的容器是否可以相互通信以及环境变量是否设置正确。
其潜在用途是无限的,因为使用 Arquillian Cube 和 Docker 进行测试的方法有无数种。我们将在以下章节中介绍最常见的使用案例。
8.2.1. 设置 Arquillian Cube
Arquillian Cube 需要您设置几个参数。它使用一些默认参数,这些参数可能在大多数情况下都适用,还有一些参数是智能地从环境中推断出来的。有时您可能需要修改这些自动定义的参数。表 8.1 描述了可以在 arquillian.xml 文件中设置的最重要的配置属性。
表 8.1. Arquillian Cube 参数
| 属性 | 描述 | 默认行为 |
|---|---|---|
| serverUri | 容器将要实例化的 Docker 主机的 URI。 | 如果设置了环境变量 DOCKER_HOST,则获取其值;否则,对于 Linux,它设置为 unix:///var/run/docker.sock,而在 Windows 和 macOS 上设置为 https://<docker_host_ip>;:2376。docker_host_ip 由 Arquillian Cube 自动解析,通过获取 boot2docker 或 docker machine IP。 |
| dockerRegistry | 设置从其中下载镜像的 Docker 仓库的位置。 | 默认情况下,这是公共 Docker 仓库 registry.hub.docker.com。 |
| 用户名 | 设置连接到 Docker 仓库的用户名。(您需要一个账户。) | |
| 密码 | 设置连接到 Docker 仓库的密码。(您需要一个账户。) | |
| dockerContainers | 将 Docker Compose 内容作为 Arquillian 属性嵌入,而不是作为 Docker Compose 文件。 | |
| dockerContainersFile | 设置 Docker Compose 文件的位置。位置相对于项目根目录;但也可以是一个 URI,它将被转换为 URL,因此您可以在远程站点上有效地拥有 Docker Compose 定义。 | |
| dockerContainersFiles | 设置逗号分隔的 Docker Compose 文件位置列表。内部,所有这些位置都被追加到一个文件中。 | |
| tlsVerify | 设置 Arquillian Cube 是否应使用传输层安全性 (TLS) 连接到 Docker 服务器的一个布尔值。 | 如果设置了 TLS_VERIFY 环境变量,则获取该值;否则,如果服务器 Uri 方案是 http,则自动设置为 false;如果是 https,则设置为 true。您可以通过设置此属性来强制设置一个值。 |
| certPath | 如果您使用 HTTPS,则证书存储的路径。 | 如果设置了 DOCKER_CERT_PATH 环境变量,则获取该值;否则,从 boot2docker 或 docker-machine 解析位置。您可以通过设置此属性来强制设置一个值。 |
| machineName | 如果您使用 Docker Machine 来管理您的 Docker 主机,则设置机器名称。 | 如果设置了 DOCKER_MACHINE_NAME 环境变量,则获取该值;否则,如果当前 Docker 机器实例中只有一个机器正在运行,则自动解析机器名称。您可以通过设置此属性来强制设置一个值。 |
提示
请记住,可以使用 ${system_property} 占位符或 ${env.environment_variable} 占位符通过系统属性或环境变量来配置 arquillian.xml 配置属性。
Arquillian Cube 连接模式
测试环境会为每个测试套件启动和停止。这意味着,根据元素的启动时间,测试时间可能会受到影响,尤其是在小型测试套件中。
使用 Arquillian Cube,您可以选择绕过在具有相同容器名称的 Docker 主机上已运行的 Docker 容器的创建/启动。这允许您预先启动容器(例如,在持续集成 [CI] 构建脚本中或在工作开始之前),并连接到它们以避免测试执行期间的额外开销。
下面是一个如何配置 connectionMode 属性的示例:
<extension qualifier="cube">;
<property name="connectionMode">;STARTORCONNECT</property>;
</extension>;
您可以为该属性设置以下模式:
-
STARTANDSTOP—默认模式,如果没有指定。创建并停止所有 Docker 容器。 -
STARTORCONNECT—如果已运行具有相同容器名称的容器且该容器在测试完成后不会被终止,则绕过创建/启动步骤。如果为 Cube 配置的容器尚未运行,则 Arquillian 将在执行结束时启动它并停止它,其行为类似于STARTANDSTOP模式。 -
STARTORCONNECTANDLEAVE—与STARTORCONNECT模式完全相同;但如果容器是由 Arquillian Cube 启动的,则在执行结束时它不会停止,因此可以在下一个周期中重用。
现在,您已经熟悉了 Arquillian Cube 中的常见配置参数,让我们来探索如何使用它编写测试。
8.2.2. 编写容器测试
我们将为 Arquillian Cube 覆盖的第一个用例是验证服务中定义的 Dockerfile 是否正确用于将应用程序容器化。尽管您可以执行多个检查,但最常见的是以下内容:
-
Docker 能够无错误地构建镜像。
-
服务暴露了正确的端口。
-
服务已正确启动并且可以正确地服务传入的请求。
首先,在 arquillian.xml 中配置 Docker,然后创建一个最小化的脚本,用于构建和运行测试中的镜像。
列表 8.4. 配置 Docker
<?xml version="1.0"?>;
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd">;
<extension qualifier="docker">;
<property name="machineName">;dev</property>; *1*
<property name="dockerContainers">; *2*
myservice:
build: ./docker
ports:
- "8080:8080"
</property>;
</extension>;
</arquillian>;
-
1 只有在使用 Docker Machine 时才需要这一行。
-
2 在 Docker Compose 格式中定义如何构建和运行镜像
当你使用 Docker Machine 并且有多个机器运行时,名为dev的机器用于构建和运行测试中的 Docker 容器。然后,使用dockerContainers属性,你嵌入一个 Docker Compose 容器定义,从位于 docker 目录中的预期 Dockerfile 构建镜像,并暴露端口 8080。如前所述,Dockerfile 是 Docker 定义的默认文件名。Dockerfile 可能看起来像以下这样:
FROM tomee:8-jdk-7.0.2-webprofile
ADD build/libs/myservice.war /usr/local/tomee/webapps/myservice.war
EXPOSE 8080
这里定义的镜像基于 Apache TomEE tomee:8-jdk-7.0.1-webprofile Docker 镜像。ADD命令将你的项目部署 WAR 文件添加到指定的镜像路径——在这个镜像中,TomEE 热部署路径是/usr/local/tomee/webapps/,因此在这里添加 WAR 文件将自动部署应用程序。最后,EXPOSE命令将 TomEE HTTP 端口 8080 暴露给外部世界。
使用这些信息,你可以编写一个测试来验证镜像是否正确构建,是否暴露了正确的端口,并且运行正确。
列表 8.5. 验证镜像
@RunWith(Arquillian.class) *1*
public class DockerImageCreationTest {
@ArquillianResource *2*
private DockerClient docker;
@HostIp *3*
private String dockerHost;
@HostPort(containerName = "myservice", value = 8080) *4*
private int myservicePort;
@Test
public void should_expose_correct_port() throws Exception {
assertThat(docker)
.container("myservice")
.hasExposedPorts("8080/tcp"); *5*
}
@Test
public void should_be_able_to_connect_to_my_service() throws Exception {
assertThat(docker)
.container("myservice")
.isRunning(); *6*
final URL healthCheckURL = new URL("http", dockerHost,
myservicePort, "health");
String healthCheck = getHealthCheckResult(healthCheckURL);
assertThat(healthCheck).isEqualTo("OK"); *7*
}
}
-
1 Arquillian 运行器
-
2 测试中增加了 Docker 客户端以访问 Docker 主机。
-
3 测试中增加了 Docker 主机 IP。
-
4 获取容器 myservice 暴露的端口 8080 的绑定端口
-
5 断言构建的镜像正在暴露端口 8080
-
6 断言容器正在运行
-
7 断言健康检查端点返回服务正在运行和运行中
关于这个测试有几个需要注意的点。首先,你应用了 Arquillian 运行器,但没有@Deployment方法。这是因为这些测试不需要在应用服务器中部署任何内容:容器镜像接收运行测试所需的部署文件,服务器已经启动。你实际上是在使用 Arquillian 提供的所有元素,但没有部署任何内容。
注意
任何没有使用@Deployment注解方法的测试都必须使用arquillian-junit-standalone或arquillian-testng-standalone依赖项,而不是container依赖项。所有测试都在 as-client 模式下运行,因为它们不能部署到应用服务器。
第二点需要注意的是,Arquillian Cube 为测试提供了一些增强器。在这个测试中,注入了 DockerClient 对象。这个对象为你提供了一些强大的操作,用于与 Docker 主机通信并获取正在运行的容器的信息。此外,测试通过 dockerHost 变量增强了 Docker 主机 IP 或主机名。容器暴露的端口 8080 的绑定端口也作为 myservicePort 变量注入。这些变量提供了允许测试与 TomEE 服务器和托管应用程序通信的信息。
最后但同样重要的是,测试方法用于验证 Dockerfile 是否正确配置,构建是否正确,以及它所暴露的服务是否正确部署。Arquillian Cube 提供了自定义 AssertJ 断言。例如,你可以编写断言来断言在 Docker 主机中实例化了特定的 Docker 镜像,或者端口已暴露,或者特定进程按预期运行。
如果由 Dockerfile 定义的构建失败,Arquillian Cube 会抛出异常,导致测试失败。使用部署服务的端点健康检查来验证在 Docker 容器中部署的微服务是否正常运行。
测试执行后,Arquillian Cube 会从 Docker 主机中删除构建的镜像。这确保了每次运行测试时磁盘空间不会增加,并且确保每个测试都是独立于下一个测试运行的。
接下来,让我们看看如何使用 Arquillian Cube 来测试更复杂的场景,如集成测试。
8.2.3. 编写集成测试
你在第五章中了解到,验证两个系统之间的连接是可能的,例如微服务与数据库(如 SQL 或 NoSQL)之间的通信,或者两个微服务之间的通信。在这种情况下,测试你的网关代码针对任何你将在生产中使用的真实系统是很正常的。与通常使用存根或模拟的 组件测试 相比,这是一个很大的不同。
集成测试的最大挑战是如何一致地设置环境来运行这些测试。例如,你可能需要在开发人员和 CI 机器上使用与生产中相同的数据库。你可能还需要一种方法来部署实际测试的微服务的所有依赖微服务。此外,确保所有环境中机器的版本保持一致并不是一个简单任务。在 Docker 之前,这种设置没有所有机器上的所有内容就很难实现。
你已经看到 Docker 和 Docker Compose 可以帮助你为测试准备一个一致的环境,以及 Arquillian Cube 如何帮助自动化这个过程。在本节中,我们将查看一个示例。
Arquillian 部署和 Docker
正如我们在第四章中所述,Arquillian 有三种方式来管理应用服务器:
-
嵌入式—应用服务器与测试运行时(IDE、构建工具等)共享相同的 JVM 和类路径。
-
管理—应用服务器独立于测试运行时启动。它实际上创建了一个新的 JVM,与实际的测试 JVM 无关。
-
远程—Arquillian 不管理应用服务器的生命周期。它期望重用已经启动并运行的实例。
有了这个想法,你可以使用 Arquillian 将你的(微)部署文件部署到运行在 Docker 容器中的应用服务器中。从 Arquillian 的角度来看,这个应用服务器实例是一个远程实例,其生命周期由第三方(在这种情况下,Docker)管理。
回想一下,类路径中的运行时适配器是 Arquillian 知道如何管理应用服务器生命周期的方式。例如,在 Apache Tomcat 的情况下,对于远程模式,您需要定义 org.jboss.arquillian.container:arquillian-tomcat-remote-7:1.0.0.CR7 依赖项。
如您所见,可以利用(微)部署并使用 Docker 来设置(部分)环境。
让我们使用 Arquillian Cube 创建一个集成测试,以测试服务与其数据库之间的集成。你将使用微部署方法来打包与持久层相关的类。为了添加到之前的 Docker Compose 文件格式,请确保使用 Docker Compose 格式版本 2 而不是版本 1。
以下列表显示了测试的外观。
列表 8.6. 集成测试
@RunWith(Arquillian.class)
public class UserRepositoryTest {
@Deployment
public static WebArchive create() { *1*
return ShrinkWrap.create(WebArchive.class)
.addClasses(User.class, UserRepository.class,
UserRepositoryTest.class)
.addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml")
.addAsResource("test-persistence.xml", "META-INF/persistence.xml")
.addAsManifestResource(new StringAsset(
"Dependencies: com.h2database.h2\n"),
"MANIFEST.MF");
}
@Inject
private UserRepository repository;
@Test
public void shouldStoreUser() throws IOException {
repository.store(new User("test"));
User user = repository.findUserByName("test");
assertThat(user.getName()).isEqualTo("test");
}
}
- 1 仅使用所需的持久层类和文件创建微部署
这与运行任何其他 Arquillian 测试没有不同,因此测试不会知道它是在本地还是远程实例上运行。
提示
如果需要,您还可以在这些测试中受益于使用 Arquillian Persistence 扩展。
下一步是定义一个 docker-compose.yml 文件,该文件启动服务器和数据库。
列表 8.7. 启动服务器和数据库
version: '2'
services:
tomcat:
env_file: envs *1*
build: src/test/resources/tomcat *2*
ports:
- "8089:8089"
- "8088:8088"
- "8081:8080"
db:
image: zhilvis/h2-db *3*
ports:
- "1521:1521"
- "8181:81"
-
1 从名为 envs 的文件中设置环境变量
-
2 Tomcat 镜像是通过 Dockerfile 构建的。
-
3 使用 H2 服务器 Docker 镜像
提示
总是注意 YAML 文件中的缩进。这是至关重要的!
在此文件中,创建了一个默认网络,并在两个容器之间共享。容器名称是每个容器用来查找其他实例的主机名别名。例如,一个用于到达 db 的 tomcat 容器配置可能是 jdbc:h2:tcp://db:1521/opt/h2-data/test。
Dockerfile 应该添加一个 tomcat-users.xml 文件,其中包含一个具有部署远程应用所需角色的用户。您需要定义环境变量来配置 Tomcat 以动态接受部署外部应用,并设置密码:
CATALINA_OPTS=-Djava.security.egd=file:/dev/urandom *1*
JAVA_OPTS= -Djava.rmi.server.hostname=dockerServerIp \ *2*
-Dcom.sun.management.jmxremote.rmi.port=8088 \ *3*
-Dcom.sun.management.jmxremote.port=8089
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false
-
1 改变熵计算方式的小技巧,以便 Tomcat 快速启动
-
2 dockerServerIp 参数在运行时自动替换为 Docker 主机 IP。
-
3 JMX 控制台被配置为接受远程通信。
熵
在 Tomcat 配置片段中的熵技巧仅在 Linux 平台上使用,但它也可以提高 Windows 机器的启动速度。默认的SecureRandom实现非常慢,因为它必须等待操作系统建立熵——这可能会花费几分钟。指定urandom对于极端加密算法来说稍微不安全一些。在某些系统上,如果你仍然注意到显著的启动时间,你可能需要使用替代语法(注意额外的斜杠):
-Djava.security.egd=file:/dev/urandom
其他选项还包括定义一个随机数的物理文件。在网上搜索 java.security.egd 以了解更多关于这个主题的信息。
最后但同样重要的是,你需要配置 Arquillian Cube 扩展以加载提供的 Docker Compose 文件。你还需要配置远程适配器,将 tomcat-users.xml 文件中声明的用户设置为连接到 Tomcat 服务器并部署应用程序。
列表 8.8. 配置 Arquillian Cube 扩展和远程适配器
<?xml version="1.0"?>;
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd">;
<extension qualifier="docker">;
<property name="dockerContainersFile">;docker-compose.yml</property>; *1*
</extension>;
<container qualifier="tomcat">; *2*
<configuration>;
<property name="user">;admin</property>;
<property name="pass">;mypass</property>;
</configuration>;
</container>;
</arquillian>;
-
1 设置 Docker Compose 文件的定位。在本例中,它位于项目的根目录中。
-
2 配置 Tomcat 适配器,使用 admin 和 mypass 作为认证参数以部署
这就是你需要配置的所有内容。Arquillian Cube 会自动处理 Tomcat 的运行位置,并将测试应用程序部署到正确的(远程)Docker 主机 IP。需要注意的是,这些步骤大多数是针对 Tomcat 的,使用其他容器可能需要不同的步骤并触及不同的文件。
警告
在 arquillian.xml 中的qualifier值必须与 docker-compose.yml 文件中定义的容器名称相同。在之前的例子中,容器名称是tomcat,而 qualifier 也是tomcat,这很有意义。
当你运行这个测试时,以下步骤将被执行:
1. Arquillian Cube 读取 Docker Compose 文件,然后在 Docker 主机上构建和实例化指定的镜像。
2. Arquillian Core 将包含持久层类的微部署文件部署到在 Docker 主机中运行的 Tomcat 容器实例中。
3. 测试是在整个测试环境设置并启动时执行的。
4. 在所有测试执行完毕后,微部署文件将被卸载。
5. Docker 容器实例将被终止并从 Docker 主机中移除。
注意,这个测试现在运行在自己的测试环境中,该环境托管了生产中使用的所需数据库。你不需要在每个项目的实际开发环境或 CI 环境中安装任何软件依赖项。Docker 和 Arquillian Cube 会自动提供测试所需的依赖项。
现在你已经看到了如何使用 Arquillian Cube 编写集成测试,接下来让我们看看如何使用它进行端到端测试。
8.2.4. 编写端到端测试
第七章 解释了,理论上你可以通过模拟你的应用程序的真实世界用户,或者至少执行真实用户的操作来从始至终验证你的应用程序。在实践中,这些测试通常是最难编写的,因为它们覆盖了大量的交互——在大多数情况下(但并非总是)是与 UI 的交互。它们还要求你设置一个完整的测试环境,包含应用程序可能与之交互的所有可能元素,例如
-
服务器
-
数据库
-
分布式缓存
-
浏览器
你现在知道 Docker、Docker Compose 和 Arquillian Cube 可以帮助你准备测试环境。请注意,在端到端测试中,你可能不需要在测试中创建部署文件;你将重用现有、已版本化的应用程序核心的 Docker 镜像。因此,正如你在第四章中看到的,那里没有提供部署方法,你需要使用 Arquillian Core 的 standalone 依赖项。
让我们看看为你在第 8.2.3 节测试的相同应用程序的 docker-compose.yml 文件可能是什么样子。
列表 8.9. 用于端到端测试的 Docker Compose 文件
version: '2'
services:
myservice:
env_file: envs
image: superbiz/myservice:${version:-latest} *1*
ports:
- "8081:8080"
db:
image: zhilvis/h2-db
ports:
- "1521:1521"
- "8181:81"
- 1 使用系统属性或环境变量设置镜像版本。如果没有设置,则使用默认值“latest”,由冒号和短横线符号表示。
在此文件中,你并不是构建一个新的 Docker 容器,而是在构建微服务的过程中重用已经构建的容器。每次运行端到端测试时,包含微服务的 Docker 镜像可能都是不同版本;因此,包含微服务的最终镜像名称在测试时通过设置名为 version 的系统属性或环境变量来动态生成。
配置文件(arquillian.xml)与之前的使用案例没有变化。
列表 8.10. 端到端测试的配置文件
<?xml version="1.0"?>;
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd">;
<extension qualifier="docker">;
<property name="dockerContainersFile">;docker-compose.yml</property>; *1*
</extension>;
</arquillian>;
- 1 设置 docker-compose.yml 文件位置
使用多个 Docker Compose 文件定义
在简单的情况下,如果一个微服务不是另一个微服务的消费者,使用单个 Docker Compose 文件可能就足够了。但如果要测试的微服务本身是其他一个或多个微服务的消费者,您可能还需要在测试之前启动所有这些服务。这同样适用于您想要编写端到端测试的情况,不仅是为了给定的微服务及其所有依赖项(这些依赖项可以是其他微服务),而且是针对整个系统。
在这种情况下,您仍然可以依赖创建一个包含所有测试所需微服务和依赖项的单个 Docker Compose 文件。但从准备就绪、可维护性和反映微服务环境变化的角度来看,这可能不是一个好主意。
我们的观点是,每个微服务都应该定义自己的 Docker Compose 文件,以设置其运行所需的测试/生产环境。这使得端到端测试变得容易,因为您可以使用有用的 Arquillian Cube dockerContainersFiles属性合并所有定义。
在以下代码片段中,Arquillian Cube 下载所有远程 Docker Compose 文件并将它们合并成一个单一组合。然后 Arquillian Cube 启动所有定义的容器,之后执行测试:
<?xml version="1.0"?>;
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd">;
<extension qualifier="docker">;
<property name="dockerContainersFiles">; *1*
docker-compose.yml,
http://myhub/provider1/test/docker-compose.yml,
http://myhub/provider2/test/docker-compose.yml
</property>;
</extension>;
</arquillian>;
- 1 存储 Docker Compose 文件的列表
如您所见,没有必要在单一位置定义测试环境。每个微服务都可以定义自己的测试环境。
最后,您可以使用在第七章(chapter 7)中学到的知识编写端到端测试,使用那里公开的任何框架。您可以为每个测试添加不同的 Docker/容器环境值,例如 Docker 主机 IP,并解决给定公开端口的端口绑定值:
@HostIp *1*
String ip;
@HostPort(containerName = "tomcat", value = 8080) *2*
int tomcatPort;
@CubeIp(containerName = "tomcat") *3*
String ip;
-
1 注入 Docker 主机的 IP
-
2 解析 tomcat 容器公开端口 8080 的绑定端口
-
3 注入 tomcat 容器的 IP
在注入 Docker 主机 IP 和容器绑定端口后,您可以为端点测试配置任何测试框架,针对运行在 Docker 主机上的微服务进行配置。例如,您可以通过以下方式配置 REST Assured (rest-assured.io)来测试一个运行在 Docker 主机上的微服务:
RestAssured.when()
.get("http://" + ip + ":" + tomcatPort + "/myresource")
.then()
....
这是一种通过构建所需 URL 来配置任何测试框架的方法。但 Arquillian Cube 提供了与 REST Assured 和 Arquillian Drone/Graphene 的紧密集成,因此您不需要在每次测试中都处理这个问题。
8.3. Rest API
因为 Arquillian Cube 提供了与 REST Assured 的集成,所以你不需要在所有使用 Docker 与 REST Assured 一起使用的测试中重复相同的配置代码。这种集成意味着你可以注入一个预配置了当前 Docker 主机 IP 和端口的io.restassured.builder.RequestSpecBuilder实例。(侧边栏“关于端口解析”解释了端口解析是如何工作的。)以下测试使用了 REST Assured 集成:
@RunWith(Arquillian.class)
public class MyServiceTest {
@ArquillianResource *1*
RequestSpecBuilder requestSpecBuilder;
@Test
public void should_be_able_to_connect_to_my_service() {
RestAssured
.given()
.spec(requestSpecBuilder.build()) *2*
.when()
.get()
.then()
.assertThat().body("status", equalTo("OK"));
}
}
-
1 使用预定义的 Docker 参数的 RequestSpecBuilder
-
2 REST Assured 配置了请求规范。
如你所见,这个测试与使用 REST Assured 的任何测试类似。唯一的区别是现在你正在设置配置了 Docker 值的请求规范对象。
关于端口解析
Arquillian Cube REST Assured 集成尝试自动解析哪个端口是公共微服务的绑定端口。默认情况下,Arquillian Cube 扫描 Docker Compose 文件中定义的所有 Docker 容器,如果只有一个绑定端口,则使用该端口。
如果有多个绑定端口,那么必须为 Arquillian Cube 用于与微服务通信的暴露端口定义port配置属性。例如,如果你使用绑定配置 8080:80,其中暴露端口是 80,绑定端口是 8080,那么当你将port属性设置为80时,扩展将解析为 8080。
要设置port属性,你需要将其添加到arquillian.xml中:
<extension qualifier="restassured">; *1*
<property name="port">;80</property>; *2*
</extension>;
-
1 设置 REST Assured 配置部分
-
2 暴露端口以解析
如果没有暴露指定编号的端口,那么配置属性中指定的端口也被用作绑定端口。
8.4. Arquillian Drone 和 Graphene
当你在运行涉及浏览器作为前端 UI 的端到端测试时,你可能会遇到的一个问题是设置测试环境。你需要在运行测试的每台机器上安装所有必需的要求,包括所需的浏览器(以及特定的版本)。
如你在第七章中学到的,事实上的工具用于 Web 浏览器测试是 Selenium WebDriver。Arquillian 生态系统提供了 Arquillian Drone 和 Graphene 作为使用 WebDriver 的集成扩展。
Selenium 项目为 Selenium 独立服务器提供了预装 Chrome 和/或 Firefox 的 Docker 镜像。因此,你实际上不需要在测试环境中安装浏览器,因为浏览器被当作任何其他由 Docker 管理的测试依赖项,例如数据库、分布式缓存和其他服务。
8.4.1. 集成 Arquillian Cube 和 Arquillian Drone
Arquillian Cube 通过自动执行几个繁琐的任务与 Arquillian Drone 集成:
-
如果尚未设置,则启动具有正确
browser属性的 Docker 容器,webdriver扩展设置为Firefox。Selenium 镜像的版本与在测试类路径中定义的版本相同。 -
提供一个可以连接到容器的
WebDriver。 -
创建一个虚拟网络计算(VNC)Docker 容器,记录每个浏览器容器中发生的所有测试执行,并将它们以 MP4 格式存储在本地机器上。
这些交互总结在图 8.5 中。
图 8.5. Arquillian 集成

表 8.2 描述了您可以在 arquillian.xml 中定义的最重要配置属性。
表 8.2. Arquillian Cube Graphene 配置参数
| 属性 | 描述 | 默认行为 |
|---|---|---|
| recordingMode | 要使用的录制模式。有效值是 ALL、ONLY_FAILING 和 NONE。 | ALL |
| videoOutput | 存储视频的目录。 | 创建 target/reports/videos 或,如果 target 不存在,则创建 build/reports/videos。 |
| browserImage | 要用作自定义浏览器镜像的 Docker 镜像,而不是默认镜像。 | |
| browserDockerfileLocation | 用于构建自定义 Docker 镜像的 Dockerfile 位置,而不是默认的 Dockerfile。此属性优先于 browserImage。 | |
注意
自定义镜像必须暴露端口 4444,以便WebDriver实例可以访问浏览器。如果使用 VNC,则必须也暴露端口 5900。
这里是一个典型配置的示例:
<?xml version="1.0"?>;
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd">;
<extension qualifier="docker">; *1*
<property name="dockerContainersFile">;docker-compose.yml</property>;
</extension>;
<extension qualifier="webdriver">; *2*
<property name="browser">;${browser:chrome}</property>;
</extension>;
<extension qualifier="cubedrone">; *3*
<property name="recordingMode">;NONE</property>;
</extension>;
</arquillian>;
-
1 典型的 Arquillian Cube 配置
-
2 从系统属性或环境变量中配置浏览器属性,默认使用“chrome”
-
3 禁用录制功能
在这里,Arquillian Cube 被配置为启动 docker-compose.yml 文件中定义的所有容器。请注意,此文件不包含任何关于浏览器的信息,因为这由 Arquillian Cube Drone 集成自动解决。
通过将browser系统属性或环境变量设置为firefox或chrome来指定浏览器。如果没有定义,则默认使用chrome。最后,禁用录制功能。
警告
在撰写本文时,Selenium 项目仅提供 Firefox 和 Chrome 的镜像。创建 Internet Explorer 镜像仍然是留给用户的一项任务。
在 列表 8.11(HelloWorldTest.java)中展示的实际测试看起来与任何 Drone 和 Arquillian Cube 测试类似,只有一个细微的区别。所有浏览器命令(因此,WebDriver)都是在 Docker 主机内部执行的,这意味着您受到 Docker 主机规则的约束。因此,在这个测试中,您不是使用 HostIp 来获取 Docker 主机的 IP 地址,而是使用 CubeIp,它返回给定容器的 内部 IP 地址。这是必需的,因为浏览器是在 Docker 主机内部运行的,并且要连接到同一 Docker 主机中的另一个容器,您需要主机名或内部 IP 地址。
列表 8.11. HelloWorldTest.java
@RunWith(Arquillian.class)
public class HelloWorldTest {
@Drone *1*
WebDriver webDriver;
@CubeIp(containerName = "helloworld") *2*
String ip;
@Test
public void shouldShowHelloWorld() throws Exception {
URL url = new URL("http", ip, 80, "/"); *3*
webDriver.get(url.toString());
final String message = webDriver.findElement(By.tagName("h1")).getText();
assertThat(message).isEqualTo("Hello world!");
}
}
-
1 使用 Drone 注解注入 WebDriver 实例
-
2 注入 helloworld 容器的内部 IP
-
3 连接浏览器到微服务的 URL
接下来,让我们看看 Arquillian Cube 和 Arquillian Graphene 是如何集成的。
8.4.2. 集成 Arquillian Cube 和 Arquillian Graphene
Arquillian Graphene 是 WebDriver API 的一组扩展,专注于在 Java 环境中的快速开发和可用性。它通过简化网页抽象(页面对象和页面片段)的使用来追求可重用测试。
Arquillian Graphene 依赖于 Arquillian Drone 来提供 WebDriver 的实例,因此 Arquillian Cube Docker(例如记录功能)之间的集成中有效的一切也适用于 Arquillian Cube Graphene。
区分在 Arquillian Drone 中编写的测试和在 Arquillian Graphene 中编写的测试的主要因素之一是后者测试会自动解析应用程序的主机和上下文。在 Arquillian Drone 中,您需要通过调用 webdriver.get(...) 方法显式设置它们。
Arquillian Graphene 提供的此自动解析功能仅在您以容器模式运行测试时才有效。(Arquillian 管理具有 @Deployment 方法的类的部署文件。)当您使用独立模式(没有 @Deployment 声明)时,这可能在端到端测试中是情况,您需要使用 arquillian.xml 配置 Arquillian Graphene,并指定应用程序部署的 URL:
<extension qualifier="graphene">;
<property name="url">;http://localhost:8080/myapp</property>; *1*
</extension>;
- 1 设置 Graphene 测试使用的 URL
问题在于,当您使用 Arquillian Cube 时,您可能在配置时间不知道 Docker 主机 IP 地址——只有在运行时阶段才知道。因此,您还不能可靠地设置它!
Arquillian Cube 通过提供可以在 url 属性中定义的特殊关键字 dockerHost 与 Arquillian Graphene 集成,该关键字在测试环境启动时由当前的 Docker 主机 IP 地址替换。此外,如果 url 的 主机 部分不是 dockerHost 或有效的 IP 地址,则该主机被认为是 Docker 容器名称,并将其替换为其容器内部 IP。
了解这一点后,前面的示例可以重写为 Arquillian Cube Graphene 兼容的形式:
<extension qualifier="docker">;
<property name="dockerContainersFile">;docker-compose.yml</property>;
</extension>;
<extension qualifier="graphene">;
<property name="url">;http://helloworld:8080/myapp</property>; *1*
</extension>;
- 1 设置 Graphene 使用的 URL
基于这些信息,您现在知道以下内容:
-
URL 中的
helloworld部分将被容器的内部 IP 地址替换。 -
应使用的端口是
helloworld容器公开的端口。
您现在可以定义一个页面对象,就像在其他 Arquillian Graphene 测试中一样:
@Location("/") *1*
public class HomePage {
@FindBy(tagName = "h1")
private WebElement welcomeMessageElement;
public void assertOnWelcomePage() {
assertThat(this.welcomeMessageElement.getText().trim())
.isEqualTo("Hello world!");
}
}
- 1 表示此页面对象的页面路径
注意,在这种情况下,您没有设置任何有关实际主机名的信息,您只是设置了此页面的相对上下文位置。
最后,与正常测试相比,测试没有变化。所有内容都由 Arquillian Cube 在底层管理:
@RunWith(Arquillian.class)
public class HomePageTest {
@Drone
WebDriver webDriver;
@Test
public void shouldShowHelloWorld(@InitialPage HomePage homePage) {
homePage.assertOnWelcomePage();
}
}
如您所见,主机信息没有出现在任何测试中。它通过 arquillian.xml 文件解析并提供给环境。这使得测试可以在任何环境中重用,因为您可以在执行测试之前动态更改基本 URL。
注意
仅当您使用独立模式(使用独立依赖项)的 Arquillian 时,才需要 Arquillian Cube 的 Graphene 自动解析。如果您使用容器模式,URL 由部署方法解析,您不需要指定任何内容。在我们看来,使用 Docker(或更一般地)编写的端到端测试应该使用 Arquillian 独立模式;这更接近于模拟真实的生产环境,这正是您试图实现的目标。
8.5. 并行化测试
当您针对单个 Docker 主机运行测试时,可能会遇到的一个问题是,在主机中运行的每个容器都必须有一个唯一名称。通常,这可能不是问题,但在某些情况下,它可能导致冲突:
-
如果您并行运行同一项目中定义的测试,那么,假设它们正在重用相同的 Docker Compose 文件,将使用相同的 Docker 主机。这将导致冲突,因为您为每个测试使用了相同的容器名称。
-
不同的项目在您的 CI 环境中运行测试并重用相同的 Docker 主机。例如,两个微服务定义了一个名为
db的 Docker 容器,并且它们正在同时构建。
有一些解决方案可以减轻这些问题:
-
第一个问题可以通过在每个并行执行中设置
arq.extension.docker.serverUri属性来解决,以使用不同的 Docker 主机。 -
第二个问题可以通过为每个项目使用一个代理/从机来解决,每个代理/从机都有自己的 Docker 主机。
与这些解决方案一起,Arquillian Cube 提供了一个名为 星号操作符(*)的有用工具。
星号操作符允许您向 Arquillian Cube 指示您想要随机生成 Docker 容器名称的一部分。所有生成的信息都会自动适应使用随机元素。您需要做的只是将一个星号字符(*)添加到 Docker Compose 文件中容器名称的末尾。以下是一个示例:
tomcat:
image: tutum/tomcat:7.0
ports:
- "8081:8080"
links:
- pingpong* *1*
pingpong*: *2*
image: jonmorehouse/ping-pong
ports:
- "8080:8080"
-
1 设置部分随机容器名称的链接
-
2 将容器名称设置为部分随机
给定这个 Docker Compose 文件,Arquillian Cube 将在运行时用 UUID 替换*字符,为每次执行生成。绑定端口被更改为随机端口(范围在 49152–65535)。同时,为容器提供了一个新的环境变量,该变量包含指向新主机位置的随机容器设置的链接;这个环境变量的形式是<containerName>;_HOSTNAME。
Arquillian Cube 应用更改后的 docker-compose.yml 文件可能看起来像这样:
tomcat:
image: tutum/tomcat:7.0
ports:
- "8081:8080"
links:
- pingpong_123456 *1*
environment:
- ping_HOSTNAME=ping_123456 *2*
pingpong_123456: *3*
image: jonmorehouse/ping-pong
ports:
- "54678:8080" *4*
-
1 将链接更新为随机容器名称
-
2 包含新容器主机名的环境变量
-
3 使用随机名称定义容器
-
4 绑定端口更新为随机端口
警告
使用星号运算符将使你的 Docker Compose 文件与docker-compose CLI 不兼容。此外,请注意,由links部分定义的 DNS 中的hostname条目也是随机生成的,因为容器名称已被更改。
星号运算符不是一个全有或全无的解决方案——你可以将它与其他方法一起使用。理想的情况是每个并行执行或奴隶/代理有一个 Docker 主机。
8.6. Arquillian Cube 和 Algeron
在第六章中,你学习了关于消费者驱动的合约以及它们是如何使用 Arquillian Algeron 扩展运行的。你通过两个步骤执行它们:第一步是在消费者端,你启动一个 stub HTTP 服务器并向它发送请求;第二步是回放并验证所有针对真实提供者的交互。这些交互在图 8.6 中进行了总结。
图 8.6. Pact 生命周期

要运行提供者合约测试,你需要部署提供者服务,然后回放并验证所有交互。这正是 Docker 和 Arquillian Cube 可以帮助你的地方,通过简化提供者服务的部署阶段。
到目前为止,使用 Arquillian Algeron 与 Arquillian Cube 之间没有太大的区别。但让我们看看一个快速示例,其中引入了一个新的 Arquillian Cube 丰富方法:
@RunWith(Arquillian.class) *1*
@Provider("provider")
@ContractsFolder("pacts")
public class MyServiceProviderTest {
@ArquillianResource *2*
@DockerUrl(containerName = "helloworld", exposedPort = "8080",
context = "/hello")
URL webapp;
@ArquillianResource *3*
Target target;
@Test
public void should_provide_valid_answers() {
target.testInteraction(webapp); *4*
}
}
-
1 Arquillian Cube 和 Arquillian Algeron 注解
-
2 使用有效的 Docker 值丰富 URL
-
3 丰富 Arquillian Algeron 目标
-
4 对 Docker 容器进行回放验证
这个测试基本上与任何其他 Arquillian Cube 和 Arquillian Algeron Pact 提供者相同,但在这个情况下,测试被添加了一个访问提供者的 URL。这个 URL 是通过解析dockerHost作为主机部分创建的。端口号是通过获取特定容器(在这种情况下是"helloworld")的注解中设置的公开端口来附加的。然后附加由注解定义的上下文。例如,生成的 URL 可能具有http://192.168.99.100:8081/hello的值。测试的其余部分与其他测试基本相同。
提示
你可以在任何 Arquillian Cube 独立测试中使用@DockerUrl注解,而不仅仅是当使用 Arquillian Algeron 时。但请注意,增强测试是在独立模式下进行的(没有@Deployment方法)。@DockerUrl增强仅在独立模式下运行 Arquillian 时才有效。
当然,你仍然需要定义一个 Docker Compose 文件并配置 Arquillian Cube。但为了简化,并且因为你已经在前面的章节中看到了这些步骤,所以我们跳过了这些步骤。
8.7. 使用容器对象模式
到目前为止,你已经看到了如何使用 Docker Compose 文件“编排”Docker 容器。Arquillian Cube 还提供了一种使用 Java 对象定义 Docker 容器的方法。
使用 Java 对象定义容器配置使你能够为 Docker 容器定义添加一些动态性,例如以编程方式修改 Dockerfile 的内容或模拟容器属性,如 IP 地址、用户名和密码。此外,因为你正在创建 Java 对象,你可以使用语言提供的任何资源,例如扩展定义、从测试中注入值或打包值为交付工件。
你可以将容器对象视为以可重用、可维护的方式对容器进行建模。因为它们是 Java 对象,所以没有任何东西阻止你在多个项目中重用它们。这减少了代码重复的数量,并且任何修复只需要在一个地方应用,而不是在多个项目中。
在我们向您展示如何实现容器对象之前,让我们看看一个它们有用的例子。假设你的微服务(或项目)需要向 FTP 服务器发送一个文件。你需要编写一个集成测试来验证你的业务代码能否正确执行此操作。
你的测试必须能够执行以下操作:
-
找到 FTP 服务器运行的 hostname/IP 和端口。
-
定义访问 FTP 服务器和存储文件所需的用户名和密码。
-
断言 FTP 服务器上文件的存在,以验证文件是否正确发送。
编写此测试的一种方法就是使用 Docker Compose 方法:
ftp:
image: andrewvos/docker-proftpd
ports:
- "2121:21"
environment:
- USERNAME=alex
- PASSWORD=aixa
这种方法有几个问题:
-
你需要将此 docker-compose.yml 文件复制到所有你想使用 FTP 服务器编写集成测试的项目中。
-
测试需要了解 Docker 容器的内部细节,例如用户名和密码。
-
测试包含特定于 Docker 容器的逻辑,例如如何验证文件是否已复制。
-
任何更改都需要传播到所有用例。
显然,这里有一个很好的候选者来编写一个封装所有与 FTP 服务器相关的逻辑的容器对象模式。以下列表显示了此容器对象可能的样子(FtpContainer.java)。
列表 8.12. 容器对象模式
@Cube(value = "ftp",
portBinding = FtpContainer.BIND_PORT + "->;21/tcp") *1*
@Image("andrewvos/docker-proftpd") *2*
@Environment(key = "USERNAME", value = FtpContainer.USERNAME) *3*
@Environment(key = "PASSWORD", value = FtpContainer.PASSWORD)
public class FtpContainer {
static final String USERNAME = "alex";
static final String PASSWORD = "aixa";
static final int BIND_PORT = 2121;
@ArquillianResource *4*
DockerClient dockerClient;
@HostIp
String ip;
public String getIp() {
return ip;
}
public String getUsername() {
return USERNAME;
}
public String getPassword() {
return PASSWORD;
}
public int getBindPort() {
return BIND_PORT;
}
public boolean isFilePresentInContainer(String filename) { *5*
InputStream file = null;
try (
file = dockerClient
.copyArchiveFromContainerCmd("ftp", "/ftp/" + filename)
.exec()){
return file != null;
} catch(Exception e){
return false;
} finally{
if (null != file) {
try {
file.close();
} catch (IOException e) {
//no-op
}
}
}
}
}
-
1 定义 Cube 名称和绑定端口
-
2 配置要在容器中设置的环境变量
-
3 设置 Docker 镜像
-
4 在容器对象中启用增强功能
-
5 封装与容器相关的操作
如您在这个类中看到的,容器对象是一个普通的 Java 对象(POJO)。此对象使用@Cube注解标注了启动容器所需的配置参数,例如name和bind/expose端口,以及使用@Image注解映射的 Docker 镜像。您可以将迄今为止学到的任何 Arquillian 测试增强功能应用于容器对象,例如host_ip、host_port和docker client。
现在您已经看到了如何定义容器对象,以下是如何在测试(FtpClientTest.java)中使用它的方法。
列表 8.13. 在测试中使用容器对象
@RunWith(Arquillian.class)
public class FtpClientTest {
public static final String REMOTE_FILENAME = "a.txt";
@Cube *1*
FtpContainer ftpContainer;
@Rule
public TemporaryFolder folder = new TemporaryFolder();
@Test
public void should_upload_file_to_ftp_server() throws Exception {
// Given
final File file = folder.newFile(REMOTE_FILENAME);
Files.write(file.toPath(), "Hello World".getBytes());
// When
FtpClient ftpClient = new FtpClient(ftpContainer.getIp(), *2*
ftpContainer.getBindPort(),
ftpContainer.getUsername(),
ftpContainer.getPassword());
try {
ftpClient.uploadFile(file, REMOTE_FILENAME, ".");
} finally {
ftpClient.disconnect();
}
// Then
final boolean filePresentInContainer = ftpContainer
.isFilePresentInContainer(REMOTE_FILENAME); *3*
assertThat(filePresentInContainer, is(true));
}
}
-
1 容器对象使用@Cube 注解。
-
2 从注入的对象检索 FTP 属性
-
3 封装与容器相关的操作
在 Arquillian Cube 测试中使用容器对象就像声明它并使用@Cube注解它一样简单。在执行时间,Arquillian Cube 检查所有带有Cube注解的字段,读取所有元信息,并启动定义的容器。测试执行后,容器将被停止。
如您所见,容器对象的生存周期与在 Docker Compose 文件中定义它并没有太大的不同。请注意,在这种情况下,不需要 Docker Compose 文件,尽管如果您愿意,您可以使用这两种方法一起使用。
更新默认值
当您与 POJO 一起工作时,您可以使用常规 Java 语言约定覆盖它的任何部分。在以下示例中,测试覆盖了容器的名称,以及端口绑定配置:
@Cube(value = "myftp",
portBinding = "21->;21/tcp") *1*
FtpContainer ftpContainer;
- 1 更新容器对象提供的默认值
Arquillian Cube 中容器对象模式提供的另一个功能是无需依赖于特定镜像。您可以使用CubeDockerFile注解从 Dockerfile 构建自己的镜像:
@Cube(value = "ftp",
portBinding = FtpContainer.BIND_PORT + "->;21/tcp")
@CubeDockerFile("/docker") *1*
@Environment(key = "USERNAME", value = FtpContainer.USERNAME)
@Environment(key = "PASSWORD", value = FtpContainer.PASSWORD)
public class FtpContainer {
}
- 1 从配置的 Dockerfile 构建镜像
CubeDockerFile注解设置了 Dockerfile 可以找到的位置,但它并不限制 Dockerfile 的内容。此位置必须可通过运行时ClassLoader访问,因此它必须存在于类路径上。
您还可以使用 ShrinkWrap Descriptors 领域特定语言(DSL)编程方式创建 Dockerfile。以下示例展示了如何使用容器对象中的 DSL 定义 Dockerfile:
@Cube(value = "ftp", portBinding = "2121->;21/tcp")
public class FtpContainer {
@CubeDockerFile
public static Archive<?>; createContainer() { *1*
String dockerDescriptor = Descriptors.create(DockerDescriptor.class)
.from("andrewvos/docker-proftpd")
.expose(21)
.exportAsString(); *2*
return ShrinkWrap.create(GenericArchive.class)
.add(new StringAsset(dockerDescriptor), "Dockerfile"); *3*
}
}
-
1 定义 Dockerfile 的静态方法
-
2 使用 ShrinkWrap Descriptors DSL 创建 Dockerfile 内容
-
3 使用所有必需内容构建存档
构建 Dockerfile 的方法必须使用CubeDockerFile注解,并且它必须是公共的、静态的,且没有参数。此外,该方法需要返回一个 ShrinkWrap Archive实例。Dockerfile 不是直接返回的,因为在某些情况下,你可能需要添加构建 Docker 容器所需的额外文件。这在你需要在容器创建期间添加文件时尤其如此。
容器对象模式提供的最后一个功能是容器的聚合。聚合允许你在其他容器对象中定义容器对象。每个聚合对象都包含对其父对象的链接,因此所有参与方都可以相互通信。
这里是如何定义内部容器对象的方法:
@Cube
public class FirstContainerObject {
@Cube("inner")
LinkContainerObject linkContainerObject;
}
除了启动两个容器之外,Arquillian Cube 通过将LinkContainerObject的主机名设置为inner来在它们之间创建一个链接。可以通过使用@Link注解进一步配置链接:
@Cube("inner")
@Link("db:db")
TestLinkContainerObject linkContainerObject;
8.7.1. 使用灵活的容器对象 DSL
Arquillian Cube 还提供了一个通用的Container对象来生成 Cube 实例。使用这种方法编写定义更高效,但使用自定义容器对象方法重用代码或提供自定义操作会更困难一些。
让我们看看如何使用Container DSL 声明和启动 Docker 容器的一个简单示例:
@DockerContainer *1*
Container pingpong = Container.withContainerName("pingpong") *2*
.fromImage("jonmorehouse/ping-pong")
.withPortBinding(8080)
.build();
@Test
public void should_return_ok_as_pong() throws IOException {
String response = ping(pingpong.getIpAddress(),
pingpong.getBindPort(8080)); *3*
assertThat(response).containsSequence("OK");
}
-
1 字段被注解为@DockerContainer。
-
2 DSL 从
withContainerName方法开始。 -
3 获取容器信息以连接
要创建一个通用的容器对象,你只需要创建一个类型为org.arquillian.cube.docker.impl.client.containerobject.dsl.Container的字段,并使用@DockerContainer注解它。
你也可以使用 DSL 方法创建 Docker 网络:
@DockerNetwork *1*
Network network = Network.withDefaultDriver("mynetwork").build(); *2*
-
1 字段被注解为@DockerNetwork。
-
2 DSL 从
withDefaultDriver方法开始。
要使用 DSL 方法创建网络,你需要创建一个类型为org.arquillian.cube.docker.impl.client.containerobject.dsl.Network的字段,并使用@DockerNetwork注解它。
容器对象和 DSL JUnit 规则
你可以使用JUnit 规则定义通用容器。这样,你可以使用任何 JUnit 运行器,如SpringJUnit4ClassRunner,与容器对象 DSL 并排使用。以下是如何定义 Redis 容器的方法:
@ClassRule
public static ContainerDslRule redis = new ContainerDslRule("redis:3.2.6")
.withPortBinding(6379);
Spring Data 和 Spring Boot
使用 Spring Data,你通过环境变量配置数据库位置。要在测试中设置它们,你需要使用自定义的ApplicationContextInitializer。以下是一个示例:
@RunWith(SpringJUnit4ClassRunner.class) *1*
@SpringBootTest(classes = Application.class,
webEnvironment = WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers =
SpringDataTest.Initializer.class) *2*
public class SpringDataTest {
@ClassRule
public static ContainerDslRule redis =
new ContainerDslRule("redis:3.2.6")
.withPortBinding(6379); *3*
public static class Initializer implements
ApplicationContextInitializer
<ConfigurableApplicationContext>; {
@Override
public void initialize(
ConfigurableApplicationContext
configurableApplicationContext) { *4*
EnvironmentTestUtils.addEnvironment("testcontainers",
configurableApplicationContext.getEnvironment(),
"spring.redis.host=" + redis.getIpAddress(),
"spring.redis.port=" + redis.getBindPort(6379)
);
}
}
}
-
1 带有 Boot 配置的 Spring JUnit 测试运行器
-
2 设置初始化器以配置环境变量
-
3 定义 Redis 容器
-
4 带有容器配置的初始化器实现
注意,你必须添加org.arquillian.cube:arquillian-cube-docker-junit-rule依赖。你不需要添加任何其他的 Arquillian 依赖。
到目前为止,你已经学习了如何使用 Docker 和 Arquillian Cube 来设置复杂的测试环境。在下一节中,我们将探讨如何在 Kubernetes 中使用和部署 Docker 镜像。
8.8. 部署测试和 Kubernetes
在本章中,你看到了如何使用 Docker 进行测试,但也许你也在生产级别使用 Docker。Docker 容器本身可能难以管理和维护。复杂的应用程序通常需要在多台机器上启动多个容器(注意 Docker 主机运行在单个主机上)。你还需要一种方法来编排所有这些容器,并提供其他功能,如容错性、水平自动扩展、秘密的分布、跨所有机器的服务命名和发现、滚动更新和负载均衡。提供这些功能的一个突出工具是 Kubernetes。
Kubernetes 是一个用于管理 Docker 容器集群的开源系统。它由 Google 创建;包括 Red Hat 和 Microsoft 在内的其他公司对其做出了贡献。Kubernetes 提供了部署和扩展应用程序的工具,以及管理现有应用程序更改的工具,例如更新到新版本或在失败或健康检查的情况下回滚。此外,Kubernetes 的创建考虑了两个重要特性:可扩展性和容错性。
以下是你需要理解的主要 Kubernetes 概念(图 8.7 总结了这些概念):
-
Pod—Kubernetes 中的最小组织单元。Pod 由一个或多个在同一主机机器上运行的容器组成,并且可以共享资源。
-
Service—一组 Pod 及其访问策略。Kubernetes 为服务提供稳定的 IP 地址和 DNS 名称,从而抽象出 Pod 的位置。因为 Pod 是短暂的,它们的 IP 地址可能会改变。服务通过始终转发到所需 Pod 的位置来响应这种变化。服务在所有 Pod 实例之间充当负载均衡器。
-
Replication controller (RC)—维护集群的期望状态。例如,如果你需要一个 Pod 的三个实例,RC 将管理给定的 Pod,并始终在集群上运行三个实例。
-
Namespace—一种创建由物理集群支持虚拟集群的方法。
图 8.7. Kubernetes 部署示例

通常,在 Kubernetes 中,你会在 JSON 或 YAML 文件中定义元素。例如,为了定义一个 Pod,你可以创建以下 pod-redis.json 文件:
{
"kind": "Pod",
"apiVersion": "v1",
"metadata": {
"name": "redis",
"labels": {
"app": "myredis"
}
},
"spec": {
"containers": [
{
"name": "key-value-store",
"image": "redis",
"ports": [
{
"containerPort": 6379
}
]
}
]
}
}
此代码片段定义了一个简单的 Pod,使用名为key-value-store的redis容器,在 Pod 的 IP 地址上暴露端口号 6379。
在 Kubernetes 中,部署应用程序/服务不是通过手动完成,而是通过可编程和自动完成。这意味着你需要测试配置的是你期望部署的内容。
Arquillian Cube 为 Kubernetes 测试提供支持。Arquillian Cube 和 Kubernetes 集成的理念是消费和测试提供的服务,以及验证环境是否处于预期的状态。Arquillian Cube 和 Kubernetes 的集成生命周期总结在图 8.8 中。
警告
只有 Arquillian 独立模式在 Arquillian Cube 和 Kubernetes 集成中受支持。
图 8.8. Arquillian Cube 和 Kubernetes 集成的生命周期

Arquillian Cube Kubernetes 创建一个临时命名空间,在隔离环境中部署所有 Kubernetes 资源。然后它在类路径中搜索名为 kubernetes.json 或 kubernetes.yaml 的文件,并在该临时命名空间上应用所需的全部 Kubernetes 资源。一切准备就绪后,它运行您的测试(使用黑盒方法)。测试完成后,它进行清理。
注意
Arquillian Cube Kubernetes 需要认证到 Kubernetes。为此,Arquillian Cube 从 ~/.kube/config 中读取用户信息(令牌和密码)。
您可以在 arquillian.xml 中配置 Arquillian Cube Kubernetes 参数。表 8.3 列出了一些最有用的参数。
注意
Arquillian Cube Kubernetes 可以从环境变量中读取属性。等效的环境属性是全部大写的属性名,点(.)符号转换为下划线(_):例如,KUBERNETES_MASTER。
表 8.3. Arquillian Cube Kubernetes 参数
| 属性 | 描述 | 默认行为 |
|---|---|---|
| kubernetes.master | Kubernetes 主机的 URL | |
| env.config.url | Kubernetes JSON/YAML 文件的 URL | 默认为类路径资源 kubernates.json |
| env.dependencies | 以空格分隔的 URL 列表,指向多个 Kubernetes 定义文件 | |
| env.config.resource.name | 选项用于选择不同的类路径资源 | |
| namespace.use.existing | 标志指定不生成新的临时命名空间,而是重用已设置的命名空间 | |
| env.init.enabled | 标志用于使用定义的 Kubernetes 资源初始化环境(与 namespace.use.existing 一起使用) | 默认情况下,创建 Kubernetes 资源 |
| namespace.cleanup.enabled | 指示扩展在测试套件结束后销毁命名空间 | 默认情况下,销毁命名空间以保持集群清洁 |
您可以通过在 arquillian.xml 中设置它来配置 kubernetes.master(如果未设置 KUBERNETES_MASTER 环境变量):
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd">;
<extension qualifier="kubernetes">;
<property name="kubernetes.master">;http://localhost:8443</property>;
</extension>;
</arquillian>;
任何 Arquillian Cube Kubernetes 测试都可以通过以下元素进行丰富:
-
一个 Kubernetes 客户端
-
一个包含测试会话信息(如临时创建的命名空间名称)的会话对象
-
一个 pod(通过其 ID)或测试启动的所有 pod 的列表
-
一个 RC(通过其 ID)或由测试启动的所有 RC 的列表
-
一个服务(通过其 ID)或由测试启动的所有服务的列表
-
服务的 URL
以下测试通过以下元素进行了增强:
@RunWith(Arquillian.class)
public class ResourcesTest {
@ArquillianResouce *1*
@Named("my-serivce")
Service service;
@ArquillianResouce *2*
PodList pods;
@Test
public void testStuff() throws Exception {
}
}
-
1 在测试中增强名为 my-service 的服务
-
2 在测试中增强所有定义的 Pod
在这个测试中,可以查询名为my-service的 Kubernetes 服务的所有信息。您还可以访问当前测试中定义的所有 Pod。以类似的方式,您可以使用ServiceList获取服务列表,或使用@Named和Pod对象获取具体的 Pod。对于 RC 对象也是如此。
要注入服务 URL,您这样做:
@Named("hello-world-service") *1*
@PortForward
@ArquillianResource
URL url;
- 1 服务名称
此外,正如 Arquillian Cube Docker 所做的那样,Kubernetes 集成提供了与 AssertJ 的紧密集成,以提供一种可读的方式来编写关于环境的断言。以下是一个如何使用此集成的一个简单示例:
@RunWith(Arquillian.class)
public class RunningPodTest {
@ArquillianResource *1*
KubernetesClient client;
@Test
public void should_deploy_all_pods() {
assertThat(client).deployments().pods().isPodReadyForPeriod(); *2*
}
}
-
1 Kubernetes 客户端增强
-
2 AssertJ Kubernetes 集成的 assertThat 方法
此测试断言当前Deployment至少创建一个 Pod,该 Pod 在一段时间内(默认为 30 秒)变为可用,并且在该时间段内(默认为 1 秒)保持Ready状态。这个测试很简单,但根据我们的经验,它捕获了在 Kubernetes 部署期间可能发生的多数错误。当然,这只是开始;您可以根据需要添加尽可能多的断言来验证应用程序是否按要求部署。此外,AssertJ Kubernetes 不仅为KubernetesClient提供自定义断言,还为 Pod、服务和 RC 提供。
Arquillian Cube Kubernetes 和 OpenShift
Arquillian Cube Kubernetes 实现了一些额外的功能,以帮助通过 OpenShift 进行测试:
-
自动设置连接到非导出路由。
-
直接从测试触发构建作业/管道。这会将
@Deployment工件推送到本地存储库并触发一个 OS 构建以进行部署。
因为 OpenShift 3 是一个 Kubernetes 系统,所以 Arquillian Cube Kubernetes 中所有有效的内容在 OpenShift 中也有效。
在介绍如何使用 Docker 进行测试目的以及您可以使用哪些工具来自动化使用 Docker 的测试之后,让我们看看您需要做什么才能开始使用它们。
8.9. 构建脚本修改
您已经看到 Arquillian Cube 与不同的技术(如 Docker、Kubernetes 和 OpenShift)有集成。每个都有自己的依赖项,以下部分将介绍如何将这些依赖项添加到您的测试中。
8.9.1. Arquillian Cube Docker
要使用 Cube Docker 集成,您需要添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.cube',
name: 'arquillian-cube-docker',
version: '1.2.0'
}
要使用 Drone/Graphene 集成,您还需要添加以下内容:
dependencies {
testCompile group: 'org.arquillian.cube',
name: 'arquillian-cube-docker-drone',
version: '1.2.0'
}
注意,在这种情况下,您需要添加 Selenium、Arquillian Drone 或 Arquillian Graphene 依赖项,就像在第七章中所做的那样。
要使用 REST Assured 集成,您还需要添加 REST Assured 依赖项:
dependencies {
testCompile group: 'org.arquillian.cube',
name: 'arquillian-cube-docker-restassured',
version: '1.2.0'
}
最后,为了使用 AssertJ 集成,添加 AssertJ 依赖项:
dependencies {
testCompile group: 'org.arquillian.cube',
name: 'assertj-docker-java',
version: '1.2.0'
}
8.9.2. Arquillian Cube Docker JUnit 规则
要使用具有 JUnit 规则支持的容器 DSL,添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.cube',
name: 'arquillian-cube-docker-junit-rule,
version: '1.2.0'
}
8.9.3. Arquillian Cube Kubernetes
要使用具有 Kubernetes 支持的 Arquillian Cube,添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.cube',
name: 'arquillian-cube-kubernetes',
version: '1.2.0'
}
要使用 AssertJ 集成,你还需要添加以下内容:
dependencies {
testCompile group: 'io.fabric8',
name: 'kubernetes-assertions',
version: '2.2.101'
}
8.9.4. Arquillian Cube OpenShift
要使用 Arquillian Cube 与 OpenShift 的特定功能(不是 Kubernetes 部分),添加以下依赖项:
dependencies {
testCompile group: 'org.arquillian.cube',
name: 'arquillian-cube-openshift',
version: '1.2.0'
}
8.10. 测试视频服务的 Dockerfile
视频服务被打包成一个 Docker 镜像。要创建此镜像,请使用以下 Dockerfile(code/video/Dockerfile)。
列表 8.14. 视频服务 Dockerfile
FROM java:8-jdk
ADD build/libs/video-service-*.jar /video-service.jar
EXPOSE 8080
RUN bash -c 'touch /video-service.jar'
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom",
"-jar","/video-service.jar"]
如你所见,这里没有发生任何特别的事情。Spring Boot 项目的输出被复制到镜像内部,当镜像被实例化时,服务也被启动。
接下来,你创建一个 Docker Compose 文件来自动化镜像的构建(code/video/c-tests/src/test/resources/arquillian.xml)。
列表 8.15. Docker Compose 文件
<?xml version="1.0"?>;
<arquillian
xsi:schemaLocation="http://jboss.org/schema/arquillian
http://jboss.org/schema/arquillian/arquillian_1_0.xsd">;
<extension qualifier="docker">;
<property name="machineName">;dev</property>;
<property name="dockerContainers">; *1*
videoservice:
build: ../.
environment:
- SPRING_REDIS_HOST=redis
- SPRING_REDIS_PORT=6379
- YOUTUBE_API_KEY=${YOUTUBE_API_KEY}
ports:
- "8080:8080"
links:
- redis:redis
redis:
image: redis:3.2.6
</property>;
</extension>;
</arquillian>;
- 1 构建并定义服务的依赖项
最后,你可以编写测试来验证镜像可以构建并且容器可以实例化(code/video/c-tests/src/test/java/book/video/VideoServiceContainerTest.java)。
列表 8.16. 验证图像和容器
@RunWith(Arquillian.class)
public class VideoServiceContainerTest {
@ArquillianResource
DockerClient docker;
@Test
public void should_create_valid_dockerfile() {
DockerJavaAssertions.assertThat(docker).container
("videoservice").hasExposedPorts("8080/tcp") *1*
.isRunning();
}
}
- 1 验证 Docker 容器属性
此测试使用 Docker AssertJ 集成。尽管这不是强制性的,但我们建议使用它以保持测试的可读性。
当执行此测试时,Arquillian Cube 指示 Docker 主机构建和运行给定的镜像。如果镜像可以构建,测试将验证应该公开的端口仍然公开(没有人更改 Dockerfile 中的它们),并最终验证容器正在运行。
小贴士
你不需要每次都构建镜像。相反,你可以在 CD 构建中创建一次镜像,然后为每种测试重用它。
练习
现在你应该能够使用 Docker 设置测试环境来运行你的测试。使用视频服务示例,编写一个简单的端到端测试,使用 REST Assured。
摘要
-
你可以使用 Docker 来设置测试环境,用于测试目的。你还可以用它来测试在生产中使用 Docker 的应用程序。
-
你可以使用 Docker 和 Selenium/Arquillian Graphene 编写 UI 测试,以便将包括浏览器在内的所有内容容器化。
-
容器对象模式允许你以编程方式创建容器。
-
Docker 不强制你使用任何特定的语言或框架。这意味着你可以使用 Arquillian Cube 来测试任何用任何语言编写的应用程序,只要它是 Docker 化的。这对于微服务架构来说是一个完美的匹配,因为每个微服务可能用不同的语言编写。
-
如果一个微服务依赖于外部服务,你不应该使用真实的外部服务来设置测试环境(这样做可能会使你的测试变得不可靠);你可以使用服务虚拟化来模拟外部服务。因为 WireMock 是一个 HTTP 服务器,你可以将其容器化并在 Docker 中使用。这样,你可以使用 Docker 来测试微服务(们),并在所需的级别上减少依赖,并通过 WireMock/Hoverfly 容器模拟响应。
第九章. 服务虚拟化
本章涵盖
-
欣赏服务虚拟化
-
模拟内部和外部服务
-
理解服务虚拟化和 Java
在微服务架构中,整个应用程序可以由许多相互连接的服务组成。这些服务可以是内部服务,例如同一应用域的成员,也可以是完全不受你控制的外部、第三方服务。
正如你在整本书中看到的,这种方法意味着当持续应用程序测试成为你的交付管道的一部分时,需要做出一些改变。在第七章中,我们观察到在测试微服务架构时面临的最大挑战之一是拥有一个干净且随时可用的测试环境。启动、运行并准备多个服务不是一件简单的事情。准备和执行测试需要时间,而且你很可能会有几个不可靠的测试——这些测试失败不是因为代码问题,而是因为测试环境中的失败。你可以采用的一种技术是服务虚拟化。
9.1. 什么是服务虚拟化?
服务虚拟化是一种用于模拟基于组件的应用程序依赖项行为的技巧。一般来说,在微服务架构中,这些依赖项通常是基于 REST API 的服务,但这个概念也可以应用于其他类型的依赖项,如数据库、企业服务总线(ESBs)、Web 服务、Java 消息服务(JMS)或任何使用消息协议进行通信的系统。
9.1.1. 为什么使用服务虚拟化?
以下是一些你可能想要使用服务虚拟化的场景:
-
当当前服务(消费者)依赖于另一个尚未开发或仍在开发中的服务(提供者)时。
-
当为新实例配置所需的服务(提供者)困难或太慢,不适合测试目的时。
-
当配置服务(提供者)不是一个简单任务时。例如,你可能需要准备大量的数据库脚本以运行测试。
-
当不同团队需要并行访问服务,而这些团队有完全不同的设置时。
-
当提供者服务由第三方或合作伙伴控制,并且你每天有请求配额限制时。你不想用测试消耗配额!
-
当提供者服务仅在特定时间或夜间某些时间可用时。
服务虚拟化可以通过模拟所需服务的行怍来解决所有这些挑战。通过服务虚拟化,你可以建模和部署一个代表提供者服务的虚拟资产,模拟测试所需的各个部分。
图 9.1 展示了为运行测试而配置真实环境与虚拟化环境之间的差异。在左侧,你可以看到编写服务 A 的测试需要启动服务 B,包括其数据库。同时,你可能还需要启动传递服务,如服务 C 和 D。在右侧,你可以看到服务 B 及其所有依赖项都被一个模拟版本所取代,该版本模拟了服务 B 的行为。
图 9.1. 真实服务与虚拟化服务

重要的是要注意,这个图表与你在第三章学习模拟和存根时看到的图表并没有太大的不同;但与模拟类不同,你是在模拟服务调用。简化这个想法,你可以将服务虚拟化想象为在企业层面的模拟。
服务虚拟化不仅应该用于测试最佳路径情况,还应该用于测试边缘情况,以便测试整个应用程序(负面测试)。有时很难对真实服务进行边缘测试。例如,你可能想测试客户端在从提供者那里获得低延迟响应时的行为,或者当发送与预期不同的字符编码时它的行为。
想想 Gamer 应用程序——你不能要求 igdb.com 和 youtube.com 在下午关闭他们的 API,以便你进行负面测试。(好吧,你可以,但别抱太大希望得到答复!)在这种情况下,服务虚拟化的有用性应该很明显。
9.1.2. 何时使用服务虚拟化
本书介绍了许多不同类型的测试,从单元测试到端到端测试。何时使用服务虚拟化是有用的?
-
单元测试—对于单元测试,你不太可能需要服务虚拟化。在 99%的情况下,使用传统的模拟、虚拟和存根技术就足够了。
-
组件测试—这是服务虚拟化大放异彩的地方:你可以测试组件之间如何相互交互,而不依赖于外部服务。
-
集成测试—由于集成测试的本质是针对真实服务运行的,在测试用例中可能会遇到问题(例如边缘情况、第三方服务等),因此你可能选择使用服务虚拟化。
-
契约测试—当测试提供者的契约时,你可能需要服务虚拟化来模拟提供者服务的依赖项。
-
端到端测试—根据定义,端到端测试不应该依赖于服务虚拟化,因为你是针对真实系统进行测试的。在某些罕见的情况下,如果你依赖于不可靠的第三方服务,服务虚拟化可能仍然是一个可行的解决方案。
正如你所见,随着你转向更功能性的测试,虚拟资产逐渐被更多真实的服务所取代。
在第四章中,我们讨论了使用 WireMock 模拟外部服务的概念。在本章中,我们将介绍一个名为Hoverfly的新工具,它专门用于服务虚拟化。
9.2. 使用 Hoverfly 模拟服务响应
悬停蝇 (hoverfly.readthedocs.io) 是一个用 Go 编程语言编写的开源、轻量级的服务虚拟化代理。它允许您模拟 HTTP 和 HTTPS 服务。如图 9.2 所示,悬停蝇启动了一个代理,该代理对请求返回存储(罐头)响应。这些响应应与真实服务为提供的请求生成的响应完全相同。如果这个过程执行正确,并且存储的响应对真实服务准确无误,悬停蝇将完美地模仿真实服务的响应,并且您的测试将准确无误。
图 9.2. Hoverfly 代理

注意
Hoverfly Java (hoverfly-java.readthedocs.io) 是 Hoverfly 的 Java 包装器,它抽象了您对实际二进制和 API 调用的操作,并提供了与 JUnit 的紧密集成。从现在起,当我们谈论 Hoverfly 时,我们指的是 Java 包装器。
9.2.1. Hoverfly 模式
Hoverfly 有三种工作模式:
-
捕获—以正常方式对真实服务进行请求。请求和响应被 Hoverfly 代理拦截并记录,以便以后使用。
-
模拟—为提供的请求返回模拟响应。模拟可以从不同的来源加载,例如文件、类路径资源或 URL,或者使用 Hoverfly 领域特定语言(DSL)编程定义。这是开发中的服务的首选模式。
-
捕获或模拟—其他两种模式的组合。如果模拟文件不存在,代理以捕获模式启动,否则以模拟模式启动。当已经开发的服务或第三方服务可用时,此模式更受欢迎。
图 9.3 展示了捕获模式的方案:
1. 使用真实服务执行请求,该服务可能部署在运行测试的机器之外。
2. Hoverfly 代理将流量重定向到真实主机,并返回响应。
3. Hoverfly 代理存储由真实服务交互生成的匹配请求和响应的脚本文件。
4. 将真实响应返回给调用者。
图 9.3. 悬停蝇捕获模式

图 9.4 阐述了模拟模式:
1. 执行了一个请求,但调用不是路由到真实服务,而是路由到 Hoverfly 代理。
2. Hoverfly 代理检查提供的请求对应的响应脚本。
3. 将预定义的响应回放给调用者。
图 9.4. 悬停飞虫模拟模式

Hoverfly 和 JVM 代理设置
Hoverfly Java 设置网络 Java 系统属性以使用 Hoverfly 代理。这意味着如果您使用的客户端 API 识别这些属性,则无需更改任何内容即可与 Hoverfly 一起工作。如果不是这种情况,您需要将 http.proxyHost、http.proxyPort、https.proxyHost、https.proxyPort 以及可选的 http.nonProxyHosts 设置到您的客户端代理配置中。
当此覆盖生效时,Java 运行时与物理网络之间的所有通信(默认情况下除 localhost 外)将通过 Hoverfly 代理进行。例如,当使用尊重网络系统属性的 okhttp 客户端时,您可能这样做:
URL url = new URL("http", "www.myexample.com", 8080, "/" + name);
Request request = new Request.Builder().url(url).get().build();
final Response response = client.newCall(request).execute();
由于代理设置已被覆盖,请求是通过 Hoverfly 代理执行的。根据所选的配置模式,请求将发送到 www.myexample.com 或进行模拟。
9.2.2. JUnit Hoverfly
让我们看看如何使用 Hoverfly 与 JUnit 的一些示例。
JUnit Hoverfly 模拟模式
Hoverfly 以 JUnit 规则的形式提供。您可以使用 @ClassRule 进行静态初始化,或 @Rule 对每个测试进行初始化。我们建议使用 @ClassRule,以避免每次测试方法执行时启动 Hoverfly 代理的开销。以下是一个示例:
import static io.specto.hoverfly.junit.core.SimulationSource.defaultPath;
import io.specto.hoverfly.junit.rule.HoverflyRule;
@ClassRule
public static HoverflyRule hoverflyRule = HoverflyRule
.inSimulationMode(defaultPath("simulation.json")); *1*
- 1 从默认的 Hoverfly 资源路径读取 simulation.json*
在这里,Hoverfly 代理启动,然后从默认的 Hoverfly 资源路径,src/test/resources/hoverfly,加载 simulation.json 模拟文件。之后,所有测试都执行完毕,Hoverfly 代理停止。
除了从文件加载模拟之外,您还可以使用 DSL 指定请求匹配器和响应,如下所示:
import static io.specto.hoverfly.junit.core.SimulationSource.dsl;
import static io.specto.hoverfly.junit.dsl.HoverflyDsl.service;
import static io.specto.hoverfly.junit.dsl.ResponseCreators.success;
import static io.specto.hoverfly.junit.dsl.ResponseCreators.created;
import io.specto.hoverfly.junit.rule.HoverflyRule;
@ClassRule
public static HoverflyRule hoverflyRule =
HoverflyRule.inSimulationMode(dsl( *1*
service("www.myexample.com") *2*
.post("/api/games").body("{\"gameId\": \"1\"}") *3*
.willReturn(created("http://localhost/api/game/1"))
.get("/api/games/1") *4*
.willReturn(success("{\"gameId\":\"1\"\}", "application/json"))
));
-
1 使用 DSL 方法启动 Hoverfly*
-
2 设置连接要建立的宿主*
-
3 为 POST 方法创建请求和响应*
-
4 为 GET 方法创建请求和响应*
请求字段匹配器
Hoverfly 有 请求字段匹配器 的概念,它允许您在 DSL 元素中使用不同类型的匹配器。以下是一个示例:
service(matches("www.*-test.com")) *1*
.get(startsWith("/api/games/")) *2*
.queryParam("page", any()) *3*
-
1 使用通配符匹配 URL*
-
2 匹配以 /api/games/ 开头的请求路径***
-
3 匹配页面查询参数的任何值*
JUnit Hoverfly 捕获模式
在捕获模式下启动 Hoverfly 与在模拟模式下相同,但您使用 inCaptureMode 来指示您想要存储交互:
@ClassRule
public static HoverflyRule hoverflyRule
= HoverflyRule.inCaptureMode("simulation.json"); *1*
- 1 在捕获模式下启动 Hoverfly 并记录结果*
在此示例中,Hoverfly 以捕获模式启动。这意味着流量被重定向/路由到真实服务,但现在这些交互被记录在默认位于 src/test/resources/hoverfly/simulation.json 的文件中。
JUnit Hoverfly 捕获或模拟模式
此模式是前两种模式的组合,如果没有先前记录的文件,则使用捕获模式。生成的文件可以添加到您的版本控制中,以完成测试用例,供其他人使用而无需真实服务。以下是一个示例:
@ClassRule
public static HoverflyRule hoverflyRule
= HoverflyRule.inCaptureOrSimulationMode("simulation.json");
9.2.3. 配置 Hoverfly
Hoverfly 默认提供了一些可能适用于所有情况的配置,但您可以通过向先前方法提供一个 io.specto.hoverfly.junit.core.HoverflyConfig 实例来覆盖它们。例如,您可以通过设置 inCaptureMode("simulation.json", HoverflyConfig.configs().proxyPort(8080)) 来更改 Hoverfly 代理启动的代理端口。
默认情况下,所有主机名都会被代理,但您也可以将此行为限制为特定的主机名。例如,configs().destination("www.myexample.com") 配置 Hoverfly 代理仅处理对 www.myexample.com 的请求。
默认情况下,本地主机调用不会被代理。但如果您的提供者服务运行在本地主机上,您可以通过使用 configs().proxyLocalHost() 配置 Hoverfly 来代理本地主机调用。
配置 SSL
如果您的服务使用安全套接字层 (SSL),Hoverfly 需要解密消息以便在捕获模式下将它们持久化到文件,或在模拟模式下执行匹配。实际上,您在客户端和 Hoverfly 代理之间有一个 SSL 连接,在 Hoverfly 代理和真实服务之间还有一个 SSL 连接。
为了简化,Hoverfly 附带了自己的自签名证书,客户端必须信任它。好消息是,当您实例化 Hoverfly 时,其证书会自动信任。
您可以使用 HoverflyConfig 类覆盖此行为并提供自己的证书和密钥:例如,configs().sslCertificatePath("ssl/ca.crt").sslKeyPath("ssl/ca.key")。请注意,这些文件相对于类路径。
配置外部实例
您可以配置 Hoverfly 使用现有的 Hoverfly 代理实例。这种情况可能发生在您使用托管 Hoverfly 代理的 Docker 镜像时。同样,您可以通过使用 HoverflyConfig 类轻松配置这些参数:例如,configs().remote().host("192.168.99.100").proxyPort(8081)。
9.3. 构建脚本修改
现在,您已经了解了服务虚拟化和 Hoverfly,让我们看看涉及的依赖项。Hoverfly 只需要一个组、工件、版本(GAV)依赖项定义:
dependencies {
testCompile "io.specto:hoverfly-java:0.6.2"
}
这会将所有必需的临时依赖项拉入测试范围。
9.4. 使用服务虚拟化处理 Gamer 应用程序
正如你在整本书中看到的,在 Gamer 应用程序中,聚合器服务与三个服务进行通信,以向最终用户组成包含所有游戏信息的最终请求,如图 9.5 所示。figure 9.5。让我们为连接到评论服务的代码编写一个组件测试。
图 9.5. 聚合器服务

在以下列表(code/aggregator/cp-tests/src/test/java/book/aggr/CommentsGatewayTest.java)中,评论服务在(预)生产环境中部署在 comments.gamers.com,你将使用捕获或模拟模式,以便初始请求发送到真实服务。所有后续调用都将进行模拟。
列表 9.1. 测试CommentsGateway类
public class CommentsGatewayTest {
@ClassRule
public static HoverflyRule hoverfly = HoverflyRule
.inCaptureOrSimulationMode("simulation.json"); / *1*
@Test
public void shouldInsertComments()
throws ExecutionException, InterruptedException {
final JsonObject commentObject = Json.createObjectBuilder()
.add("comment", "This Game is Awesome").add("rate",
5).add("gameId", 1234).build();
final CommentsGateway commentsGateway = new CommentsGateway
();
commentsGateway.initRestClient("http://comments.gamers.com")
; *2*
final Future<Response>; comment = commentsGateway
.createComment(commentObject);
final Response response = comment.get();
final URI location = response.getLocation();
assertThat(location).isNotNull();
final String id = extractId(location);
assertThat(id).matches("[0-9a-f]+"); *3*
}
-
1 实例化 Hoverfly 规则
-
2 调用真实主机
-
3 断言位置有效
与其他测试用例相比,最大的不同之处在于,第一次运行此测试时,请求通过 Hoverfly 代理发送到部署在 comments.gamers.com 的评论服务,并记录请求和响应。由于该文件尚不存在,因此会创建 src/test/resources/hoverfly/simulation.json 文件。下次运行测试时,通信仍然通过 Hoverfly 代理进行代理,但由于该文件现在存在,将返回预定义的响应。
如果你好奇(我们知道你是),记录的文件看起来像下面的列表(src/test/resources/hoverfly/simulation.json)。
列表 9.2. 带有预定义响应的模拟文件
{
"data" : {
"pairs" : [ {
"request" : {
"path" : {
"exactMatch" : "/comments"
},
"method" : {
"exactMatch" : "POST"
},
"destination" : {"exactMatch" : "comments.gamers.com"},
"scheme" : {
"exactMatch" : "http"
},
"query" : {"exactMatch" : ""},
"body" : {
"jsonMatch" : "{\"comment\":\"This Game is Awesome\",
\"rate\":5,\"gameId\":1234}"
}
},
"response" : {
"status" : 201,
"encodedBody" : false,
"headers" : {
"Content-Length" : [ "0" ],
"Date" : [ "Thu, 15 Jun 2017 17:51:17 GMT" ],
"Hoverfly" : [ "Was-Here" ],
"Location" : [ "comments.gamers.com/5942c915c9e77c0001454df1" ],
"Server" : [ "Apache TomEE" ]
}
}
} ],
"globalActions" : {
"delays" : [ ]
}
},
"meta" : {
"schemaVersion" : "v2"
}
}
摘要
-
服务虚拟化不是合同测试的替代品,而是一种与它们一起使用的东西,主要在提供者验证场景中使用。
-
服务虚拟化用于消除依赖于外部和可能不可靠服务的测试的不可靠性。
-
虚拟资产是你所模拟的服务。
-
你可以使用服务虚拟化来模拟现有服务之外的未完成服务,从而允许并行团队快速开发。
-
Hoverfly Java 负责所有网络重定向,让你继续编写测试。
第十章. 微服务中的持续交付
本章涵盖
-
在持续交付管道中使用微服务
-
在管道上执行测试
-
理解编码的管道
-
构建 Jenkins 管道
-
确定性地部署服务
我们希望这本书已经拓宽了您的视野,并扩展了您为开发微服务架构测试的技能集。这些测试的目的是确保在重构、修复错误或添加新功能时,不会引入任何回归。
现在的问题是何时执行测试,在哪里执行它们,以及测试如何与生产服务的部署相关。在本章中,您将了解传统的持续交付(CD)管道是如何演变为服务于微服务架构的,以及如何构建一个专注于测试执行的管道。
我们假设您在应用程序的 CD 方面有一些经验,并且可能对 Jenkins (jenkins.io) 作为 CD 服务器有一些基本的使用。还有其他几个很好的构建自动化服务器,例如 Travis CI (travis-ci.org) 和 Bamboo (www.atlassian.com/software/bamboo),但我们不得不选择一个来集中关注,我们选择了 Jenkins。本章中描述的原则大致适用于您最终可能选择的任何 CD 服务器。
我们还假设您正在使用某种类型的源代码控制或源代码管理(SCM)服务器,例如 Git (git-scm.com) 或 SVN (subversion.apache.org),来管理您的源代码。如果不是这样,那么您真的很糟糕,您的源代码将与您的硬盘一起死亡!说真的,我们不会希望这种情况发生在任何人身上。
10.1. 什么是持续交付?
持续交付 是一种围绕更快、更频繁地发布软件的方法。这种方法有助于降低可能影响用户体验的更改交付的成本、时间和风险。由于应用程序的交付是持续进行的,并且带有增量更新,因此更容易从最终用户那里收集反馈并相应地做出反应。
在持续集成(CD)中的主要概念是部署管道。正如其名所示,它是一系列步骤或程序,应用程序必须通过这些步骤或程序才能发布到生产环境。部署管道可能会根据您选择遵循的发布应用程序的过程而改变。例如,一些公司可能会在发布前进行手动测试/探索性测试阶段。其他公司可能会更进一步,将持续部署应用于管道,在构建成功后自动将每个更改发布到生产环境。另一方面,持续交付仅用于确保任何更改在任何时候可能发布(实际发布是手动决策)。
最常见、通用的部署管道如图 10.1 所示。通常,部署管道包括以下四个主要阶段:
1. 提交阶段——发布过程的第一部分,在团队成员向源代码管理(SCM)服务器提交内容后触发。这个阶段包括编译过程、单元(和重要的其他)测试执行、代码质量分析和构建可交付成果。理想情况下,提交阶段不应超过 10 分钟。
2. 自动化验收测试——执行被认为是慢速的自动化测试,因为这些测试使用了被测试系统中的多个部分(例如,自动化 UI 测试)。
3. 用户验收测试——用户测试应用程序以确保其符合他们的期望。其中一些测试可能是自动的(例如,容量测试),但也可以采用其他手动测试,例如探索性测试。通常为代码分析定义质量门,如测试覆盖率、指标收集和技术债务的测量;这个总称是完成定义(DoD)。
4. 发布——基于每个阶段的全部反馈,关键用户决定将版本发布到生产环境或放弃该版本。这是最终的验收测试或标准。
图 10.1. 典型的部署管道

现在您已经熟悉了 CD 的基础知识,让我们继续探讨微服务架构如何与之结合。
10.2. 持续交付与微服务架构
微服务在部署时应该具有以下特征:
-
每个微服务应该是一个小型、独立的部署单元。在相同的过程中一起部署微服务不被认为是最佳实践。
-
商业功能应该独立部署。需要注意的是,这意味着每个微服务都应该提供向后兼容性,当公共 API 发生变化时,通常通过版本化 API 来实现。
这些特性会影响部署管道。因为每个微服务都应该独立部署,所以你需要为每个微服务创建一个新的管道。图 10.2 展示了概述。
图 10.2. 微服务架构部署管道

使用这种方法创建部署管道的主要优势如下:
-
管道更小,只包含单个微服务的步骤。
-
由于与第三方系统的集成较少,管道更容易设置。
-
由于测试数量较少,你能够更快地收到反馈。你正在执行单个微服务的测试,而不是整个应用程序的测试。
但也有一些缺点需要你注意:
-
每个微服务可能使用不同的技术栈进行开发。这意味着每个部署管道可能需要每个阶段不同的工具,使得代码的可重用性降低,维护更复杂。
-
微服务是应用程序、运行它的服务器、它需要的数据库以及任何其他所需基础设施的组合。这意味着部署微服务的流程可能因微服务而异。例如,部署 SQL 数据库模式与部署 NoSQL 数据库模式不同。
-
即使微服务经过单元测试、组件测试、契约测试、服务虚拟化等全面测试,你仍然会在将系统的新部分独立部署到已运行的系统中时面临不确定性。询问诸如“旧消费者能否与新的提供者通信?”和“新服务能否从其他提供者(包括新旧)那里消费数据?”这样的问题是正常的。
为了减轻这些缺点的风险,你可以遵循以下策略:
-
在与项目源代码相同的存储库中定义管道。这确保了管道是由开发微服务的同一团队创建和维护的。
-
使用蓝绿部署方法。这意味着你为每个新版本创建一个完整的集群。然后运行一些自动化测试或采用探索性测试方法来验证一切是否按预期工作。之后,切换到新集群。
-
可以使用金丝雀发布(借用煤矿工人的术语)。采用这种方法,每次只部署少量节点的新微服务。一些真实用户(最好是内部用户)在定义的时间内使用系统对抗新微服务。在这段时间内,你可以监控所选用户交互的结果,以确保新服务按预期运行。
-
创建一个易于快速应用于任何给定微服务的回滚策略,以便在出现故障时,可以尽快回滚到之前的状态。
-
经常发布。经常发布意味着一次发布较少的功能,这反过来意味着您一次只改变系统的一步。这意味着完全破坏系统更困难。更重要的是,更容易检测到问题所在。
如您所见,在微服务架构中的发布自动化与其他任何应用程序的要求相同:能够以确定性和速度进行部署。
10.3. Orchestrating continuous delivery
您已经看到,持续交付(CD)管道由几个阶段组成,每个阶段可能包含几个步骤。实现每个步骤的代码通常位于您的构建工具中。例如,提交阶段由 编译、测试、代码质量 和 打包 步骤组成,如图 10.3 所示。当您使用 Gradle 构建工具时,提交阶段执行的命令分别是 gradle compileJava、gradle :test、gradle check 和 gradle assemble。
图 10.3. 提交阶段示例

您不希望每次想要执行提交阶段或任何其他阶段时都手动调用这些命令。您需要一个方法来编排所有这些命令的调用,并按正确的顺序运行它们以优化过程。(测试和静态代码分析步骤可以并行执行。)
持续集成(CI)服务器协调定义管道的所有步骤,管理构建过程,并为部署应用程序提供魔法。除了管理发布过程外,CI 服务器还提供了一个中心位置来检索管道执行的所有反馈,例如测试失败、每个环境的工件部署以及手动交互时挂起的构建过程。
10.3.1. 与 Jenkins 合作
您需要一个持续集成(CI)服务器来管理整个构建过程。正如我们之前提到的,我们认为 Jenkins 是最广泛使用的服务器,用于管理满足与全系列系统集成要求的部署管道。
Jenkins 是一个跨平台、CI/CD 服务器。它提供了各种定义您的部署管道的方法,并且与大量的测试和部署技术集成良好。
这里只是众多可用集成中的一些:
-
代码和提交—Git、GitHub、Mercurial、Visual Studio、Eclipse 和 Nexus
-
构建和配置—Maven、Gradle、Docker、Chef、Ant、Vagrant、Ansible 和 AWS
-
扫描和测试—Gerrit、Sauce Labs、Sonar、Gatling、JUnit、FitNesse 和 Cucumber
-
发布—uDeploy、Serena 和 MidVision
-
部署—AWS、Docker、OpenShift、Kubernetes、OpenStack、GCE 和 Azure
如您所见,Jenkins 覆盖了您可能需要实现任何部署管道的所有步骤。让我们看看如何使用 Jenkins 管道使用代码定义部署管道。
10.3.2. Jenkins 管道
Jenkins 管道 是一组插件,旨在帮助您将持续集成/交付/部署管道实现到 Jenkins 中。使用 Jenkins 管道,您可以在项目中以代码的形式定义您的交付管道,而不是依赖于过去的点对点用户界面。
通常,这个定义与你的服务位于同一个源代码管理(SCM)仓库中。这对于微服务架构来说非常完美,因为你可以将所有与该服务相关的所有内容放在一起。
编码的管道提供了一些好处。因为它是代码,您可以将其视为代码,您可以执行以下操作:
-
自动为所有分支和拉取请求创建管道
-
执行代码审查和管道的迭代改进
-
保持审计跟踪,因为更改也被提交到源代码管理(SCM)
-
启用协作,因为每个人都可以查看和编辑代码
在 Jenkins 中编写管道的默认文件名是 Jenkinsfile,它通常被检查到服务源代码管理(SCM)仓库的根目录中。Jenkinsfile 是一个包含通过 Pipeline DSL 交付服务/项目的所有步骤的文本文件。
截至 2.5 版本,Jenkins 管道支持两种离散的语法来定义管道:声明式管道 和 脚本式管道。在底层,两者都作为 Groovy DSL 实现。
声明式管道
声明式管道提供了一个简化的、有偏见的语法,用于编写部署管道。这种简化限制了灵活性和可扩展性;但另一方面,它使得部署管道易于编写和阅读。根据我们的经验,声明式方法对于几乎所有情况都足够了。
所有声明式管道都必须包含在 pipeline 块中。每个块必须只包含 部分、指令 和 赋值语句。
这里是一个 Jenkinsfile 的例子:
pipeline {
agent any *1*
stages { *2*
stage('Commit Test') {
steps { *3*
sh './gradlew check' *4*
}
}
}
post { *5*
always {
junit 'build/reports/**/*.xml' *6*
}
}
}
-
1 在任何可用的 Jenkins 代理(一个 Jenkins 进程)上执行管道
-
2 阶段是所有工作完成的地方。
-
3 阶段内执行的步骤
-
4 Shell 指令命令
-
5 构建之后执行的章节(后构建步骤)
-
6 归档测试结果的指令
根元素必须是 pipeline,它包围了当前管道的所有阶段。agent 指令指定整个管道或特定阶段将在哪里执行。
any 选项表示管道应该在任何可用的节点上执行。您还可以指定具有特定标签或特定 Docker 镜像的节点。
接下来,您定义一系列阶段。每个阶段都与您的部署管道的阶段相关,例如提交阶段、验收测试阶段等。每个阶段由负责执行工作的几个步骤组成。
执行结束时,post 指令被设置为始终注册 junit 结果。post 指令可以在顶层 pipeline 块和每个 stage 块中使用。请注意,post 支持条件块,如 always、failure、success、changed 和 unstable,这些块设置是否执行 post 操作。
在解释脚本管道之前,让我们看看一些声明式管道的简单示例。以下 Jenkinsfile 基于 Maven 镜像分配一个新的 Docker 容器。将工作区添加到镜像中,然后在其内部运行步骤:
pipeline {
agent {
docker 'maven:3-alpine' *1*
}
stages {
stage('Example Build') {
steps {
sh 'mvn -B clean verify' *2*
}
}
}
}
-
1 执行构建的 Docker 镜像
-
2 在容器内执行的 Shell 命令
你也可以在 Jenkinsfile 中与用户进行交互,如下例所示:
stage('Deploy - Staging') {
steps {
sh './deploy staging'
sh './run-smoke-tests'
}
}
post {
failure {
mail to: 'team@example.com', *1*
subject: "Failed Pipeline: ${currentBuild.fullDisplayName}",
body: "Something is wrong with ${env.BUILD_URL}"
}
}
stage('Sanity check') {
steps {
input "Does the staging environment look OK?" *2*
}
}
-
1 在失败时发送电子邮件
-
2 等待用户输入
这个 Jenkinsfile 有两个阶段:一个用于部署服务,一个用于检查构建状态。还有一个错误处理程序。如果发生错误,将向团队发送电子邮件,通知他们失败情况。第二个阶段等待任何用户的批准:部署管道将暂停,直到用户点击“继续”按钮。
脚本指令
script 步骤接受一段脚本管道代码块,并在声明式管道中执行它:
script {
def zones = ['EMEA', 'US']
for(inti=0;i< zones.size(); ++i) {
echo "Deploying service to ${zones[i]} datacenter"
}
}
对于非平凡大小或更复杂性的 script 块,应将其实现为一个共享库。
脚本管道
到目前为止,你已经看到了如何使用 Jenkins 声明式管道来定义管道。尽管这可能覆盖大多数用例,但如果你需要更多控制或需要执行更复杂的操作,那么 scripted pipeline 是最佳方法。声明式管道中涵盖的所有步骤在脚本管道中也是有效的。
脚本管道实际上是一个用 Groovy 构建的通用 DSL。这意味着 Groovy 语言中有效的多数功能在脚本管道中也是可用的。
这是一个 Jenkinsfile,展示了脚本管道的使用示例。
列表 10.1. Jenkinsfile
stage ('Compile') {
node { *1*
checkout scm *2*
try { *3*
sh "./gradlew clean compile"
} catch (ex) {
mail to: 'team@example.com',
subject: "Failed Pipeline: ${currentBuild.fullDisplayName}",
body: "Something is wrong with ${env.BUILD_URL}"
}
}
}
stage ('Deploy') {
node {
if (env.BRANCH_NAME == 'master') {
echo 'Proceed with deployment'
} else {
echo 'Only master branch can be deployed'
currentBuild.result = "UNSTABLE" *4*
}
}
}
-
1 选择运行节点
-
2 从源代码管理(SCM)检出代码
-
3 使用 try/catch 进行安全的流程控制
-
4 改变构建结果
你可以看到这个 Jenkinsfile 更像是一个 Groovy 脚本,而不是一个纯管道。
在脚本管道中,你定义阶段和节点。一个 node 是管道阶段将要执行的工作代理。你可以使用 sh、checkout 和 mailto 等步骤开始编写管道代码,或者使用 try/catch 和 if/else 等 Groovy 结构。
这只是展示如何编写部署管道代码的一个示例,以帮助你走上正确的道路——要全面了解 Jenkins 管道,需要整本书。要了解更多信息,你可以阅读jenkins.io/doc中的文档。
10.3.3. 确定性部署
在第六章中,你了解到消费者驱动型契约不仅仅是你的服务 API 的设计过程,也是分割服务依赖关系以便在隔离状态下进行测试的一种方式。契约首先针对消费者进行测试,然后针对该消费者的提供者进行测试。
这是在微服务架构中相对于端到端测试的一个大优势,因为在微服务架构中,由于环境准备复杂,尝试编写端到端测试可能会很复杂。此外,由于运行环境的复杂性(众多相互连接的服务、网络、不同的数据库等),这些测试可能成为不可靠或非确定性的候选者。
如果你没有使用端到端测试,如何独立部署新服务并确信没有出错?答案是,在部署管道中运行契约测试,针对环境矩阵进行测试。
在第六章中,你了解到第一步是编写一个契约。然后,你测试该契约与消费者和提供者的头部(当前开发)版本,以便你知道它们是兼容的。至少,这是在开发机器上的方法。
在 CD 阶段,服务是相互独立部署的(可能既有消费者也有/或提供者),这种方法就不够了。没有可靠的方法来回答以下问题:
-
消费者的头部版本是否与提供者的生产版本兼容?
-
提供者的头部版本是否与其消费者的生产版本兼容?
你需要在契约测试中更进一步,并验证契约与生产环境。
部署新的消费者
在将新的消费者部署到生产环境之前,重要的是验证消费者生成的契约与其提供者的头部版本。这也适用于提供者的生产版本。你需要确保当新的消费者进入生产环境时,当前的生产提供者仍然能够接收输入并为该输入产生输出。
注意
契约测试可以针对头部版本失败,但不能针对生产版本失败。尽管我们建议你修复这种情况,但这并不是阻止消费者部署到生产的障碍——至少,直到头部版本升级到生产版本。
部署新的提供者
在将新的提供者部署到生产环境之前,你需要验证契约的头部版本是否仍然适用于提供者所依赖的消费者。同样,这也适用于生产契约版本。你需要确保当新的提供者进入生产环境时,已经部署到生产环境中的消费者仍然能够与其通信。
注意
合同测试允许对头版本失败,但不能对生产中的版本失败。再次建议您修复此问题,但这不会阻止将提供者部署到生产环境。
10.4. Jenkins
Jenkins 提供了大量的插件来支持 CD 流程的所有阶段。Jenkins 以多种格式分发,包括 WAR 文件、平台相关的安装程序和 Docker 容器。我们猜测,如果您已经走到这一步,您已经安装了 Java。但以防万一您还没有,让我们看看如何从 WAR 文件(一个仅需要 Java 运行的自包含可执行归档)下载、安装和进行 Jenkins 的初始设置。
注意
在撰写本文时,Jenkins 的最新版本是 2.114。请检查并安装 Jenkins 的最新版本——该项目经常发布。
生成的密码
在打开浏览器访问 Jenkins 控制台之前,请特别注意控制台输出,类似于以下片段。

初始密码
Jenkins 首次启动时,会生成一个默认密码。您需要复制或记下此密码,因为首次访问 Jenkins 网络控制台创建初始管理员账户时需要它。
按照以下步骤操作:
1. 从
jenkins.io/download下载 Jenkins(参见图 10.4)。选择通用的 Java 包 (.war) 下载链接,并将文件本地保存。图 10.4. Jenkins 下载网站
2. 打开终端,并将下载的 jenkins.war 文件移动到您希望服务运行的目标位置:例如,
mv ~/Downloads/jenkins.war /opt/jenkins。3. 通过运行
java -jar jenkins.war启动 Jenkins。几秒钟后,Jenkins 将启动并运行。4. 打开浏览器并访问 http://localhost:8080,系统会提示您提供新的默认管理员密码。
5. 输入新密码后,您将看到一个屏幕,您可以选择安装建议的插件或手动选择要安装的插件。为了简化操作,并且因为这对于大多数情况来说是一个很好的起点,请点击“安装建议插件”,如图图 10.5 所示。Jenkins 将下载并安装 Jenkins 生态系统中最常用的插件,包括 Jenkins Pipeline 插件。图 10.6 显示了插件安装窗口。
图 10.5. 定制 Jenkins
图 10.6. 插件安装
6. 所有插件安装完成后,您将看到一个屏幕来创建第一个管理员用户,如图图 10.7 所示。
图 10.7. 创建管理员用户

安装和设置已完成,您现在可以开始定义您的第一个 Jenkins 管道作业。
10.4.1. 定义管道
要创建管道,请按照以下步骤操作:
1. 在 Jenkins 主页上点击新建项目链接,并为项目添加一个名称(例如,game-service)。
2. 选择多分支管道类型。此选项为(尚未指定的)项目中的每个检测到的分支创建一组管道项目(参见图 10.8)。
图 10.8. 新的多分支管道项目
3. 创建项目后,您可以配置管道作业。配置最重要的部分是分支源部分,其中定义了项目位置。点击添加源,然后选择 Git 选项。您将看到图 10.9 中所示的块。
图 10.9. 项目配置块
4. 将项目的完整 Git URL 添加到项目仓库字段,然后点击保存以完成注册新项目的流程。
在这一点上,Jenkins 将检测配置的存储库中的所有分支。如果分支包含 Jenkinsfile 文件,Jenkins 将使用该文件为该分支安排新的构建。
Blue Ocean
在撰写本文时,Blue Ocean 是一个可用于 Jenkins 的插件,通过减少复杂性并提高管道的可视清晰度来改善 Jenkins 用户体验。它默认未安装,因此您需要通过 Jenkins 插件管理器进行安装。以下是一些显著功能:
-
CD 管道的可视化,以便快速轻松地检查管道的状态
-
用于创建管道的可视化流程编辑器
-
个性化以满足团队基于角色的需求
-
精确定位,显示需要关注的管道中的确切点
-
分支和拉取请求的原生集成
在 Blue Ocean 中创建新作业很简单:导航到 http://localhost:8080/blue,点击新建管道,并填写所需信息。

新的管道
在幕后,Jenkins 检测可用的 Jenkinsfiles 并相应地安排构建。
10.4.2. Jenkins 管道的示例
10.2.3 节讨论了运行合同测试,以防止在发布新服务时破坏环境。想法是对头版本和生产版本运行合同测试,以确保所有服务在新的发布后都能相互通信。
让我们看看如何使用 Jenkins 和脚本管道有信心地发布一个 消费者。以下示例跳过了与编译和测试阶段相关的步骤,并专注于 消费者 交付管道的合同部分:
stage('Consumer Contract Tests') {
withEnv(['publishContracts=true']) { *1*
sh "./gradlew :contract:test" *2*
}
}
-
1 将 publishContracts 环境变量设置为 true
-
2 执行合同测试
消费者合约测试(存储在 contract 模块中)正在运行,生成的合约被发布到合约仓库。这是因为以下 arquillian.xml 文件评估 publishContracts 环境变量,以使 Arquillian Algeron 扩展 (arquillian.org/arquillian-algeron) 发布创建的合约:
<extension qualifier="algeron-consumer">;
<property name="publishConfiguration">;
<!-- Information about repository-->;
</property>;
<property name="publishContracts">;${env.publishcontracts:false}</property>;
</extension>;
在这个阶段,消费者端的合约测试正在运行,验证新的消费者版本能否与最新的合约版本进行通信。
在将这个新的消费者部署到生产之前,您需要确保指定的消费者提供者与这个新合约兼容。以下 Jenkinsfile 片段展示了如何验证最新合约与头提供者和当前生产提供者。
列表 10.2. 验证最新合约
def headResult, productionResult
stage('Provider Contract Tests') {
parallel ( *1*
headRun : {
headResult = build job: 'comments-service',
parameters: [string(name: 'commentsserviceurl', value: '')],
propagate: false
echo "Contract test against head Comments Service" +
"${headResult.result}"
},
productionRun : {
productionResult = build job: 'comments-service',
parameters: [string(name: 'commentsserviceurl',
value: 'commentsservice:8090')], *2*
propagate: false
echo "Contract test against production Comments Service" +
"${productionResult.result}"
}
)
}
if (productionResult.result != 'SUCCESS') {
currentBuild.result = 'FAILURE' *3*
echo "Game Service cannot be deployed to production" +
"Comments Service is not compatible with current GameService."
} else {
def lineSeparator = System.lineSeparator()
def message = "Do you want to Deploy Game Service To Production?" +
"${lineSeparator} Contract Tests against Production" +
"${productionResult.result} ${lineSeparator} Contract Tests against" +
"HEAD ${headResult.result}"
stage('Deploy To Production?') { *4*
input "${message}"
deploy()
}
}
-
1 头和生产的合约验证并行运行。
-
2 将生产位置作为参数传递
-
3 构建在生产提供者失败时失败。
-
4 如果生产正常,请求部署
这个阶段验证最新合约对于提供者的头和生产版本也是有效的,在这个例子中,是评论服务。两个版本的验证是并行进行的。因为提供者的验证合约位于提供者项目中,所以需要触发构建。
对于生产运行,服务位置被定义为构建参数。在提供者版本成功通过最新合约验证后,用户会被提示部署新的消费者到生产环境。
小贴士
根据您的软件发布模型,管道脚本可能会根据项目而有所不同。因此,我们鼓励您访问 jenkins.io/doc/book/pipeline 并深入了解 Jenkins 管道如何适应各种场景。
摘要
-
每个服务都应该独立部署。
-
代码化管道简化了部署,并鼓励将服务的交付视为服务项目的一部分。
-
确保使用合约测试针对生产版本部署到生产环境。
-
Jenkins 涵盖了在微服务架构中实施持续交付的所有需求。
附录。使用 Arquillian Chameleon 隐藏多个容器
对多个容器(如 WildFly、Glassfish、Tomcat 等)进行测试,或者在不同模式(管理、远程或嵌入式)之间切换,不可避免地会导致 pom.xml 文件膨胀。您会遇到以下问题:
-
您需要为每个容器和您想要测试的模式注册特定的依赖项,这也需要几个配置文件。
-
在管理容器的情况下,您需要下载、安装,并且可能还需要配置应用程序服务器。
Chameleon 容器 (github.com/arquillian/arquillian-container-chameleon) 可以快速适应您的需求,而无需进行额外的依赖项配置。要使用这种方法,就像在 Arquillian 中通常所做的那样,但将 Chameleon 容器添加到 pom.xml 而不是特定应用程序服务器的组件中:
<dependency>
<groupId>org.jboss.arquillian.junit</groupId>
<artifactId>arquillian-junit-container</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.arquillian.container</groupId>
<artifactId>arquillian-container-chameleon</artifactId>
<version>1.0.0.Beta2</version>
<scope>test</scope>
</dependency>
然后,将此配置添加到 arquillian.xml 文件中:
<container qualifier="chameleon" default="true">
<configuration>
<property name="chameleonTarget">
wildfly:9.0.0.Final:managed</property> *1*
<property name="serverConfig">standalone-full.xml</property> *2*
</configuration>
</container>
-
1 选择容器、版本和模式
-
2 适配器的特定属性
此示例告诉 Arquillian 使用管理模式的 WildFly 9.0.0。当运行测试时,Chameleon 会检查配置的容器是否已安装;如果没有安装,Chameleon 会下载并安装它。然后,Chameleon 在类路径上注册所需的底层适配器。之后,容器启动,并正常执行测试。
注意,任何设置的属性都会传递给底层的适配器。这意味着使用 Chameleon,您可以使用在适配器中有效的任何属性。
您可以在 github.com/arquillian/arquillian-container-chameleon 上了解更多关于 Arquillian Chameleon 以及使用它的优势。







浙公网安备 33010602011771号