1.什么是合约?从契约的阶段来看,现存资料表明,它可以追溯到西周的《周恭王三年裘卫典田契》。条例内容得到多方认同和遵守,为“万年永宝所用”。因此,签订合同的目的是为了遵守,是一种诚信关系的建立。诚信是我国固有的优良传统,是绵延千年的民族美德。在中国儒家思想体系中,它是伦理道德内容的一部分。《现藏于台北故宫博物院》现实真的那么美好吗?童年时期的价值教育未能改变社会现状,反而契约精神缺失的案例比比皆是。那么,契约真的要消失了吗?不一定,在软件测试领域,我们又拿起了契约这个武器。2.发展历程接下来,让我们回顾一下合约测试的起源和发展历程:假设我们有这样一个场景:A团队负责开发API服务,B团队调用API来消费服务。为了保证API的正确性,我们会对外部系统的API进行测试(除非你100%确定外部系统永远是正确的,不变的),当外部系统不是这样的时候可能会出现问题stable或者requests时间过长,我们的测试效率会很低,稳定性也会下降。例如,当外部API挂掉,测试失败时,你不能完全确定失败是因为API函数被改变了,还是因为运行环境不稳定导致请求失败。最初,这个问题的解决方案是构建一个测试替身(TestDouble),通过模拟外部API的响应行为来增强测试的稳定性和响应速度。实现方式是在测试环境中搭建一个模拟服务环境,通过设置一些请求参数返回不同的响应内容,然后由内部系统调用,保证调用端的正确性。我们在搭建模拟环境的时候可以使用几种不同的测试方式,比如Dummy、Fake、Stubs、Spies、Mocks等等。但是,问题又来了。如果使用测试替身,如何保证外部系统API及时变化?也就是说,当内部系统测试通过后,如何保证真正的外部API没有变化?一种比较简单的方法是一些测试使用测试替身,而另一些测试则周期性地调用真正的外部API。这样既保证了测试的运行效率,调用端的准确性,又保证了真实的外部系统API发生变化时能够得到反馈。这里的每个人都快乐吗?如果剧情就此结束,那就太俗套了。该方案最大的缺陷在于API的响应速度。真正的外部API反馈周期太长。如果减少真正的API测试间隔,又会回到文章开头的困境。那么如何解决这个问题呢?首先,让我们分析一下之前解决方案的共同点。在上面的场景中,我们都知道外部API函数来编写相应的功能测试,采用直接调用外部API的方式来达到验证测试的目的,这必然会带来两个问题:一是对服务消费者通过测试API来感知服务提供者的API。其次,直接依赖于真实API的测试效果受限于API的稳定性和反射速度。解决方案首先是解耦依赖,去掉对外部API的直接依赖。相反,内部和外部系统都依赖于一个相互约定的协议——“合同”,约定内容的变化会被及时感知;其次,将系统间的集成测试转化为契约生成的单元测试,例如利用契约描述的内容构建测试替身。这样,依赖契约的测试效率优于集成测试,契约替代了外部API作为信息变化的载体。对于合约,业界比较成熟的方案是基于YAML标记语言的SwaggerSpecification(OpenAPISpecification),或者基于JSON格式的PactSpecification。通常的做法是,API提供者以“合同”的形式将功能发布在公共平台上,供调用者解释和参考。这里我们可以暂且称之为Provider-Driven-Contract。这种做法的潜在问题是,不知道函数提供者的API返回内容是否满足所有API调用者的需求。因此,针对这个问题,再次反转依赖关系,将契约测试转化为Consumer-Driven-Contract测试(CDCT),通过向API提供者提供契约形式来实现功能。CDCT是否成为问题终结者?稍后请听分解。注:合约测试的典型应用场景之一是内外部系统之间的测试。另一个典型的例子是前后端分离后的API测试。我不会在这里展开太多。三、契约测试的维度1、测试覆盖率比较(纵向)单元测试:对软件中的基本单元的测试,大部分是方法和函数,运行速度快。合同测试:以与单元测试大致相同的速度运行的服务之间的功能测试。E2E测试:系统前后端之间或不同系统之间的集成测试,多是通过模拟UI操作来实现,运行速度是三者中最慢的。2.测试效率对比(横向)环境依赖:单元测试:组装契约测试:组装、依赖契约文件、虚拟路由服务端到端测试:组装、真实路由服务、前端UI运行速度:单元测试>contractTest>End-to-endtestPact官方给出的几种场景:适用场景:团队可以在开发过程中控制Consumer和Provider。适合Consumer驱动开发的场景,为每个独立的Consumer和Provider都做好了管理。需要。不适用场景:公共API或OAuth授权服务Provider和Consumer没有良好的沟通渠道性能测试Provider端功能测试(Pact只测试内容和请求格式)不同的输入有相同的输出,不能达到验证的目的,当前的测试输入需要依赖之前测试返回的结果。以上对比表明,契约测试要解决的问题是替代系统之间的集成测试,通过契约和单元测试加速系统运行。同时也说明合约测试中存在一些不适用的场景,应根据使用场景区别对待。合同测试不会取代单元测试和E2E测试。第四,合同测试与CD一体化。一开始,我们的流水线是这样的。单元测试为独立测试,单元测试通过后运行集成测试。这时候集成测试就成了系统瓶颈,一旦集成测试失败,必须快速修复,其他流水线只能等待修复,否则任何新的改动都会导致测试失败。一种解决方案是将集成测试分散到每个管道中,每个集成测试都在最新代码的当前版本和其他系统的最后通过版本之间运行。这样就解决了测试的独立性和不妨碍其他流水线测试的效果,然后把测试通过的不同系统的包按照版本保存下来。但是这样一来,集成测试的缺点就更加明显了。首先是系统部署耗时长,每次集成测试都需要在不同的流水线上运行相同的测试,增加了测试成本和反馈周期。接下来,我们用契约测试代替集成测试。这样有几个好处,既解决了独立测试的目的,又解决了集成测试慢、部署时间长的问题。为了保证合约测试的正确性,合约文件由Consumer端生成,然后由Provider端实现API。我们使用CDCT来改造我们的管道。我们首先假设系统B希望系统A提供新的功能。如果按照图中黄色步骤提交,则测试失败。原因是此时合约文件是最新的B-A.consumer.1.1.pact对应的A-B.provider.1.0.jar不是最新的,所以测试失败。按照图中步骤2运行。提交A的pipeline时,A当前版本已经升级到1.1,合约文件还是1.0版本。在没有断线测试的情况下,A-B.provider.1.1.jar最终提交给了服务端。然后按照图中步骤3运行,A-B.provider.1.1.jar和B-A.consumer.1.1.pact完美匹配,最后将B-A.consumer.1.1.pact提交给服务器。因此,改成CDCT后,虽然对提交顺序有一定的依赖性,但带来更多的好处是保证合约文件的生成是由调用方提议的,并且保证是最新的,以确保系统的正确性。喜欢思考的同学不难发现CDCT本身也有缺陷。举个简单的例子,当B已有合约限制了A的某个功能,当B需要A更新其API时,首先提交B的合约测试。或者把A的功能改成最新版本?事实上,两者都不可行。解决办法千变万化,就是大家熟悉的再熟悉不过的重构思路。王健总结的十六字格言:我们分五步完成API更新:Provider提交新API保证新功能,旧API功能不变,提交并通过测试。将消费者端API调用指向新的提供者端API,并更新合约文件以约束新功能。将Provider端旧API同步更新为新API,提交测试通过。将消费者端指向旧的API,并保持其他一切不变。删除Provider端的临时过渡新API。至此,我们已经解决了如何保证API更新时合约测试的提交顺序。如果删除API,只需删除消费者端的契约测试即可。五、思考问题1、并行测试时,谁先提交成功的版本,其他测试会重跑吗?想象一下,当两个并行管道A和B同时运行时,A1运行在A.1和B1.0,B运行B1.1和A1.0的测试,如果双方都能通过各自的测试,但新版本不兼容(A1.1和B1.1测试失败),双方将各自保留较新的版本,从而创建两个互不兼容的版本。目前的解决方案是人为制造一个“瓶颈”,保证同时只运行一个合约测试,并且只保存一个版本。2.合约测试的可维护性如何?构建契约测试类似于单元测试,在Pact的框架下非常容易维护。但是测试框架本身还存在一些问题,例如区分大小写、空值验证、只有一个合约文件、合约测试分组等(以上是基于pact1.0的做法,pact2.0使用了这样的机制作为正则表达式和TypeMatching来解决验证“特定”值的问题。有关更多详细信息,请参阅官方协议文档。)6.结论合同测试不是灵丹妙药。它不是替代端到端测试的终结者,也不是单元测试的升级。更偏向于服务间的API测试,通过服务依赖与单元测试的解耦来加快测试效率。【本文为专栏作者“ThoughtWorks”原创稿件,微信公众号:Thinkworker,转载请联系原作者】点此查看该作者更多好文
