伴随着互联网的快速发展,Web应用系统从面向企业内部发展到面向市场用户,业务的日趋复杂以及用户量的上升,那些曾经工作良好的单体应用开始遇到开发、测试、部署、发布各个方面的瓶颈,诸如扩展新增功能艰难
、系统庞大难以维护
、编译太耗时,发布流程太慢
等问题困扰着开发团队。
SOA的问世促使系统架构发生了跨越式的演变,它提出了面向服务的架构思想,将系统拆分成多个服务组件,并通过ESB(企业服务总线)对服务组件进行统一管理,但重量级的ESB使得自身又成为了一个瓶颈。随之而来的是近来业界流行的微服务架构,它将SOA的思想进一步升级,将系统组件化、服务化以及去中心化,强调 轻量级
、松耦合
、服务自治
、独立部署
。
微服务架构解决了单体应用的痛点,打破了SOA的瓶颈,同时也带来了很多的复杂性。部署运维方面,服务的部署、管理、监控。开发设计方面,服务的拆分、设计、编码、测试都将会变得复杂。幸运的是,容器化技术(比如无比流行的Docker)已经很大程度上帮助我们克服了环境的差异性,而一些容器编排工具诸如Kubernetes, Rancher, Docker-compose 提供了容器部署管理的解决方案。作为行业的领航者,ThoughtWorks也在极力倡导 开发、设计、部署、运维一体化 的DEVOPS文化理念,并通过丰富的咨询和交付成果来帮助企业研发团队更好地实施微服务架构的开发。
那么在编码测试方面,又有什么招来保证微服务架构下系统的质量?
本文将从开发测试的视角来探讨如何在微服务架构下通过不一样的测试策略来尽可能的保证系统的质量。
当我们的意识中只存在一样东西的时候,我们便可以不假思索的拿来就用。
在单体时代,对于开发-测试-部署,业界已经具备了一套很成熟的解决方案。基于这种方案,当一个敏捷开发的小Team开始构建一个应用之前,CI搭建的过程也会变得非常简单:CI只需要从一个代码库中去pull代码,然后编译-测试-部署,它的流程可以简化成:
在这种单线流水线模式下,如果团队的自动化实践做得很好,开发人员只需要关注自己编写代码时所编写的测试的质量和数量。整个应用的测试策略简单直接:
保证足够的单元测试的覆盖率,保持一定数量的Servcie测试,添加一些重要业务流程的E2E测试。
微服务架构是一种演进式架构,开发团队跟领域专家在一起进行业务分析(Event Storming),从而划分出独立的服务,系统一开始确定为独立服务的数量可能是几个,伴随着业务的复杂深入,会不断地衍生出新的服务。下图是一个包含了四个服务的微服务架构的系统:
微服务体系中的诸多服务不可避免跨服务调用,它们通常使用轻量级的HTTP RESTful API。那么如何保证跨服务调用的可靠性以及整个系统集成的质量?尤其是当不同服务由不同小团队负责开发和测试。
系统被拆分成独立的服务,每个服务都是一个完整的小系统,首要工作仍然是保证服务自身的业务功能的正确性。比如一个Java Web应用(Springboot),API功能以及各个Service的业务逻辑的正确性,可以通过单元测试来保证。服务细分之后从某种意义上让单元测试更加易于编写,可以借助测试替身来屏蔽掉对其他服务依赖。
Unit测试使得开发人员可以快活地活在自己的世界中,每个开发团队按照图纸造出系统的一个部件,只有当这些小部件集成在一起之后能够按照用户的期望为用户提供服务才体现出了系统业务价值。所以我们要通过系统集成测试(UI测试)来保证集成的质量。
从 测试金字塔 中可以看出,在一个系统中,UI测试是数量最少的。虽然它的业务价值最高,但它高昂的成本使得它只会覆盖业务流程复杂的业务场景。甚至,当一个微服务架构系统中服务个数量达到一定之后,很多开发团队对UI测试开始望而却步,因为在一个存在多个服务的系统中(即便单体应用系统)做集成测试,会面临诸多痛点:
这些痛点在很大程度上会削减一个开发团队的生产力,某些企业会雇一个QA进行重复的人工测试从而解放开发人员的生产力。这种措施有悖于追求卓越的理念,并没有从本质上解决系统的集成的质量问题。既然UI测试已经不适用引进了微服务架构的开发团队,要如何保证服务集成的质量,我们还需要在自动化测试道路上另辟蹊径。
系统级别的集成测试阻碍重重,我们不妨退一步思考,将集成的范围缩小 保证服务俩俩的集成的可靠性。有了这个想法,我们开始对服务俩俩配对做集成测试。测试架构演变成:
我们需要真实运行待测试的服务,并且对其他服务使用替身。不难看出这种方式存在以下问题:
虽然Pair集成测试没有从根本上解决UI测试的痛点,但它提出了 积小成多 的理念,该理念告诉我们:只要能够保证服务俩俩之间的集成是可靠的,我们就可以相信系统集成也是可靠的。
就在两年前,我在珠海出差的某项目上跟小伙伴一起尝试了一种集成测试方案。当时项目采用的是前后端分离开发,后端作为服务提供者提供RESTful API,前端作为消费者消费API。
为了保证前后端开发人员并行开展工作,我们引入了Contarct概念。前后端开发人员基于业务共同定义API协议(Contract),该协议以JSON文件存在于代码库的测试资源目录中,前端在开发过程中以JSON文件作为测试的断言依据。而后端开发人员则参照该协议内容来实现API。
基于这种方案,前后端开发人员如果都遵守了协议,联调的过程就会非常顺利。而它的优势也很明显的体现出来:
前后端本质上等价于服务提供方和服务消费方,所以该理念运用在微服务之间的集成测试中,系统的测试架构会得到进一步演进:
我么在享受着它带来的好处的同时,问题也偷偷地潜入系统中。不久后,CI就报警了:UI测试测试挂了
进行一番debug之后我们定位到了问题,解开了 按照Contract单独运行测试一切OK,为什么上集成环境就莫名其妙挂掉!
的疑惑:
// 两天前request {
method 'POST'
url '/users'
body([
name: $(regex('[a-z]{6, 20}')),
email: 'sjyuan@thoughtworks.com',
homePage: 'http://sjyuan.cc'
])
headers {
contentType('application/json')
}}
// 两天后request {
method 'POST'
url '/users'
body([
name: $(regex('[a-z]{6, 20}')),
email: 'sjyuan@thoughtworks.com',
homePage: 'http://sjyuan.cc',
gender: 'M'
])
headers {
contentType('application/json')
}}
通过Git历史记录发现服务消费方(前端)将API协议更新了,而服务提供方(后端)没有同步修改实现。
回顾一下引入Contract概念的集成测试
,之所以会出现协议的修改直到集成环境中才暴露出来,是因为缺乏自动化监控机制来提前发现问题并预警。让我们做进一步深入思考:把同一份API契约作为服务提供方和服务消费方的测试断言依据,一旦契约被一方改动,则另一方的测试便会失败。
归根结底,我们缺乏一种有效的强制约束来约束双方,马上要揭晓的消费者驱动契约测试
可以提供这种约束。
消费者驱动契约测试的流程是,消费者定义他们期望的API或消息是什么样子,这些期望即为契约,从这些契约可以生成存根,此后消费者团队可以在构建过程中重复使用它们。消费者和生产者都需要验证契约。
CDCT强调契约由消费者来驱动,并由双方共同遵守,核心是共同遵守。
那么如何保证共同遵守呢?
敏捷宣言中提到 可工作的软件 优于 面面俱到的文档
。引入Contract概念的测试会定义一个Contract文档(JSON协议文件)。对于消费方,该文档被用作测试断言依据,文档被转换成一个可工作的软件
(可执行的测试套件:修改文档会导致测试失败)。而对于服务提供方,因为测试的断言与Contract文档没有强制关联,它最多只能是一个面面俱到的文档
。
所以,只有当双方都将文档转换成可工作的软件时,文档的修改便会导致任意一方测试失败,文档才真正成为双方共同遵守的契约(可工作的软件总是可靠的,文档却有可能已经过期)。
消费者驱动契约测试中存在一个契约,双方基于契约生成可工作的测试套件:
CDCT具备了引入Contract概念集成测试
的诸多优点,并且通过可工作的测试套件保证了契约的一致性和实时性。
运筹帷幄之中,决胜千里之外。
三国明星诸葛亮负责运筹帷幄,关、张、赵等武将负责冲锋陷阵,从而决胜千里之外的硝烟战场。团队确定了测试策略之后,应当交由优秀工具来实施执行。
关于单元测试,业界已经有非常优秀的测试工具和框架,比如我们正在做的Springboot应用,JUnit
, Mockito
, JMock
, Hamcrest
等都是测试工具箱里的明星。对于CDCT,目前比较流行的有JVM框架 Spring cloud Contract,以及支持多语言的 Pact。
如果团队正在开发一个Springboot应用,Spring cloud Contract 是一个不错的选择。它使用Groovy DSL定义测试契约并生成测试套件,测试套件去验证服务提供方是否满足契约,测试通过之后会生成一个jar文件,该jar文件随后会作为一个可运行的Stub server,消费方基于Stub server编写测试,从而验证功能是否满足契约:
在CDCT中,不管是测试生产者还是测试消费者,都需要引入一种快速失败方法。即如果任何一方违反了契约,最好在构建的第一分钟就失败,而不是等到2小时之后的集成测试中失败。所以,我们需要将CDCT作为构建Pipeline中的一个Stage集成到CI中。
代价高昂的UI测试使得开发团队逐渐对它失去了信心,尤其引入了微服务架构,它所带来的复杂性使得业界摒弃UI测试的呼声高涨。早在2009年,著名的敏捷和TDD专家J.B. Rainsberger在InfoQ上提出 Integration Tests Are a Scam。
集成测试是一个骗局,你可能需要编写2-5%集成测试来做一个E2E的测试,但它们可能到处在重复单元测,另外集成测试存在彼此重复。更糟糕的是,当集成测试失败时,你不知道哪里出了问题,不能及时准确定位问题。
J.B. Rainsberger后来还在博客上发表了 《Integration Tests Are a Scam》,文章借用强有力的数据分析来证实自己的观点。他提出的最佳实践是:用契约测试或协议测试来做集成测试!
Martin Fowller 在2012年的 测试金字塔理论 中也指出:
应该引入面向应用程序服务层的中间层测试,这些测试既保持了端到端测试的诸多优势,又避免了许多与UI框架相关的复杂性。在Web应用程序中,中间层测试相当于API层测试,而位于金字塔顶层的UI测试则相当于Selenium测试。
ThoughtWorks技术雷达 于2016年已经正式采纳消费者驱动契约测试。
We’ve decided to bring consumer-driven contract testing back from the archive for this edition even though we had allowed it to fade in the past.
微服务架构的盛行促使越来越多的开发团队开始引入CDCT,逐渐淡化UI测试。团队的测试策略正在发生不同的演变:
引入了CDCT并摆出了正确的姿势,便可大大弱化UI测试,甚至可以使用少量的人工测试来代替自动化UI测试。CDCT帮助我们缓解了UI测试的痛点,但也要当心走极端,譬如有些团队的测试策略发生了下面的极端情况:
软件工程曾经从未产出银弹,相信未来也不会,一种新的方案的诞生只是解决了已有方案的痛点,好比微服务架构解决了单体的那些痛点之后,却又带来了足够的复杂性,从而对团队自身的能力提出了挑战。在选择测试策略的时候可以参考以下几条原则:
其中第二条原则强调:如果一个高层测试失败了,不仅仅表明功能代码中存在bug,还意味着单元测试的欠缺。因此,无论何时修复失败的端到端测试,都应该同时添加相应的单元测试。
微服务架构的复杂度不仅体现在技术上,与之相辅相成的是系统的业务架构,而技术架构总是服务于业务架构。优秀的测试策略和工程技术实践让我们更好地构建复杂的架构体系并克服它所带来的挑战,而最终决定一个系统成功与否在于人。所以,团队中每一个人应该保持Open的心态,持续学习,提升自己的高度(技能和业务),掌握实施微服务的相关技能,比如利用DDD去做服务的划分,从而能够更好的驾驭微服务架构。