聊一聊契约测试
什么是契约
如果从契约产生的阶段来说,现有资料表明最早要追溯到西周时期的《周恭王三年裘卫典田契》,将契约文字刻写在器皿上,就是为了使契文中规定的内容得到多方承认、信守,万年永宝用。所以订立契约的本身,就是为了要信守,就是对诚信关系的一种确立。诚信,是我国所固有的一种优良传统,也是延续了几千年的一种民族美德,在中国儒家的思想体系里,是伦理道德内容中的一部分。
《现藏于台北故宫博物院》
现实真的是那么美好吗?小时候的价值观教育未能改变社会的现状,缺少契约精神的案例却比比皆是。
那么,契约真的要消失了吗?不尽然,在软件测试领域,我们又重新拾起了契约这把利器。
发展历程
接下来让我们把时间回溯到 2011 年初,回到老马的文章 《集成契约测试》 中来,回顾一下契约测试的起源和发展历程:
假设我们有这样一个场景:A 团队负责开发 API 服务,B 团队进行 API 调用消费服务。
为了保证 API 的正确性,我们会对外部系统的 API 进行测试(除非你 100%相信外部系统永远正确和保持不变),这很可能就会导致一个问题,当外部系统并不那么稳定或者请求时间过长时,就会导致我们的测试效率很低,并且稳定性下降。比如当外部 API 挂掉导致测试失败时,你并不能完全确信是 API 功能被更而改导致的失败还是运行环境不稳定导致的请求失败。
最初,解决这个问题的方案是构建测试替身( Test Double ),通过模拟外部 API 的响应行为来增强测试的稳定性和反应速度。实现手段是在测试环境中搭建一个模拟服务环境,通过设定一些请求参数来返回不同的响应内容,然后再被内部系统调用,来保证调用端的正确性。构建模拟环境时我们可以使用几种不同的测试手段,如 Dummy,Fake,Stubs,Spies,Mocks 等。可是,问题又来了,如果使用测试替身那如何能保证外部系统 API 变化时得到及时的响应,换句话说,当内部系统测试都通过的通过时,如何能保证真正的外部 API 没有变化?
一个比较简单的方式是部分测试使用测试替身,另外一部分测试定期调用真实的外部 API,这样既保证了测试的运行效率、调用端的准确性,又能确保当真实外部系统 API 改变时能得到反馈。
是不是到这里就皆大欢喜了呢?
如果剧情到这里就结束的话,未免太过俗套。这个方案最大的缺陷在于 API 的反应速度,真实外部 API 的反馈周期过长,如果减少真实 API 测试间隔时间就又会回到文章最开始的两难境地。
那么如何解决这个问题呢?先来让我们剖析一下前面几种解决方案的共通点。
在上面的场景中,我们都是已知外部 API 功能来编写相应的功能测试,并且使用直接调用外部 API 的方式来达到验证测试的目的,这样就不可避免的带来两个问题:
第一,服务消费方对服务提供方 API 的更改是通过对 API 的测试来感知的。
第二,直接依赖于真实 API 的测试效果受限于 API 的稳定性和反映速度。
解决方式首先是依赖关系的解耦,去掉直接对外部 API 的依赖,而是内部和外部系统都依赖于一个双方共同认可的约定—“契约”,并且约定内容的变化会被及时感知;其次,将系统之间的集成测试,转换为由契约生成的单元测试,例如通过契约描述的内容,构建测试替身。这样,依赖契约的测试效率优于集成测试,同时契约替代外部 API 成为信息变更的载体。
对于契约来讲,行业内比较成熟的解决方案是基于 YAML 标记语言的 Swagger Specification (OpenAPI Specification),或者是基于 JSON 格式的 Pact Specification 。
通常的做法是 API 的提供者使用“契约”的形式,将功能发布在公共平台,给调用方进行说明和参考,这里我们可以暂时称之为 Provider-Driven-Contract。这种做法的潜在问题是,功能提供方的 API 返回内容是否都满足所有 API 调用者的需求不得而知。所以,针对这个问题,依赖关系再一次反转,契约测试就摇身一变成为了 Consumer-Driven-Contract test(CDCT), 通过给 API 提供方提供契约的形式,来完成功能的实现。
难道 CDCT 成为了问题终结者吗?请听后面分解。
注: 契约测试其中一个的典型应用场景是内外部系统之间的测试,另一个典型的例子是 前后端分离后的 API 测试 ,这里不做过多展开。
契约测试的维度
1.测试覆盖范围对比(纵向)
单元测试:对软件中的基本组成单位的测试,大多数是方法函数的测试,运行速度快。
契约测试:对服务之间的功能进行的测试,运行速度基本与单元测试相同。
E2E 测试:对系统前后端或者不同系统之间的集成测试,大多通过模拟 UI 操作的方式实现,运行速度三者之中最慢。
2.测试效率对比(横向)
环境依赖:
- 单元测试:程序集
- 契约测试:程序集、依赖契约文件、虚拟路由服务
- 端到端测试:程序集、真实路由服务、前端 UI
- 运行速度: 单元测试 > 契约测试 > 端到端测试
Pact 官方给出的几个场景:
适用场景:
- 团队能把控开发过程中的 Consumer 和 Provider 端
- 适合 Consumer 驱动开发的场景
- 对于每个独立的 Consumer 端,Provider 端都能管理好需求。
不适用的场景:
- 公共 API 或者是 OAuth 授权服务
- Provider 端和 Consumer 端没有良好的沟通渠道
- 针对性能的测试
- Provider 端的功能性测试(Pact 只测试内容和请求格式)
- 对于不同输入有相同的输出,并未达到验证的目的
- 当前测试输入需要依赖之前测试返回的结果
以上对比说明契约测试所要解决的问题是替代系统之间的集成测试,通过契约和单元测试的方式加速系统运行。同时也说明契约测试存在一些不适用的场景,要依据使用场景区别对待。契约测试没有取代单元测试以及 E2E 测试。
契约测试与 CD 的整合
最开始,我们的 pipeline 是这样的,单元测试是独立的测试,当通过单元测试后运行集成测试。此时集成测试成为了系统瓶颈,而且一旦集成测试失败,就必须被迅速修复,其他 pipeline 只能等待其修复,否则任何新的变更都会测试失败。
一个解决办法是将集成测试分散在每个 pipeline 上,每次集成测试运行的版本是当前的最新代码和其他系统的上一次通过版本之间的测试。这样解决了测试的独立性以及不会阻碍其他 pipeline 测试的效果,然后将通过测试的不同系统的 package 按照版本保存。但是这样一来,集成测试的缺点就更为明显提现出来,第一是系统部署时间长,每次集成测试需要运行同样的测试在不同 pipeline 上,增加了测试成本和反馈周期。
接下来,我们使用契约测试替代集成测试。这样有几点好处不仅解决了独立测试的目的,同时解决了集成测试慢和部署时间长等问题。
为了保证契约测试的正确性,契约文件由 Consumer 端生成,然后 Provider 端来实现 API,我们使用 CDCT 来改造我们的 pipeline。
我们先假设 B 系统希望 A 系统提供新功能,如果按照图中黄色步骤来提交的话,则会测试失败,原因在于此时,契约文件是最新的 B-A.consumer.1.1.pact 与之对应 A-B.provider.1.0.jar 不是最新的,所以测试失败。
按照图中步骤 2 运行,当提交 A 的 pipeline 时,当前版本的 A 已经升级到 1.1,而契约文件还是 1.0 版本,没有 break 测试的情况下,最终将 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 功能不变,提交并通过测试。
- 将 Consumer 端 API 的调用指向 Provider 端的新 API,并更新契约文件以约束新功能。
- 将 Provider 端旧 API 同步更新为新 API,提交并通过测试。
- 将 Consumer 端指回旧有 API,其他保持不变。
- 将 Provider 端临时过渡的新 API 删除。
至此,我们解决了 API 更新时如何保证契约测试的提交顺序,如果是删除 API,则直接删除 Consumer 端的契约测试即可。
需要思考的问题
1.如果并行测试的话,谁先提交成功的版本,另外一个测试是否要重新运行?
设想,当两个并行 pipeline A 和 B,同时运行时,A 中跑的是 A1.1 和 B1.0,B 中跑的是 B1.1 和 A1.0 的测试,假如双方均能通过各自的测试,但是新版本不兼容(A1.1 和 B1.1 测试失败),双方都将各自的新版本保留,这样就造成了存在相互不兼容的两个版本。目前解决方案是,人为制造一个“瓶颈”,保证同时只有一个契约测试在运行,保存的只有一个版本。
2.契约测试可维护性如何?
构建契约测试类似于单元测试,并且在 Pact 的框架下十分方便维护。但是,测试框架本身还有一些问题,诸如,大小写敏感,空值验证,只有一份契约文件,契约测试分组等。
以上是基于 pact 1.0 的实践,pact2.0 使用了正则表达式以及 TypeMatching 等机制解决了验证“具体”值的问题,更多详细内容请关注 pact 官方文档
结语
契约测试不是银弹,它不是替代 E2E 测试的终结者,更不是单元测试的升级换代,它更偏向于服务和服务之间的 API 测试,通过解耦服务依赖关系和单元测试来加快测试的运行效率。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 如何读懂代码
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论