距离我上一次写契约测试的文章已经过去了三年,在这期间,契约测试在测试策略层面已经确确实实地被很多团队落地实践,无论是对工具的熟练层度、还是对引入契约测试的主观意愿,越来越多的团队在契约测试上都展现出了更高的使用水准,甚喜。
最近,我接触到了两个不同项目的一些事情,它们都对契约测试有所涉及,但又都包含了一些很容易让人迷失的细节,所以想和大家一起分享。
生产者端的契约测试不是“写”出来的
在一次帮助项目上的开发同学评审契约测试代码的时候,我留意到开发同学多次描述“……在生产者端的实现是这么写的……” ,我顿时感到有些“好奇”,因为正常情况下,Pact在生产者端的契约测试不是写出来的,而是执行出来的(否则“消费者驱动的契约测试”的最终结果就只能是累死生产者团队)。于是我们进一步地对生产者端的契约测试代码进行了走读。
结果发现,开发同学通过注解的方式、使用Pact的state功能对契约文件中定义的每一个交互分别进行了对应响应的实现。这样就会出现“契约文件定义多少交互,生产者端就要写多少测试”的情况,显然,这不是一种最佳的契约测试实践方式。
我们先来回顾一下契约测试在生产者端的一般实践方式,如下图所示,Pact从Pact Broker拉取契约文件(或者直接读取本地的契约文件),然后从契约文件中提取交互中的请求发送给生产者服务,生产者服务根据请求返回对应的响应,Pact再将生产者返回的真实响应与契约文件中定义的期望响应进行对比,得出测试结果。在这一过程中,生产者端的契约测试有两个重要特征:
生产者端只需要执行测试,而不需要写测试,测试案例都由Pact通过契约文件来触发执行;
测试执行过程中,要求生产者服务一定要是尽量真实的服务;
这里的“真实”又体现在两个点上,其一,服务一定要是真正部署且运行的服务;其二,服务的代码一定(或尽量)要贴近真实产品的源码,避免包含或使用支持测试的辅助代码,即测试尽量保证非侵入性。
而Pact提供的state方式,恰恰是一种侵入式的测试方式。通常来讲,当消费者端期望测试一些异常情况下的交互时,可以和生产者端协商使用state来支持测试,比如测试生产者服务出现Internal Server Error的情况,这些情况在正常的测试环境中很难稳定触发,不能支持测试的持续执行,所以才会使用state的方式来“模拟”。所以,作为一种侵入式的模拟测试手段,state方式在契约测试中一定要慎用。
那么,回过头来想一下,为什么开发同学会在生产者端的契约测试中地毯式地使用state呢?我想可能还是对契约测试的理解有些流于表面造成的。通常情况下,当我们说到“写测试”的时候,头脑中的步骤大概是这样的:
- 分析和思考测试点;
- 把测试案例写下来;
- 执行测试;
而在使用Pact进行消费者驱动的契约测试时,特别是在生产者端,“分析和思考测试点”的工作显然是不需要的,因为已经有契约文件这个现成的测试集合了,那么要做的就是“写测试案例”和“执行测试”。怎么写?对于生产者端的契约测试,Pact官网并没有给出多少写的步骤(因为确实在生产者端,通常情况下就不需要写测试),唯独要写的就是state的方式。由此,可能会误导一些开发同学以为生产者端的契约测试就是根据契约文件的定义使用state来遍地开花,实则不然。
生产者端的契约测试要使用Mock吗?
还是在上述对生产者端的契约测试进行评审的过程中,我们发现生产者端的state之所以“很香”,一个非常重要的原因就是方便构造数据。我们上面提到,state的主要使用场景是模拟生产者服务出现异常情况的响应,异常情况都可以模拟,那正常情况的响应岂不更是顺手拈来。以SpringBoot的Controller、Service、Repository三层划分来说,既然能在Service层(甚至Controller层)使用Mock返回任意数据的响应,那何必还去调用依赖服务或者查询数据库然后组装真实数据返回呢?毕竟对测试来说,测试数据的准备和保障永远都是令人头痛的事情,这对契约测试来说也不例外。所以,在“真香定律”面前,state + Mock就成了生产者端执行契约测试的最佳组合……貌似。
这样的认知有一个看似无懈可击的“理论支撑”,那就是:“契约测试验证的只是生产者服务返回的数据结构(少量情况下可能也会校验数值),通俗来讲就是schema,既然只验证schema,那生产者服务内部的数据是Mock的还是E2E的,其实并不重要”。如此考量,可能也是对契约测试的认知流于片面所致。作为契约测试众多价值中的一种:验证生产者服务的履约能力,期望的一定是最真实的生产者服务,能够E2E就尽量E2E,能不使用Mock就尽量不使用Mock,只有这样,我们验证的履约能力才是最接近真实的履约能力。实施自动化测试的目的,不是让测试能永远顺利通过,而是让测试能永远体现出其应有的价值。
当然,理想很丰满,但现实却很骨感。在实践过程中,我们确实难免会遇到依赖服务不稳定、测试数据难以构造等问题。这种时候,我们首先应该考虑的是使用虚拟服务(比如wiremock)和测试数据库(比如testcontainers),而不是通过state在生产者服务内部添加Mock代码,因为前者在解决数据依赖的同时保证了生产者服务自身的完整性和真实性,而后者则是外科手术式的侵入式实现。而当虚拟服务和测试数据库都无法满足我们的需求时,比如就是需要构造Internal Server Error的情况,那么就大可使用state + Mock的组合了。
换个角度看契约测试
通常情况下:
- 我们都是在服务之间讨论契约测试,典型的场景就是在微服务之间构建契约测试;
- 契约测试要想发挥最大的价值,一定是在多(消费者)对一(生产者)的架构中;
然而,这两点也有例外。前段时间帮助另一个项目解析测试痛点时,就遇到了一个非常鲜明的案例。如下图所示,一个APP前端消费后端的API服务,甚至可以把后端的API服务理解为APP专属的BFF。对于这样的架构(其实这里都谈不上“架构”,仅仅算个调用关系罢了),通常我们是完全不用考虑做契约测试的,因为如此架构下的契约测试并不能带来多余UI E2E测试与API功能测试的任何价值。
然而,这个项目的痛点在于,在生产环境上要求可以同时共存不同版本的APP,BFF需要对历史版本的APP进行前向兼容。不同版本的APP在消费BFF时使用的接口定义可能不同,这就要求团队在每次更新BFF版本时,都要对所有历史版本的APP做回归测试,以避免出现接口不兼容的情况。显而易见,这个测试工作量是很大的,即使通过UI的E2E自动化测试来全回归,测试执行和维护的工作量也是远远超出了我们通常基于测试金字塔所期望的可控范畴。可如果我们换个视角来看待这个问题,如下图所示,将APP的不同版本视为各自不同的消费者服务,BFF还是那个唯一的生产者服务,那整个架构不就是我们前面提到的最经典的契约测试场景了吗?
所以,只要我们能够建立既往各版本APP与其当时版本BFF之间的完整契约测试,我们就能为BFF的后续迭代变更提供强有力的质量保障,从而避免了每次更新BFF都要回归测试各个历史版本APP的艰难挑战。
说到这里,细心的同学可能会想,“UI的E2E自动化测试解决不了这个问题的根本原因是工作量太大,契约测试能够解决这个问题无非是因为测试维护和执行的工作量小而已,那么类似的,不用契约测试,而用API功能测试的方法,为各个版本的APP所消费的后端BFF版本建立各自的自动化API功能测试,是不是也能解决这个问题呢?” 答案是肯定的,建立多套自动化API功能测试确实可以解决相同的问题。那它和契约测试的区别又在哪里呢?答案就是没有区别。在这个场景下,当我们使用Pact进行契约测试时,其实质也是使用不同的契约文件触发了不同的版本的API测试。而当我们抛开Pact这个工具,使用类似RestAssured这样的工具来实现类似的“多套”API自动化测试时,我们达到的效果和使用Pact是几乎完全相同的。其实,当我们真的构建这种多套API功能测试时,我们所做的工作就是使用RestAssured对契约测试进行了实现。所以说,契约测试更重要的是一种思想,当我们剖析完问题的实质、确定可以使用契约测试来解决问题后,选择怎样的工具是可以非常灵活的。