概述:
在软件工程的世界里,我们经常面临变化。微服务不仅改变了软件的体系结构,而且改变了团队的组织方式和协作方式。
相对于单体式应用,微服务有其优势,同时,也有引入后所新产生的问题,测试就是问题之一。
在这篇文章中,我们想概述一下测试如何在微服务的新世界中发生变化。我们还将介绍消费者驱动的契约测试的细节和支持它的框架。
为了较为全面的阐述CDCT的概念,本文翻译、引用、和综合了多篇相关文章的内容,相关链接附后。
目录:
一、单元测试
二、端到端(系统)测试
三、集成测试
四、使用消费者驱动契约测试(CDCT)
五、总结
一、单元测试
当我们谈到微服务时,我们还应该进行单元测试吗?答案是肯定的,单元测试已经证明是一种可靠的、快速的,也不是那么昂贵的方法来测试业务逻辑的有效性。但是单元测试仅仅保证服务提供者或者服务消费者某一方的代码是有效的或者功能是正常的,而不能保证服务之间的交互是有效的。而这恰恰是微服务的核心应用场景之一。
二、端到端(系统)测试
当我们谈到微服务时,我们还应该进行端到端的测试吗?是的,进行端到端测试是很重要的,但是当我们谈到微服务时,为了执行端到端的测试,需要部署从服务消费者到服务提供者之间所有环节的相关调用,复杂程度可能会非常高。
三、集成测试
测试两个服务(提供者和消费者)之间的交互的传统方法是使用集成测试。这样做的目的是在某些集成环境中同时运行消费者服务和提供者服务,并检查它们是否按预期进行交互。这种类型的测试模拟了服务在生产环境中的行为,因此在理论上集成测试是有意义的。然而,这种方法存在一些问题。
首先,集成测试通常比较慢。它们需要设置集成环境,启动消费者和提供者服务并初始化它们的依赖关系。起初,这似乎不是一个问题,但是随着集成测试的数量开始增加,构建过程变得越来越慢。在微服务体系结构中尤其如此。在每一对交互的微服务之间进行集成测试是不合适的。
集成测试的另一个问题是它们很脆弱。有时,它们会因为与服务本身无关的原因而失败,可能存在网络问题或数据库之类的外部依赖关系。而意味着失败的集成测试并不一定意味着代码存在问题。
集成测试的另一个问题是定位困难。即使由于消费者和提供者服务之间的实际集成问题而导致集成测试失败,很难确定问题的所在:这是消费者服务的错误吗?还是提供者的服务?还是两者兼而有之?
集成测试增加了额外的团队开销。集成测试主要由QA团队执行,而不是由开发人员自己执行,这意味着在出现问题时,团队之间需要额外的开销。这也导致了一个问题:谁更适合测试两个服务之间的集成点:QA团队?还是服务的实际开发人员?在到达QA之前,清楚地知道两个服务在开发时是否正确地交互,将为我们节省大量的时间和开销。
四、使用消费者驱动契约测试
(CDCT)
虽然三种方式各有利弊,但与集成测试及端到端测试相比,单元测试相对来说是健壮、可靠的,它们工作速度快,并且非常具体地告诉我们问题在哪里。如果可以更加有效的测试方法改进单元测试来验证服务间交互,肯定会改善我们的开发、测试和部署体验。
消费者驱动契约测试(Consumer-Driven Contracts Testing)背后的理念是定义每个服务消费者与提供者之间的契约,然后根据该契约对消费者和提供者进行独立测试,以验证他们是否符合契约约定的事项。
为了更好地理解,我们将使用以下示例模型来描述这一微服务测试方法背后的概念。
在上图中,我们可以看到两个微服务通过REST相互通信。第一个服务是消费者(Consumer)的角色,第二个是提供者(Provider)的角色。
当服务提供者不发生变化的情况下,比如我们通过Mock模拟服务提供者的相关反馈,相关测试是可以通过的。
但是,如果是在生产环境中,测试时模拟的服务反馈很可能跟不上服务提供者的变化,比如服务提供者更改了服务的数据格式,从“名字,姓名“到”人名“。集成测试将无法捕捉到这个问题,因为它们是针对过时版本的提供程序运行的,此时,就会发生如下的情况。
消费者驱动契约的理念是将服务消费者和提供者之间的互动正式化。服务消费者创建一个契约,它是服务消费者和提供者之间就他们之间将要发生的交互达成的协议。或者换句话说,提出服务消费者对提供者的期望。一旦提供者就契约达成协议,消费者和提供者都可以获取契约的副本,并使用测试来验证它们的相应实现没有违反契约。
消费者驱动的契约测试,通常实现方式如下:
1. 选择合适的场景,定义消费者的请求和期望的响应。
2. 使用Mock机制,为消费者提供模拟的提供者以及期望的响应。
3. 记录消费者发送的请求、提供者提供的响应以及关于场景的其它元数据,并将其记录为当前场景的契约。
4. 模拟消费者,向真正的提供者模拟发送请求。
5. 验证提供者提供的契约是否和之前记录的契约一样。
这种新的测试方法的优点是它们基本上是添加了交互条件的单元测试:它们可以在本地独立运行,而且速度快、可靠。但这其实与Mock方式模拟的好处相当,事实上,CDCT所带来的优势远非如此。
优势1:降低接口变化带来的服务消费者风险
CDCT契约的发起方是服务消费者,由服务消费者定义自己所需要的反馈信息,因此,可以保证服务消费者总是能够获得自己所需的反馈。而不论服务提供者一方发生了什么变化。以CDCT测试框架PACT为例。
服务消费者通过建立模拟提供者的Mock,可以对请求、响应和相关信息记录下来,成为一个Pact文件。这个文件就是消费者与提供者之间的契约。在这个过程中,服务提供者无需进行任何操作。
接下来,在服务提供者一端,将通过模拟消费者的Mock对Pact文件进行回放,要求服务提供者针对该契约做出正确的响应。通过这样的的过程,完成一次完整的从服务消费者向服务提供者的驱动过程。
当服务提供者需要对接口做出变更时,仍旧需要遵循契约的要求,以反馈正确的结果,这样,就可以保证服务消费者总是得到正确的信息而不论服务提供者的接口发生怎样的变化。除非消费者端主动的重新订立契约。
优势2:解耦开发团队,降低测试成本,解放生产力
当服务消费者和服务提供者以契约为中介形成解耦的时候,相关的技术团队也因此形成了解耦,而不需要一定针对一个端到端的测试场景来进行配合。
这里我们引入两个技术团队进行相关的测试。左侧的是服务消费者,需要通过ID查询用户的邮件地址,右侧的是服务提供者,负责反馈正确的邮件地址信息。
在服务消费者和提供者之间建立一个契约,我们称之为TEST,来要求服务提供者根据ID反馈正确的EMAIL。
服务消费者可以通过运行TEST测试来了解自己能否获得正确的信息,但事实上,这并没有必要,因为只有当服务提供者一方发生服务接口的变更时,才会影响契约的效力,所以正确的做法是,只需要在服务提供者一方来进行对契约的验证测试即可。
这样,服务消费者将通过契约来驱动服务提供者完成既定的功能反馈,当双方对此过程协调一致,运转正常之后,服务提供者将不再需要服务消费者来发布任何契约的变更,就可以单独的依赖契约发现代码的缺陷。而服务消费者技术团队,就可以专注于本身的事情,甚至于去支持其他的项目内容。
应用场景举例:第三方API的集成测试
在现实场景中,一方面企业内部会有诸多的遗留系统API,另一方面也同时会有很多情况需要调用外部的API,比如谷歌地图,这些情况下API并不受我们的掌控,即使提交一些反馈,相关变更也可能以数周或者数月为单位,甚至对于遗留系统来说,相关的供应商都已经不复存在。
对于应用将对这类API进行集成的场景,此时,应用是消费者端,而API是服务提供端,我们可以有三种处理方式:
1、消费者端手动检查:通过手动检查应用程序是否做了它应该做的事情以及是否使用了来自API的正确值来确保应用程序仍然工作。
2、服务者端真实调用:首先确认API被正确集成,测试的时候直接调用API来检查相关功能是否正确,这将涉及网络带来的测试速度的影响,以及调用费用的消耗,毕竟每一次调用都不是免费的。
3、记录服务端反馈,并在代码库中回放:在这种情况下,仅需要调用一次API,并将相关反馈记录为JSON文件,从而解决了网络和费用问题,但仍旧无法绕开一旦服务接口发生变化带来的影响。
引入 CDCT可以缓解这个问题。但显然我们不能将契约发布给Google Maps API或我们遗留的CRM系统,并迫使他们遵守。这些提供者可能既不关心也不具备支持CDCT的工具。因此,乍一看,为第三方API使用CDCT似乎很奇怪。
我们可以做的是在自动化测试期间,创建另一个服务,作为谷歌API的替代品。该服务将保存从实际API中定义所需字段的契约。我们称这些服务为代理。它们从不代理HTTP请求,而是在自动化测试期间充当谷歌API和应用之间的中间角色。代理将有两个目标:
1.确保API按预期响应,就像在实际调用真实的谷歌API一样。
2.向服务消费者提供契约文件,以供回放,类似于一个JSON响应文件。
让我们举个例子,我们要展示从德国斯图加特到柏林需要多长时间。使用Google距离矩阵API 我们进行如下的调用:
http https://maps.googleapis.com/maps/api/distancematrix/json \
origins==Berlin destinations==Stuttgart
调用结果是
{
"destination_addresses" : [ "Berlin, Germany" ],
"origin_addresses" : [ "Stuttgart, Germany" ],
"rows" : [
{
"elements" : [
{
"distance" : {
"text" : "636 km",
"value" : 635736
},
"duration" : {
"text" : "6 hours 18 mins",
"value" : 22651
},
"status" : "OK"
}
]
}
},
"status" : "OK"
}
通过这样的请求调用,我们了解到,从柏林开车到斯图加特需要大约6小时18分钟的时间。这个时间是通过22651来换算取得。这是以秒为单位的持续时间。我们的服务消费者,例如Android应用程序,可能想决定他们想如何为用户对这个值做格式化。因此我们应该确保这个经行时间字段包含在响应中,也就是说,针对这个值做契约上的约定。
以 Spring Cloud Contract 的 Groovy DSL 为例,我们可以定义如下的契约:
org.springframework.cloud.contract.spec.Contract.make {
request {
method GET()
url("/maps/api/distancematrix/json") {
queryParameters {
parameter 'origins': 'Berlin'
parameter 'destinations': 'Stuttgart'
}
}
}
response {
status 200
body([
rows : [[
elements: [[
duration: [
value: 22651
]
]]
]],
])
}
}
可以看到,相对于此前的完整反馈,契约只包含我们关心的部分响应和用于创建预期响应的所应发出的请求。框架将可以自动生成以下的测试代码和相关字段 的断言。
@Test
public void validate_shouldProvideDistanceBetweenTwoCities() {
// when:
Response response = webTarget
.path("/maps/api/distancematrix/json")
.queryParam("origins", "Berlin")
.queryParam("destinations", "Stuttgart")
.request()
.method("GET");
String responseAsString = response.readEntity(String.class);
// then:
assertThat(response.getStatus()).isEqualTo(200);
// and:
DocumentContext parsedJson = JsonPath.parse(responseAsString);
assertThatJson(parsedJson).array("['rows']")
.array("['elements']").field("['duration']")
.field("['value']").isEqualTo(22651);
}
如果我们在请求中提供指定的参数(when部分),预期应该获得指定的响应(then部分)。生成的契约测试不需要我们编写任何实现代码就可以通过。
并且在测试运行之后,我们会得到一些JSON文件作为存根,类似PACT的契约文件,保存在本地用于应用测试。
如果实际的谷歌API服务调整了两地的行经时间由25561改为25562,上述的代码可能就并不适用了。我们需要将生成的断言修改如下的内容:
assertThatJson(parsedJson).array("['rows']")
.array("['elements']").field("['duration']")
.field("['value']").matches("\\d+");
这样,在实际调用过程中,即使谷歌API反馈的是12345,服务消费方也不会崩溃。
此外要让测试命中存根而不是真正的API,我们需要配置如下的服务映射。
stubrunner:
ids: 'co.hodler:scdcproxy:+:stubs'
stubsMode: LOCAL
ids-to-service-ids:
scdcproxy: google-distance-service
通过使用CDCT技术,我们确保了
● 该API的行为与我们预期的一样。● 除了代理项目之外,我们的测试不调用真正的API。
● 我们确保预期的响应和实际的响应之间没有不匹配。
主流框架介绍
能够完成CDCT任务的框架有Janus\Pact\Pacto\Spring Cloud Contract等,网上可以找到比较多资料的是PACT和Spring Cloud Contract。
PACT
(https://docs.pact.io/)
其官网的说明是这样的:
PACT是一种契约测试工具。契约测试是一种确保服务(例如API提供程序和客户端)能够相互通信的方法。如果没有契约测试,了解服务可以通信的唯一方法就是使用昂贵而脆弱的集成测试。你是否放火烧了你的房子来测试你的烟雾报警器?不,你用测试按钮来测试它和你耳朵之间的合同。PACT为您的代码提供了测试按钮,允许您安全地确认您的应用程序将一起工作,而不必先部署这个世界。
Pact是一个开源框架,最早是由澳洲最大的房地产信息提供商REA Group的开发者及咨询师们共同创造。REA Group的开发团队很早便在项目中使用了微服务架构,并在团队中对于敏捷和测试的重要性早已形成共识,因此设计出这样的优秀框架并应用于日常工作中也是十分自然。
Pact工具于2013年开始开源,发展到今天已然形成了一个小的生态圈,包括各种语言(Ruby/Java/.NET/JavaScript/Go/Scala/Groovy...)下的Pact实现,契约文件共享工具Pact Broker等。Pact的用户已经遍及包括RedHat、IBM、Accenture等在内的若干知名公司,Pact已经是事实上的契约测试方面的业界标准。
Spring Cloud Contract
(https://cloud.spring.io/spring-cloud-contract/)
Spring Cloud Contract是一套完整的解决方案,帮助用户成功地实现消费者驱动的契约方法。目前,Spring Cloud Contract的主体是Spring Cloud Contract Verifier项目。
Spring Cloud Contract Verifier是一个工具,它支持基于JVM的应用程序的消费者驱动契约(CDC)开发。用Groovy或YAML编写契约定义语言(DSL)。
Spring Cloud Contract Verifier将TDD提升到软件体系结构的级别。
五、总结
消费者驱动的契约测试,关键理念在于两个方面:
一是,通过提供中介契约,形成了服务消费者和服务提供者之间的解耦
二是,由消费者出发发布契约的方式,确保服务消费者的价值得以优先实现
从而带来的好处是:
一是服务提供端的接口变化不会对服务消费端产生影响
二是降低了传统的集成测试以及端到端测试过程中的昂贵成本。
三是快速反馈、独立部署、降低复杂度,更快的开发速度和更短的迭代时间。