软件测试微服务中间件

契约测试之核心解惑

2019-05-22  本文已影响1347人  ariman

在之前写的《契约测试之Pact By Example》中,我曾提到会再写一篇文章,来聊聊如何正确地认识和理解契约测试(好吧,至少是我认为的"正确地")。但在随后的一年多时间里,对契约测试的讨论渐渐淡出了我的视野。我的理解是,随着微服务的大行其道,契约测试作为带刀护卫,已经深入人心了,所以没必要再去炒这碗冷饭,就像现在已经没有谁会再来码字吹Selenium一样(...请相信,我一定不是因为懒才这么说的o(* ̄3 ̄)o)。

然而,在最近参加的一次面向Dev的后端分享的讨论中,我意外的发现,契约测试作为构建微服务重要的一环工程实践,虽然确实已经被团队原生接受,但对于契约测试的理解,还存在一些认识上的盲点,特别是当契约测试与集成测试、接口测试一起讨论的时候,理解的偏差往往会被放大不少。所以,我想必要的码点字,分享一下我对契约测试的理解,还是有益的。


"契约测试,是建立在服务的消费者和生产者之间的......"(此处省略废话N多字),如果您要继续看下去,请注意:

关于测试的表述

在聊契约测试之前,让我们先来说一些平时看似毫不起眼的小话题---"测试的表述"。

"我们可以在E2E测试中覆盖这个场景,而不是单元测试..."

或者

"你们的E2E测试是怎么做的?..."

这里的E2E测试可能经常出现在我们的日常交流中,那你知道它的准确含义吗?答案是没有含义!它基本等价于你们一伙人去食堂吃饭(...笑啥,俺就是食堂党,咋的!),A:"今天吃啥?",B:"新鲜的"。新鲜的啥?炒饭?面条?饺子?套餐?......

E2E,End To End,端到端,字面意思简单明了,但它只是一个副词(组),而不是一种测试类型。所以,我们真正想表述的,可能是E2E API Test。那么"E2E API Test"就完整的表述了一项测试活动了吗?不是的!E2E表示的是测试方式,API表示的是被测对象,但这里,我们还缺少被测对象的被测属性,比如,Function、Performance, Security等等,所以,一个比较完整的表述,往往可以是这样的:

当然,平常的交流中,一般不会这么文绉绉地去抠字眼,因为我们彼此都清楚讨论问题的上下文,这点很重要。特别是针对E2E测试这样的表述。比如,我们有一个前后端分离、后端是微服务集群的系统应用,同样的E2E测试可能就代表着完全不同的测试活动:

如果从更多的维度来思考,比如套上测试四象限的模式,那么对于测试活动的表述,还会有更多考量。但今天的主题是关于契约测试的,所以就不过多的展开了。为什么要在讨论契约测试之前来废话"测试表述"呢?因为契约测试其实是多种测试方式的和思维的复合产物,比如,契约测试是E2E的测试吗?还是说是基于Mock的?契约测试是服务的接口测试还是集成测试?等等。所以,如果对这些基本的测试概念不是很清楚的,很容易迷失在契约测试的理念中。

为什么要做契约测试?

为什么要做契约测试?"因为我们是微服务"?(╬ ̄皿 ̄)=○

很多回答这个问题的答案,都关注在契约测试的目的上。那么,什么是契约测试的目的呢?简单来说,契约测试就是为了发现契约破坏(Contract Breaking)而进行的测试活动。如果你使用过Pact或者Spring Cloud Contract,你会发现,契约测试本身也是通过调用Provider的API接口来获取Response,再与契约文件中期望的结果做对比,从而验证契约是否正确。形式上,这和我们的API接口测试,或者针对功能的集成测试(以下简称集成测试,因为我们这里不讨论API的安全、性能等问题)是非常类似的。换句话说,我们通过API的接口测试或者集成测试,也能达到检查契约的目的,那为什么还要做契约测试呢?这种思考逻辑是完全正确的,也是为什么很多初学者都认为契约测试没有必要的原因。

那再问,为什么我们还要做契约测试呢?真正能够回答这个问题的,不是契约测试的目的,而是契约测试可以带来的价值!

契约测试的价值

那什么是契约测试的价值呢?要说清楚契约测试的价值,就需要准确认识契约测试的精髓--"消费者驱动"。

消费者驱动的字面含义,大家都清楚,但往往容易忽略的是被驱动的对象。在讨论契约测试的范畴里,"消费者驱动"述及的对象是契约,而不是契约测试。

当某个provider正常上线后,某个consumer需要消费这个provider的服务,那么应该由consumer来提出期望建立它们之间的契约测试。因为,契约测试,形式上,虽然测试的是provider,但,价值上,保证的却是consumer的业务。如果consumer对自己都不上心,你还期望provider来时刻关注你的死活吗?别笑,在跨团队的微服务体系下,这些都是真切的痛点。

理清了消费者驱动,就让我们来看看契约测试真正的价值吧。一个经典的案例:

在上图一个简单的消费关系中,provider为consumer A,B,C提供服务。provider自己提供的schema包含name,agegender三个简单的字段。请注意,这份包含name,age和gender的JSON,其本身,只是一个schema,并不是任何契约。契约一定是成对存在的,没有确切consumer的交互定义,只是schema,不是契约。一个列子,中介打印了一份合同,上面写好了房屋租赁的全部信息,但在房东和租客都签字之前,这份"合同"并不具有任何效力,所以它根本就不是一份有意义的合同,法律上,它叫"要约"。(...感谢我大学的法律老师,我居然还记得这个词儿)

现在,这里有三份契约(对应的,就应该有三份契约测试),consumer A消费provider的age和gender,consumer B消费name、age和gender,consumer C消费name和gender。就目前provider提供的schema来说,没有任何问题,大家相安无事。

某日,因为业务需求,consumer C期望provider提供更加详细的name信息,包括firstName和lastName。这个需求对provider并不困难,所以,provider打算对schema做类似下面的修改。

这样的修改,很明显,对consumer C是需要的,对consumer A无所谓,但对consumer B却是不可接受的,属于典型的契约破坏。此时,provider和consumer B之间的契约测试就会挂掉,从而对provider提出预警(至于,剩下的,怎么协调和consumer B的兼容问题,就不是契约测试关注的问题,那需要的是团队间的communication)。

上面这个示例中的一些细节,可以帮助我们发掘契约测试的价值点:

"consumer A没有使用name,consumer C没有使用age",

基于消费者驱动的契约测试,契约的内容由consumer提供,其内容体现的是各个consumer对provider提供的schema的消费需求。这里的需求,不光包含consumer"需要什么",还包含consumer"不需要什么"。这是非常有意义的,因为当你发现provider提供的schema的某些部分不被任何consumer消费时,就代表provider可以对schema的这些内容做任意的修改,完全不必担心会影响到任何consumer。这是契约测试非常重要的价值点。

"单个provider多个consumer",

要最大化的体现契约测试异于集成测试的价值,一定是在"单个provider对应多个consumer"的架构下来说的。因为,在只有一个provider和一个consumer的架构下,只存在一份契约,对该契约内容的任何修改,对这对provider和consumer来说,都是显而易见的,那么就不会出现契约破坏的情况。说人话,就是,如果是consumer提出要修改契约,consumer一定知道改怎么消费新的契约内容;如果是provider提出修改契约,对于唯一的一个consumer,provider能很方便的告知其将要对契约的修改。并且,在这种情况下,集成测试往往就已经完整的达到了契约测试的目的。

而在单个provider对应多个consumer的架构下,情况就大不一样了。provider和consumer C之间的契约修改,对consumer A无感,对consumer B却是契约破坏,对此,集成测试是无能为力的。仔细来看,这里有4个service,就会有4个集成测试。但每个集成测试都只会关注自己的业务正确性,具体来说:

可见,虽然4个集成测试都各司其职,但都不能对这个契约破坏的问题做到防患于未然!只有契约测试,才是这个问题的最佳答案!这就是契约测试最大的价值,它只会在"单provider多consumer"的环境下(这是微服务的常见场景,但不是必然场景),才能发挥出来。

"很显然,对consumer A无害,但对consumer B却是契约破坏",

"很显然",仅仅是对于我们这个简单得不能再简单的示例而言,真正的业务场景下,特别是一些复杂的微服务集群,又或者是一些时间跨度很长的系统,对于某个provider,到底有多少个consumer?而provider的每一处修改,又到底会对哪些consumer的契约造成怎样的影响?这些往往都是很难确定的问题。我最近所在的一个集团项目上,一个搜索地址的基础服务provider,有十个左右的consumer,其中有八个consumer没有契约测试,就不清楚它们对provider的API具体是如何消费的,所以每次provider要更新,就得八方去通知这些consumer的团队来做回归测试。有时,一点小小的修改,回归测试一分钟就可以搞定,但人肉联系各个团队却会花上好几天......

如果每个consumer都能和provider建立契约测试(这里我们暂且不考虑负载和去重的问题),通过类似Pact Broker这样的实践,我们就能很好的解决这些效率问题。

OK,理解透契约测试的这些价值后,对于"要不要做契约测试?"、"谁来做契约测试?"这些问题,相信你就不再疑惑了。想再次强调一下的是,契约测试很多情况下基于微服务而生,但并不代表每个微服务都一定需要契约测试。相对的,一些传统的单体服务,它的架构设计和部署实施,完全和微服务的理念相反,但它提供的服务却被众多的下游消费者使用,那么这样的服务,也有很强的契约测试需求。所以,千万不要把契约测试和微服务做"死绑定",一定要基于服务的业务来考虑策略。

契约测试和接口测试、集成测试的区别

"契约测试和接口测试、集成测试的区别",从2015年我第一次在BQConf讲契约测试,到写这篇文章之前,最近一次和别人讨论契约测试,这都是一个一直被提起的问题。在上面的内容中,其实已经或多或少的提到了相关的内容。由于具体的测试方式,都是"调用API验证Response",契约测试、接口测试、集成测试经常被放在一起来进行比较,甚至质疑彼此。

先让我们来看看接口测试和集成测试。说实话,对于测试理论夯实的QA来说,这里应该没有任何问题的,因为接口测试和集成测试,它们压根儿就是从完全不同的维度来描述测试活动的。

前面说过,如果要完整的描述一个测试活动,至少需要考虑三个内容:测试方式、被测对象、被测属性。然而,"接口测试"和"集成测试",显然,都是我们根据上下文使用的简称,更准确的:

测试方式 被测对象 被测属性
接口测试 调用API接口 只能是API ...
集成测试 ... ... 肯定是被测对象在于外部依赖集成时的行为表现

接口测试

集成测试

所以,基于不同的维度,我们有"接口测试"和"集成测试"的表述,但,当放在和契约测试来讨论的时候,它们描述的可能是同样的测试活动。即,通过调用API接口,来测试API的功能行为。

这里,想强调一下集成测试中的"集成"。对于传统的瀑布开发模式,对应的测试流程按照测试级别(Test Level)划分,一般是:单元测试 -> 集成测试 -> 系统测试 -> 验收测试,这是"集成测试"早期的由来。

那会儿的应用,往往是庞大的单体服务,服务内部有分工明细、边界分明的"模块"。这些模块被并行开发,就绪后就会进行彼此集成。集成的对象,一般可以简单分为:逻辑模块、数据库模块、外部服务模块。比如,在上古时代,对数据库的操作是比较繁琐的,开发人员往往需要自己组装SQL语句,然后封装成模块来供上层调用。单元测试可以保证这些模块自己的逻辑正确,但像"模块中的各个函数接受的参数个数和参数类型是否和模块使用者的需求相匹配"这样的问题,就需要集成测试来确保(集成不等于集成测试,内容所限,我就不过多说明了)。这些测试都是发生在单体服务内部的,类似于现在的组件测试。

如今,微服务的设计,将不同业务的"模块"拆分成了不同的服务,各个服务都是高内聚的。以Spring为例,Controller -> Service -> Repository,内部垂直划分,简单明了。像上面提到的手写SQL这样的数据持久化工作,已经基本不存在了,取而代之的是像spring-boot-starter-data-jpa或spring-boot-starter-data-mongodb这样功能强大、方便易用的公共组件,最重要的,这样的公共组件,一般都有很高的官方质量保证的。所以,结论就是,在上古时代的那种传统的集成测试,在微服务的体系下,已经基本不需要了。

而对于单个微服务的质量保障,特别是当这个微服务有外部集成的时候,比如数据库或者外部服务,我们仍然需要进行检查外部集成的测试。再结合微服务业务的单一性,我们可以很自然的将这种"检查外部集成的测试"合并到API的接口功能测试中。说人话就是,对于微服务,只进行API的接口功能测试,既涵盖对被测服务领域逻辑的检查,又覆盖其对外部集成的检查。

当然,这里已经讨论到了微服务测试策略了,我就不再过多展开了。话收回来,如果要和契约测试进行区别比较的话,我们只用考虑功能性的API接口测试就可以了。

理清了接口测试和集成测试的内部姻缘(下面我统称功能测试),我们就最后来说说它们和契约测试的区别吧~
其实,上面那个示例,已经很好的展现了它们的区别,我就不过多解释了,简单来说:

契约测试可以替代集成测试吗?

"契约测试替代集成测试",说实话,第一次听见这个说法的时候,我是非常惊讶的,这得多大的脑洞才能给出这样的命题呀!

提示一下,就题论题,这里的"集成测试",并不全等与上面提到的"功能测试",仅仅是一般论的集成测试。

先来揣测一下,为什么会有这样的问题吧。我们知道,在Pact(JVM)的实施过程中,第一步是在consumer端生成契约文件。这期间,Pact会根据自定义的契约,在consumer端启动一个mock server(如果你有看源码,就知道它只是一个普通的HttpServer实例),consumer向这个mock server发送request获取response,整个过程被记录成JSON的契约文件。

这个流程的最后一步,一直有一个大家乐于争论的话题:"要不要对response的内容做断言检查?"。这是一个很开放的问题,没有标准的答案。但我想强调的是,不加断言,这一切只是一个"流程"或者说"步骤",加上断言,它就是测试。是的,对consumer来说,它就是consumer的一种集成测试(啥?"用的是Mock Server,都没有集成真正的provider,为什么叫集成测试?" 如果你有这个问题,可以再仔细想想集成测试的真正含义......)。

以上是解题背景。现在,让我们再来省一下题吧,"契约测试可以替代集成测试吗?",这里,其实隐藏了很大的一个意识盲点。契约测试,描述的测试活动,一定是架设在一对consumer和provider之间的。那么题目里的集成测试呢?你是想替换consumer端的集成测试?还是想替换provider端的集成测试?还是说其实你也不清楚到我想替换哪一端的集成测试......"不!我想说的不是两个服务之间的集成的那种测试,而是整个系统,包括全部上下游服务,集成在一起的集成测试"......诶,好吧,那叫系统(E2E)测试......

还是让我们回到一般论的集成测试上来吧(不然,要说的实在太多了 T_T),无论是consumer端还是provider端,集成测试的关注点,是consumer是否可以正确的消费provider的API,这里的"消费"包括调用接口和解析数据。它的被测对象,注意,一定是consumer,或者说,是一个服务作为consumer的角色(因为,某个服务经常既是consumer,又是provider)。而契约测试的被测对象,一定是provider。好了,这就是问题的核心,其它的细节,我想就不必再赘述了吧。

关于Pact和Spring Cloud Contract

"用Pact还是Spring Cloud Contract?",这是另一个经常被讨论的话题。它背后折射的却是另一个非常重要的概念博弈:契约测试 vs 基于契约的测试(契约驱动的测试)

Pact的理念是消费者驱动的契约测试。什么是契约测试呢?目前,我没有找到任何"权威"的定义。其实,面向工程实践的理念,也许根本就没有权威,有的只是最适用于自身的实践总结。即便如此,我还是希望以个人的视角,提供一些解读:

好了,有了这三点重要的理论基础,就让我们来具体看看Pact和Spring Cloud Contract(以下简称SCC)的区别吧。

在上面的图中,给出了Pact和SCC具体的使用方式(逻辑路径)。当然,如果你有一些基本的Pact或SCC的使用经验,就再好不过了。

Pact,在consumer端生成契约文件,发布到Pact Broker,而后,provider从Pact Broker获取契约文件,触发provider端执行契约测试。

SCC,实际生成契约文件的工作是发生在provider端的,基于这份契约文件,在provider端,生成了Java的测试案例,这些测试案例用于provider的功能测试;而在Consumer端,使用同一份契约文件作为Stub,生成了基于WireMock的mock service,consumer可以使用该mock service来做集成测试。

可见,Pact作为消费者驱动契约测试的倡导者,真正地实践了消费者驱动的契约测试。相对的,SCC,既没有实际的将契约作为被测对象来进行测试,更没有确实地实现"消费者驱动"。SCC的做法,实际上是基于同一份契约,分别驱动了consumer端的集成测试和provider端的功能测试。所以,Pact和SCC的区别,就在于,前者做的是"契约测试",后者做的是"基于契约的测试(契约驱动的测试)"。

如果有同学阅读过SCC的文档,一定会质疑,SCC明文写着"Spring Cloud Contract Verifier enables Consumer Driven Contract (CDC) development of JVM-based applications",那为什么说它没有确实地实现"消费者驱动"呢?因为在SCC的设计中,原始契约文件是在provider端生成的。为了实现CDC,consumer需要在其本地克隆provider的代码仓库,"借"provider来生成原始的契约文件。显然,在现实的项目中,consumer团队不可能随心所欲的获取到provider代码仓库访问权限,所以有了后来的,基于Share Repo的解决方案,来实现契约的共享(编辑和使用)。所以说,从最初的设计思想来看,SCC并没有像Pact那样,"实实在在"地实践了消费者驱动的契约测试。

那么,到底是选择Pact(契约测试)还是SCC(基于契约的测试)呢?答案是"按需取舍"。
比较Pact和SCC的目的,并不是区别彼此的好坏长短,而是阐述它们各自不同的测试理念。Pact的价值点,前面已经说过了,SCC,虽然做的并不是真正的契约测试,但它通过共享(同一份)契约的方式,实现了微服务测试中,consumer和provider之间E2E集成测试的解耦,这在实际项目中,也是有重要的现实意义的。感兴趣的同学可以自己下来多研究研究,我就不在这里扩展了。

一些问题

至此,在我看来,契约测试相关的认识难点,就已经基本解读到了。但在结束全文之前,有两个问题,我还想再阐述一下:

consumer端的集成测试需要做到什么程度?

对于Pact,前面提到,在consumer端生成契约文件的时候,加上断言语句后,就"构成"了consumer端的集成测试。这个集成测试,从Pact的角度来说,是可选的,它的目的是保证consumer端生成的契约文件本身是正确的。但从consumer的角度来说,要不要进行这一层级的集成测试,取决于consumer团队自己的测试策略。我想说的是,如果要进行这一层级的集成测试,请一定合理把握你的测试粒度和测试范畴。

"生产者驱动的契约测试"?

相较于到目前为止通篇强调的"消费者驱动的契约测试",你可能在其他地方,或多或少的,看到过"生产者驱动的契约测试"的命题。
单论契约,确实可以分为"消费者驱动的契约"和"生产者驱动的契约",但述及契约测试,到目前为止,恕俺视野有限,我并不认为"生产者驱动的契约测试"是一种正确的表述。

上一篇 下一篇

猜你喜欢

热点阅读