优秀的编程知识分享平台

网站首页 > 技术文章 正文

微服务架构原理:一文解析展现契约测试的相关技术与用法实战

nanyue 2024-10-17 11:14:57 技术文章 4 ℃

契约测试的相关技术与用法实战

之前提到过,TDD属于开发方法,契约测试则是一种工程实践,在了解了TDD和契约测试的相关概念和原理后,下面介绍在实际项目实战中的具体技术框架和用法。

Mock测试

Mock的英文意思是虚假、不诚实、仿制。在单元测试中会经常使用Mock的方式来保证单元测试更加“单元”,即不用集成真实的依赖组件或服务,只是模拟它们的行为。

所谓“单元”,就是最小的单位组件。单元测试就是对一个单元,即程序的最小组件的正确性进行验证,通常在面向对象中,最小的单元就是方法。也就是说,在单元测试中,让测试只去关注验证方法本身的逻辑。但在我们的方法中会有别的依赖,哪怕程序设计得再合理,方法的职责再单一,在代码逻辑中依然存在着各种关联关系,如Controller依赖于Service,Service依赖于Repository,有时还会有外部的服务依赖,如数据库依赖、缓存服务的依赖、第三方服务接口的依赖等。


如果要编写一个包含这些依赖方法的单元测试,最直接的办法就是把相关的依赖都部署起来,这样测试在运行时就会调用到真实的依赖或服务,又回到了集成测试的怪圈,假设我们只想要写一个简单方法的单元测试,这个方法依赖第三方系统的接口调用,难道还需要部署一套第三方系统才能测试吗?

单元测试是最基本的代码级别的测试,目的是消除程序单元本身的不可靠,并不会关注其他单元的逻辑,我们来看一下测试金字塔理论,Mike Cohn的原始测试金字塔如图4.13所示。

金字塔将测试分为3层,最底层是单元测试,中间层是服务测试,最上层是UI测试,面积越庞大代表着测试的数量和覆盖范围也就越大。金字塔越往上,测试所需要的集成依赖也就越多,测试的执行效率也就越慢。

所以,单元测试应该更加单纯,结构更加分离,运行更加高效,这就需要用到Mock,Java中比较常用的Mock工具是Mockito,当然,Spring Boot Test中自动集成的Mockito扩展了很多Mock的用法。

假设现在开发一个商城系统,在订单服务中有计算订单价格的方法,规则是如果下单的用户为VIP用户,那么订单价格打9折,具体的实现代码如下。

getPrice方法除有本身订单价格的计算逻辑之外,还需要调用用户服务去判断下单的用户是否为VIP用户,现在需要对getPrice方法进行测试,它的测试代码如下。

我们的测试目标是getPrice方法的计算逻辑,但从getPrice的代码中发现,要想方法正常运行,就需要依赖用户服务。假设用户服务是一个远程服务,那么还需要单独部署一个用户服务,包括服务所依赖的数据库、缓存服务等组件,而且如果要完成下单用户是否为VIP两种场景的测试覆盖,还需要在用户服务的数据库中准备两条符合条件的用户数据,这样测试显然十分麻烦,也不符合单元测试的规范。

这时可以通过Mock的方式将测试代码改造一下,首先需要解决getPrice依赖UserService的问题,这里不关心用户服务判断用户是VIP的逻辑是否正确,关于它的测试应该由另一个单元测试来检验。这里只是测试价格的计算逻辑,所以我们可以Mock一个UserService来替代真实的UserService,代码如下。

将之前由Spring 注入OrderService的方式改为创建一个OrderService,使用Mockito框架提供的Mock静态方法构造一个假的UserService,通过构造器将UserService传入OrderService中,此时就不需要一个真实的启动用户服务了。

然后还需要针对测试的场景给假的UserService定义一些规则,让它能够模拟真实的用户服务功能。例如,测试中有两个用户,用户的ID分别为1和2,其中ID1是VIP,ID2是普通用户,调用UserService的isVIPUser方法时,当传入用户ID1时,返回true,当传入用户ID2时,返回false,明确规则后,将之前的代码改造如下。

通过Mockito框架提供的when和thenReturn方法,可以灵活地定义Mock服务的行为规则,然后运行测试,测试虽然正常通过,但启动有点慢,因为加入了Spring的测试注解。

加入这两行注解后,测试会启动Spring容器、加载Spring上下文等信息,就像启动项目一样,非常耗时,之前写这两行注解是因为我们使用Spring创建和注入的OrderService,所以需要启动Spring容器。现在我们通过Mock的方式将OrderService与UserService解耦,改为手动创建一个OrderService,将Spring框架从测试代码中去除以提高测试执行效率,此时完成的测试代码如下。

运行时快很多,从中可以发现当我们的测试越接近单元测试,它的依赖就越少,执行速度就越快,与图4.13中测试金字塔一样。Mock是契约测试中的常用手段,熟悉了Mock之后,接下来学习几款优秀的契约测试框架的用法。

消费者驱动的契约测试Pact

Pact是目前比较主流的契约测试框架,采用的是消费者驱动契约(Consumer-Driven Contracts,CDC)的测试方法。消费者驱动契约就是消费者将具体期望提出来,告知服务提供者,然后双方定义契约,服务提供者依据契约进行服务的开发,而这份契约最终也会被用来验证服务提供者提供的接口,从而达到消费者驱动服务开发的目的。

CDC是目前比较主流的契约测试实现方式,这样做也更加符合日常逻辑,而且由消费者提出的契约更加契合真实的调用需求,比单纯的后端为了接口定义契约更有前瞻性,CDC产生的契约生产的Mock服务几乎能和实际服务实现一致。

总体而言,CDC在服务开发方面有以下两个显著的优点。

(1)消费者更加关注关键业务的价值和驱动因素,能够规范服务的功能,使服务方在最小的范围内实现业务价值。消费者驱动的契约通过断言生成服务的输入和输出约束,因此服务提供者和契约会完全与服务消费者的业务目标保持一致。

(2)消费者驱动的契约为我们提供了细粒度的洞察力和快速反馈。在实践中,由于契约是从消费端产生的,因此它可以细粒度地针对每个消费者的需求进行检测,在消费者的契约中可以快速获取到服务提供者的信息,能快速检测出具体服务的变更并评估其对当前生产中的应用程序的影响。

Pact就采用了CDC的方式,并且提供了Ruby、JavaScript、Go、Python和Java等多种语言的实现,接下来以Java项目为例介绍Pact的具体用法。

首先,我们需要在项目中加入Pact的依赖au.com.dius:pact-jvmconsumer-junit_2.12,笔者目前使用的版本是3.5.22,在项目的build.gradle中加入如下代码。

假设我们现在有一个订单服务作为服务的消费者,要调用用户服务获取订单的用户信息,其服务调用关系如图4.14所示。

我们访问Pact在GitHub上的官方地址可以查看到详细的使用教程,其中介绍了3种Pact在服务消费端的用法。

其次,还是先配置Pact的依赖,代码如下。

1. 在服务消费者端使用Pact

提供的ConsumerPactTestMk2类我们新建一个UserClientJunitTest 的测试类,继承ConsumerPactTestMk2,代码如下。

ConsumerPactTestMk2提供标准的Pact的测试实现,并且提供相应的定制化方法,其中createPact用于定义一个契约,主要可以定义接口的请求路径、参数、响应类型和响应等信息,同时Pact会根据定义契约去生成契约文件,providerName和consumerName用于定义契约的服务提供者和消费者双方的名称,最后runTest方法可以在里面测试契约的正确性,Pact会根据我们在createPact中定义的契约启动模拟服务,通过runTest的方法参数MockServer可以获取这个服务的信息,用于实际的测试请求。

例如,现在要测试一个GET请求,地址是/users/{id},然后返回用户信息的Json,代码如下。

这里用到了一些Model类,代码如下。

还使用了自定义JsonUtils工具类,代码如下。

在createPact中,使用PactDslWithProvider来定义契约,即定义接口的http method、请求路径、返回类型与返回的header和body,其中given会作为契约的名称标识,在服务提供者的测试中使用到,uponReceiving 会作为契约的描述,最后根据 providerName 和consumerName方法返回服务提供者和消费者的名称来生成契约文件,如上述代码,生成的契约文件为order-service-user-service.json,默认生成在项目根目录下的target/Pacts文件夹中。

在runTest中,Pact会根据契约的定义启动一个模拟服务作为MockServer,我们可以使用Apache的org.apache.http.client.fluent.Request来发起一个请求,测试实际的返回结果是否符合消费者的预期,来验证契约的正确性。

当然,如果这里觉得Request.Get不好用,可以使用之前章节介绍过的Spring的RestTemplate,前提是集成了Spring Web,然后可以这样写代码。

这里不用集成SpringBootTest和SpringRunner,首先因为这里的测试很“单元”,并不依赖Spring的相关组件,其次是加载Spring容器会导致测试变慢,所以在测试中我们都尽量自己去创建实例,避免通过Spring Bean的方式进行实例的管理。

2. 在服务消费者端使用Pact提供的Junit Rule

这里的写法其实和第一种类似,第一种写法的好处是提供了标准的抽象方法,模板化的编程方式只需关注具体的方法实现,但缺点是不够灵活,一个类中只能定义一个服务提供者,不能与多个服务提供者进行测试,而使用@Rule的方式就比较灵活,代码如下。

在上述代码中,通过@Rule、@Pact和@PactVerification等注解来达到与继承中定义方法相同的效果。例如,使用@Rule可以定义服务提供者的Mock Server及提供者的名称,使用@Pact可以定义消费者的名称和创建契约的方法,使用@Test和@PactVerification组合的方式可以测试契约,这样就可以灵活定义多个服务提供者,即多个Mock服务,同样,这种方式也可以生产契约文件。

3. 在服务消费者端直接使用Pact DSL

也许你认为第二种使用@Rule注解的方式不够灵活,例如,一个类中只能定义一个契约,意味着一个类中只能对一个契约进行测试。所以Pact提供了第三种服务消费者的测试方式,代码如下。

没有第三方的注解,也不需要加载其他上下文,每个测试中,需要手动地创建相关实例进行创建和验证契约的操作,不过Pact提供了ConsumerPactBuilder来构造契约,通过consumer方法设置消费者的名称,通过hasPactWith方法设置服务提供者的名称,同时Pact还提供了runConsumerTest的静态方法构造一个服务提供者的Mock Server来验证契约。

每个@test都是一个完整的契约定义和验证,在同一个类中定义多个契约是很方便的。同样,这种方式也会生成契约文件。下面以DSL的用法为例,展示生成的契约文件内容,契约为JSON格式,具体如下。

下面需要将这份契约文件复制到服务提供端,用于对服务提供方的接口进行验证。

在服务提供端,将生成的契约文件 order-service-userservice.json复制到服务端根目录的PactContracts文件夹下,然后进入Pact服务提供者的测试依赖包,在build.gradle中添加如下代码。

尽量与服务消费端使用的版本保持一致,以减少一些不必要的问题。添加完依赖包后,开始添加接口的测试,代码如下。

我们使用Pact提供的PactRunner启动测试类,并且使用@Provider定义服务的名称,这里的名称需要和契约文件中定义的服务提供者的名称相同,然后使用@PactFolder指导契约文件的路径。

接着只需用@TestTarget定义服务提供者的信息,如host和port等,默认host是localhost,配置服务提供者的端口是8081,然后定义一个空的方法,使用@State注解来修饰该方法,@State的value值要和你想要测试的契约中的providerStates.name属性相同,也就是我们在服务消费方定义契约时所使用的ConsumerPactBuilder.given方法所定义的名称。

下面运行测试,此时Pact会使用契约中定义的请求方式真实地请求localhost:8081的服务,测试结果如下。

测试失败,提示信息是连接被拒,很显然这里并没有启动任何端口为8081的服务,所以契约测试没有通过。假设服务配置的端口是8081,可以在测试前启动应用,这里使用的是Spring Boot,所以只需在测试中添加如下代码。

再次运行测试,发现仍然失败,但错误信息变了,显示如下。

原来是404,证明刚才增加的启动服务代码有效,下面只需继续TDD完成接口的开发。接口代码如下。

UserController所调用UserService的代码如下。

这样的测试显然不是单元测试,因为它真实地启动了整个应用,接口的控制层、服务层、持久化层,包括数据库都集成进来,那还如何做到单元测试?其实Pact框架也想到了这一点,使用Pact提供的SpringRestPactRunner来启动测试,代码如下。

增加@SpringBootTest注解来启动Spring Web,并且配置端口为随机端口,target就不再需要指定具体的端口号,使用SpringBootHttpTarget可以获取当前启动的随机端口号。

假设用户接口的实现是依赖UserService,为了解耦可以使用@MockBean来Mock服务类,然后在@State的方法中定义Mock的规则,当然,也可以使用@Before来定义规则,代码如下。

此时再运行测试,结果如下。

终于成功了,其实这样还不够“单元”,使用SpringBootTest启动的仍然是整个应用,目的是测试接口,也就是测试Controller,只不过这里Mock了UserService,而使Controller与其后续的依赖都分离了,但其实我们要测试的范围比 Controller 更小,仅仅是UserController,那么能不能只加载UserController,Pact提供了一种新的Target来解决这个问题,即MockMvcTarget,具体代码如下。

这里不再需要SpringRestPactRunner和SpringBootTest,因为我们不需要加载Spring的整个上下文。使用MockMvcTarget来配置服务端,可以看到使用target的setController方法类设置想要加载的Controller,UserService仍然使用Mock的方式创建,并且通过构造器传入UserController,其他代码不用修改,启动一下,测试执行时间又加快了。

假设此时修改了接口,User类中username变为username2,修改属性名称这种操作在开发中会经常出现,再次运行测试,结果失败,错误信息如下。

此时契约测试会帮我们检测出这个问题,从而避免服务发生变化而导致的依赖方调用失败的问题。

至此,Pact的消费者和服务者的契约测试用法基本介绍完毕,下面再介绍一个Pact工具的使用方法。在上述的例子中,我们采用手动方式将契约从服务消费者的代码库中复制到服务提供者的代码库,如果忘记了复制最新的契约文件,很可能造成契约双方的契约文件内容不同步,那么契约测试也就失去了意义。

虽然可以通过Git的Submodules工具来实现两个仓库的文件同步,但Submodules本身有一定的使用复杂度,而且经常出现流水线的配置冲突问题,增加了项目本身的环境配置复杂度。Pact提供了相应的同步工具,并且还提供了图表的数据展示,下面以Docker的方式部署Pact Broker的服务。

首先需要安装一个数据库,因为Pact Broker需要依赖外部的数据库做存储,并支持postgresql、mysql和sqlite等数据库,官方推荐使用postgresql,这里使用Docker来启动一个postgresql,指令如下。

如果本地没有postgresql的镜像,此时Docker会自动拉取最新的postgresql镜像,下载完成后再运行postgresql数据库,如上指令中我们对外配置了5432端口作为数据库的端口,设置数据库的用户名和密码为admin/password。

启动成功后,我们可以使用docker ps指令查看到已经运行的容器,然后连接数据库进行初始化工作,例如,若要为Pact Broker创建相关的数据库和用户,可以使用如下指令进入postgresql的命令模式。

如果显示与下面一样,证明数据库已经安装并且运行成功。

然后执行相关的创建用户和创建数据库的指令。

如上所示,我们创建用户名为pactuser的用户,密码为123456,创建名为 pactbroker 的数据 库,并且赋予 pactuser 用户操作pactbroker数据库的全部权限。

然后运行以下指令拉取并运行最新的Pact Broker服务。

Pact Broker服务的默认端口是80,将它映射到端口9500上,通过“--link” 使 当 前 容 器 能 够 与 之 前 启 动 的 postgresql 的 容 器pactbroker-db通信,通过“-e”设置容器的环境变量,只需配置数据库 相 关 的 连 接 配 置 即 可 。 启 动 成 功 后 , 在 浏 览 器 中 访 问http://localhost:9500,进入Pact Broker首页,如图4.15所示。

现在可以在项目中使用Pact了,由于Pact本身是基于CDC的策略,因此使用Pact Broker时应该先由服务消费者提交契约到Broker,然后服务提供者从Broker拉取最新的契约。

(1)消费者推送契约。Pact Broker为消费者端提供了相应的Maven和Gradle插件进行契约推送操作,下面以Gradle为例,在build.gradle中添加配置如下。

如果使用的是Gradle 2.1以上的版本,还可以直接通过如下配置加入Broker插件。

然后配置契约文件的路径以及Pact Broker的服务地址,这样Pact Broker的插件就可以执行推送的工作,在消费者的build.gradle中添加配置如下。

然后执行如下指令。

执行成功,控制台返回如下代码。

然后访问Pact Broker的页面地址,发现已经有了一条契约的数据,数据中显示契约的消费者和提供者的名称、提交时间,以及详细的契约信息和调用关系图,Pact Broker首页的契约数据如图4.16所示。

单击文件的小图标可以查看契约的详细信息,如图4.17所示。

可以直接单击服务的名称,进入服务调用关系图页面,如图4.18所示。

(2)服务端拉取契约。完成消费端的契约推送之后,接下来需要在服务端拉取契约,在服务端集成Pact Broker要更简单些,并不需要集成任何插件。

首 先 , 将 原 先 的 契 约 文 件 删 掉 , 在 测 试 类 上 删 除@PactFolder("PactContracts")注解,这样测试就已经和本地的契约文件没有关系,然后增加@PactBroker注解来配置Pact Broker的服务信息和契约信息,其他代码不变,具体如下。

其中,host和port是配置的Pact Broker的服务信息,这样Pact就知道从哪里去拉取契约,然后需要指定consumer,告诉Pact去拉取哪个消费者的契约,当然可以使用数组来定义多个消费者。再次运行测试,测试通过并且会在控制台发现有如下日志输入。

证明Pact会在测试之前连接Pact Broker服务,并拉取我们需要的契约来进行测试,这样就做到了契约的同步。虽然需要配置一个额外的服务和数据库,但使用起来还是十分方便的。

本文给大家讲解的内容是契约测试的相关技术与用法实战

  1. 下篇文章给大家讲解的是Spring 家族契约测试 Spring Cloud Contract
  2. 觉得文章不错的朋友可以转发此文关注小编,有需要的可以私信小编获取;
  3. 感谢大家的支持!

Tags:

最近发表
标签列表