◎ 契约测试概述
◎ 契约测试与TDD
◎ 契约测试与独立交付
◎ 契约测试的相关技术与用法实战
在微服务架构中最常见的事情就是远程调用,如服务和服务之间的远程调用,前端和后端之间的远程调用,BFF和服务之间的远程调用,等等。当一个服务的接口发生变化时,依赖它的消费者也需要进行相应的调试或修改,如果这个过程采用口口相传或文件通知的办法,就会很低效,而且容易遗漏。在大多数时候服务端并不能清楚地知道全部的消费者有哪些,哪个接口会影响哪个消费者。这时就需要一种自动的方法来帮助我们测试接口的可靠性,这就是契约测试。
契约也就是合约,是双方当事人意见一致并且要共同遵守的行为表示,服务的调用者和提供者就好比签订契约的甲方和乙方。契约测试就是验证签订契约双方的行为是否符合契约。
通常我们并不知道服务间的依赖关系是怎样的,如每个接口的消费者是谁,相同的接口不同的消费者都需要哪些数据,这些消费者正在消费哪个版本的接口等,要在一个项目中理清这些问题显然有些困难,哪怕管理做得再好,也不可能面面俱到,而且文件记录和实际情况往往会有差距。
如何能准确地检测接口的变化所带来的影响?是否管理所有服务端与消费者之间的关系?虽然这样做看似可行且最直接,但是要管理所有接口的版本、调用关系等信息无疑是一个巨大的工程,而且也只能完成快速定位接口,并不能完全保证把影响降低,解决这些影响。一旦有遗漏,就意味着系统有问题。
契约测试的做法能解决上述问题,在微服务中,无论是服务与服务之间,还是服务与前端之间,抑或是服务与API Gateway之间,只要双方有远程调用的依赖关系,都可以定义一个关于双方所依赖的接口契约,约定好接口的请求和返回信息,包括地址、参数、头部、响应数据等,并且最好通过Git等版本管理工具将契约管理起来。
由服务提供者和调用者共同维护,双方需要严格遵守这份契约,通常我们会将这份文件的信息解析出来,作为双方单元测试的基准,然后消费者和服务者双方都会测试自己的服务或请求是否遵守这份契约的规则,从而保证双方依赖接口的正常使用,契约测试示意如图4.1所示。
只要一方发生变化,就会导致测试的失败,然后变化的一方就会去更改契约并且通知相关接口调用者。假设忘记通知其中一个调用者,这个调用者在使用最新的契约进行测试时,也会测试失败,然后就会发现这个接口的变化,最后沟通并修复问题,这就是契约测试。
这样做的好处是,当服务端接口变动后,只需修改对应的契约文件,就能让契约的另一方测试失败,准确地分析接口的影响范围,并且如果契约测试是自动化的,整个过程成本极低,而且高效、准确,这样我们就能通过自动化测试的手段,最大限度地避免人为的遗漏,保证服务提供者和调用者之间依赖的正确性。
而消费者如果想对接口进行调整,同样可以修改契约文件,然后服务端的契约测试就会失败,保证服务提供者对于接口的验证。
这份契约不仅可以作为服务端和客户端的逻辑验证,还可以用来模拟一个后端的服务,接口的调用者就不用等到服务开发完成后才能调试程序,服务提供者和调用者双方通常会在最开始定义好接口的契约,然后服务提供者依据契约去开发接口,服务调用者则可以使用契约模拟一个假的服务实例,通常称这个假的服务实例为Mock Server。调用者会先用Mock Server来开发自己的程序,等真实的服务开发好后再进行集成测试,这种做法在前后端分离开发中尤为常见,如图4.2所示。
测试方式有很多种,如单元测试、集成测试、E2E测试、冒烟测试等,对于开发人员来讲,接触最多的是单元测试。契约测试也是单元测试的一种,说起单元测试就需要提到TDD,接下来了解一下契约测试在TDD中的实践。
TDD(Test-Driven Development,测试驱动开发)是一种软件开发过程中的应用方法,提倡在编写代码时先写出测试,然后编码实现,编码的目的就是让测试通过,以达到一种由测试驱动开发的过程,并因此得名TDD。
这个方法最早是由XP(Extreme Programming,极限编程)提出来的,XP是一种软件工程的方法学,也是敏捷软件开发中最高效的几种方法之一,它更强调可适应性而不是可预测性。XP认为软件需求的不断变化是软件项目开发中不可避免的现象,应该欣然接受,与其在项目初始阶段费尽心思地定义和控制需求,不如将精力放在建设软件的适应能力上,所以我们需要不断地重构,并且需要测试为重构保驾护航,TDD可以说是XP中十分重要的一环,想深入了解的读者可以查阅相关资料。
什么是TDD,应该如何做TDD?例如,如果现在要实现一两个整数相加的功能,那么TDD开发步骤应如图4.3所示。
首先写一个测试,代码如下。
这个测试是我们期望的一个基本结果,如期望有一个Calculator类,提供一个add方法,并且执行add(1,1)的结果是2。显然,这时编译会报错,因为还没有编写任何一行实现代码,这时以最少量的代码先让代码编译通过。
为了保证编译通过,编写一个Calculator类,代码如下。
然后运行测试,出现空指针异常,原因是变量calculator为空,再次增加代码来解决空指针的问题,测试代码改造如下。
再次运行测试,运行正常,但测试结果失败,报错如下。
显然期望结果是2,但实际得到的结果是0,快速修改代码来尝试让测试通过,修改add方法,代码如下。
再次运行测试,测试成功,不过还需要编写新的测试来让测试失败,添加测试如下。
再次运行测试,不出意外测试再次失败,错误如下。
这时继续重构我们的实现,代码如下。
再次运行测试,测试通过,我们还需要再次增加测试代码来查看这次的实现逻辑是否有问题,代码如下。
再次运行测试,测试依然通过。如果不放心,可以继续增加一些其他条件的输入参数,尽量是一些边界值或不同的情况组合,如果此时测试仍然能够通过,基本上就算完成了该功能的开发。
这就是通过TDD的方式开发一个功能的过程,虽然这个场景比较简单,但已经演示了TDD的精髓所在。有时我们在所有测试通过后,可能还会加入一些重构代码的环节。例如,在测试通过的过程中,会产生一些“坏味道”的代码,带来如代码冗余、复杂度过高、信息链等问题,这时我们就需要进行重构,重构会出现新的问题,导致测试再次失败。有测试保障代码的逻辑,我们就可以进行放心大胆的重构,继续TDD让测试再次通过。综上所述,从TDD的过程可以得出如图4.4的流程。
从图4.4可以看出,在TDD的过程中,实现功能之前需先编写会失败的测试,然后以最少的代价编写具体的实现代码让测试通过,在测试通过后,再对代码进行重构,测试可能失败,或者再次尝试编写一些会失败的测试,再继续整个过程。
国内对于TDD的理解同样十分模糊,有很多人对TDD有一定的误解,认为其只适合不擅长前期代码设计的初级开发人员,或者认为TDD会增加开发人员的工作量,损害生产力,所以它只适合大公司,小团队没有时间去执行TDD,当然也有完全相反的观点,认为TDD只适合初创公司。尽管TDD拥有众多忠实的支持者,但也不乏反对者,理由如下。
(1)使开发效率低下。
(2)不重视设计,总想着重构。
(3)过于保守,会因为怕破坏测试而不想重构。
(4)太过于注重细节而忽略整体设计。
(5)适合新手或者初创公司。
(6)大型企业才有时间去执行。
……
从上述理由中发现大家的观点都是不愿意TDD,但理由却自相矛盾。那为什么需要TDD,TDD又有哪些好处?
TDD更像是一种设计方法。编写单元测试的行为更像是一种设计行为,是一种围绕需求核心价值可以落地实施的设计行为,能更好地帮助开发人员理解软件的需求和验收条件,更好地进行思考和设计。
TDD使单元测试更有价值。表面上看,从我们使用其开发的话,工作量好像变多了,但不采用TDD,单元测试可以省略吗?一个缺少单元测试覆盖的项目,无论是开发还是重构,都会有危险。很多时候,我们在事后为了提高测试覆盖率而编写的测试代码并不能完全契合最初的开发意图,甚至只会编写一些HAppy Path的测试,从而导致项目的测试覆盖率上去了,但测试大多数都是没有价值的,而使用TDD,代码就是为了使测试通过而编写的,测试已不只是契合开发意图的,此时的测试就是开发意图,这样的测试才更有价值。当我们拥有相对全面而完善的单元测试时,代码就像是强壮的护卫,可以放心大胆地进行优化、重构等工作而不需要人工进行反复回归。
TDD帮助我们分解开发步骤,使开发更具有目的性,同时帮助我们理清开发思路,使开发时有条不紊、步骤分明。我们可以将一个简单的加法运算拆分成可执行的每个步骤。同样,在面对复杂问题时,TDD仍然可以将问题拆分成具体的软件需求,通过结果导向,考虑功能的输入和输出,来指导开发逐步实现需求。
TDD更加契合敏捷的思想,需要保证系统的适应力,正如在图4.4中所展示的,重构在TDD中是重要的一环,不断让测试失败、通过,就是为了让代码能够更加安全、灵活地重构。
综上所述,通过整体分析可以发现使用TDD反而会提高开发效率,因为它更加重视设计,能够减少人工的代码回归,帮助开发人员进行任务分解,更加契合敏捷思想。
前面提到TDD比较注重细节,会忽略整体规划。其实,TDD分为ATDD(Acceptance Test Driven Development)和UTDD(Unit Test Driven Development)两个概念。
ATDD即验收测试驱动开发,它的实践一直在用,只是并未与理论对应。例如,在开始编写业务代码前,无论是敏捷还是瀑布,都会由产品经理或业务分析师,抑或测试人员编写验收测试用例,然后开发会依据这次测试用例深入理解系统需求,这一过程甚至对代码能起到一定的指导和驱动作用,这就是ATDD。
UTDD即单元测试驱动开发,更多的由开发人员自己完成。例如,在4.2.1节的例子中,开发人员编写单元测试用例,然后编写实现代码让测试通过,再编写测试试图让测试失败,然后重构实现,再次让测试通过。
很明显,UTDD比较关注细节,在单元测试中,其更加关心代码和技术实现本身,而ATDD更关注系统的整体业务需求和结果,所以说TDD只关注细节显然太片面,如图4.5所示。
契约测试也是TDD
我们来回想一下契约测试,如果说TDD是一种软件方法,那么契约测试更像一种工程实践,在前面的内容中了解到,在契约测试中需要定义一份契约文件,这份契约可以作为前端接口的Mock Server服务于前端开发,也会作为后端接口的验证条件,从而驱动后端服务接口的开发。
通常,我们会在开发之前就定义好契约,因为服务调用的一方常常依据契约生成对应的Mock Server,保证双方都能够并行开发。不难看出,一旦我们应用了契约测试,无形中就开始了TDD的第一步,契约就是我们最开始定义的失败测试。为了让契约测试通过,我们会进一步编写实现代码,无论怎样重构,只要需求没有变化,契约也不会发生变化,契约测试会保证接口的正确性。这样看来,契约确实与TDD有异曲同工之处。
如果要进行单元测试,通常契约测试只会针对接口层的测试,若要开发一个Controller,则其契约测试步骤如图4.6所示。
首先写一个测试,代码如下。
这个测试是我们期望的一个基本结果,如期望有一个Calculator类,提供一个add方法,并且执行add(1,1)的结果是2。显然,这时编译会报错,因为还没有编写任何一行实现代码,这时以最少量的代码先让代码编译通过。
为了保证编译通过,编写一个Calculator类,代码如下。
然后运行测试,出现空指针异常,原因是变量calculator为空,再次增加代码来解决空指针的问题,测试代码改造如下。
再次运行测试,运行正常,但测试结果失败,报错如下。
显然期望结果是2,但实际得到的结果是0,快速修改代码来尝试让测试通过,修改add方法,代码如下。
再次运行测试,测试成功,不过还需要编写新的测试来让测试失败,添加测试如下。
再次运行测试,不出意外测试再次失败,错误如下。
这时继续重构我们的实现,代码如下。
再次运行测试,测试通过,我们还需要再次增加测试代码来查看这次的实现逻辑是否有问题,代码如下。
再次运行测试,测试依然通过。如果不放心,可以继续增加一些其他条件的输入参数,尽量是一些边界值或不同的情况组合,如果此时测试仍然能够通过,基本上就算完成了该功能的开发。
这就是通过TDD的方式开发一个功能的过程,虽然这个场景比较简单,但已经演示了TDD的精髓所在。有时我们在所有测试通过后,可能还会加入一些重构代码的环节。例如,在测试通过的过程中,会产生一些“坏味道”的代码,带来如代码冗余、复杂度过高、信息链等问题,这时我们就需要进行重构,重构会出现新的问题,导致测试再次失败。有测试保障代码的逻辑,我们就可以进行放心大胆的重构,继续TDD让测试再次通过。综上所述,从TDD的过程可以得出如图4.4的流程。
从图4.4可以看出,在TDD的过程中,实现功能之前需先编写会失败的测试,然后以最少的代价编写具体的实现代码让测试通过,在测试通过后,再对代码进行重构,测试可能失败,或者再次尝试编写一些会失败的测试,再继续整个过程。