0%

微服务环境下的测试策略与落地实践

这篇文章原本是当年(具体当的哪一年,忘了…T_T)抱着学习的心态,翻译的Toby Clemson的文章《Testing Strategies in a Microservice Architecture》,后来又加入了一些自己在项目上的实践总结。最近在清理磁盘文件的时候,偶然又给刨了出来,索性就搬到博客里来吧~

如果你在Google上面搜索”microservice test strategy”,你一定会找到下面这个结果:
google-search-result
伴随着Martin Flower的大名赫然在目,但是,这篇文章的作者,不是老马,而是Toby Clemson。之所以提这个,是因为已经多次听到有小伙伴在述及这篇文章时,张口就是”Martin Fowler的那篇微服务测试策略的文章……”,所以,尊重作者,还是从记住他们的名字开始吧~


微服务作为一种新的系统架构设计风格,开始被越来越多的团队接受和应用,它在给团队带来新的开发体验的同时,也给产品的质量保证提出了新的议题,如何测试我们使用微服务构建的应用?有哪些问题是在我们测试微服务的过程中需要考虑的?等等这样的问题也开始随着微服务的普及,而成为越来越多质量保证工程师需要面临的问题。于是,我们总结并提出了一些在实际的微服务项目中有益的实践方式,它们中有的是在任何产品质量保证过程中都会涉及的内容,比如单元测试和集成测试,而有的则在微服务架构中才会突显作用的内容,比如契约测试。但无论是哪种类型的测试,我们都希望通过对微服务针对性的应用,来为我们的微服务系统应用提供最可靠的质量保证体系。

test-summary

单元测试

单元测试是对一个可测试应用的最小单元进行测试的活动。那么多小的可测单元才是最小的可测单元呢?这个问题没有统一的答案,一个函数、一个类、一个库等等都可能被选择作为一个最小的可测单元来进行单元测试。从软件开发的角度来看,这取决于软件的架构设计,如果我们在软件设计的时候就赋予了一个类甚至是一个函数高价值的功能,那么这个类或者函数就有成为单元测试被测对象的意义。而从软件测试的角度来看,在选择单元测试的被测对象时,策略上还需要考虑被测对象的可测性,看它是否可以进行单独的测试,或者存在复杂的对外依赖关系,进行单元测试的代价太高,比如需要准备过多的测试桩数据或测试桩服务等。

当一个单元具备了合理的被测价值和可接受的可测性时,我们就可以对其进行测试设计了。单就单元测试的设计而言,我们期望被测单元在大小上越小越好、在功能上越简单越好,以及在对外部依赖上越少越好,这样能更加方便我们设计可读性好、维护性强的单元测试案例。 在设计和进行单元测试的时候,根据对被测单元外部依赖的处理方式的不同,单元测试常被分成关联型的单元测试和独立型的单元测试。其中,关联型的单元测试就是将被测单元和它相关的外部依赖(其它的单元)作为一个整体,进行黑盒测试。它的优点是你不需要对复杂的依赖关系进行任何处理,通过测试可以得到关联单元之间真实的状态转换带来的被测单元的行为表现,而缺点就是当发现失效时,难于定位缺陷是来自被测单元还是外部依赖。 相对的,独立型的单元测试则是将被测单元之外的全部外部依赖使用测试替身替换,保证发现的任何失效都是来自被测单元的缺陷。独立型的单元测试因为使用了测试替身,所以它既可以测试被测单元作为服务或数据生产者的行为正确性,也可以检查被测单元作为服务或数据使用者时是否正确调用和解析了来自外部依赖的数据源。当然,在提供这些优势的同时,独立型的单元测试的缺点就是需要创建和维护测试替身,有时甚至是很复杂的测试替身。 值得一提的是,关联型的单元测试和独立型的单元测试并不是相互冲突的,实践当中,有时还会对同样的被测对象同时设计这两种测试类型。

unit-test

那么对于一个如上图的微服务架构,我们进行单元测试的一般策略可以是:

  • Domain,使用关联型的单元测试;因为领域层部分的代码通常都是进行各种状态转换和数据计算的复杂功能区,它和外部依赖的部分,比如Service Layer和Gateways都是数据强相关,对于这样的功能代码,使用测试替身的代价太高。

  • Gateways 和 Repositories,使用独立型的单元测试;这个部分的代码主要的功能是数据的传输和预处理,与之交互的数据主要都是来自外部依赖的单元,比如数据库和外部服务,对这部分单元测试的目的是验证获取的数据可以被正确的传输和解析,而不是测试和外部的交互过程,于是我们使用测试替身来模拟外部依赖的数据源可以得到更高的测试效率,另外,使用测试替身还可以帮助我们触发各种异常处理的测试场景,比如数据库返回异常的数值,这在使用外部依赖的时候是很难真实的重复测试的。

  • Service Layer,使用独立型的单元测试;这部分代码和上面的Gateways类似,它主要的功能是传输和预处理这个微服务与其调用者之间的数据,对其单元测试的目的同样是关注数据传输和处理的正确性,无论这个数据是微服务调用者发起的请求数据,还是Domain计算完成后返回的响应数据,都可以通过使用测试替身来获取很好的测试效果。

有时,一些微服务会在体量上设计得很小,只包含领域层与最简单的对外数据交互部分,比如一些功能单一却逻辑复杂的数据适配微服务,对于这样的微服务,较之单元测试,组件测试往往能得到更好的性价比。

单元测试是一项技术性比较强的测试活动,理论上,通过使用测试替身我们可以对每一个测试单元进行非常完备的测试,但那样做的代价也是很高的,无论是创建测试替身还是维护测试替身,所以在设计和进行单元测试时,应该时常审视我们的测试目的,避免进行过度的测试,使我们的单元测试套件有一个合适的体量,这既便于我们对单元测试本身进行维护,同样也保证我们的单元测试在持续集成的过程中能在较短的时间里完成执行。

最后,就像我们在任何敏捷实践中强调的一样,我们希望由开发人员来主导单元测试的编写,测试人员适度的参与评审,这样既可以较好的保证单元测试在案例场景上的质量。至于要不要做到TDD,团队应该根据自身的情况来决定。

集成测试

集成测试是测试被测对象与其他外部组件进行交互的测试活动。集成测试的时候,被测对象都是和与之相关的外部组件共同协作的,他们共同组成了一个子系统。需要注意的是,虽然集成测试会贯穿整个子系统,但我们的测试对象始终只是其中的一个组件或模块,而不是整改子系统。这其实有点类似于单元测试中的关联型测试。

集成测试是所有测试类型中的万金油,我们既能在单元测试中看到集成测试的形式,也能在系统测试或端到端测试中讨论集成测试,这都取决于我们的测试策略如何来界定和执行集成测试。在微服务的集成测试当中,对测试的粒度没有固定的要求,我们通常强调的集成测试是指在微服务与外部系统交互的层面上的测试活动,比如微服务与数据库的交互,或者是一个微服务与另一个微服务的交互等等。

integration-test

在一个类似上图的微服务当中,我们的集成测试关注的是当前这个微服务与数据库及外部服务的数据交互,我们的测试对象是Data Mapper和Gateways。这个我们在前面的单元测试部分也有提到,当时我们是选择将外部服务和数据库用测试替身进行模拟来提高测试效率,因为我们只关注外部的数据是否可以被内部的单元正确处理和传输。而对于集成测试,我们关注则应该是全部的交互场景,比如正向交互有多少场景、负向交互有多少场景,这些场景都应该体现在集成测试的测试案例当中。

  • Gateway 集成测试,我们应该测试各种协议错误,比如丢失报文头或无效的报文头,错误的SSL处理,以及各种收发消息的格式匹配问题。另外,外部服务超时无响应等异常案例也应该纳入集成测试当中。 当然,在使用真实的外部服务的时候,往往比较难实现各种负向或异常的交互场景,这时可以选择使用桩服务来进行测试,但其带来的效能开销需要被很好的权衡。 这部分测试还有一个难点是对特定数据的状态要求。比如你需要通过外部服务来获取某个数据的状态,而该状态又和时间相关,导致你在两次获取数据之间其状态就已经发生了改变,这种情况会给自动化的集成测试带来不小的麻烦。所以在策略上我们应该尽量选择或构造固定的数据来进行集成测试。

原文发表于2014年,其述及的”桩服务”、”数据状态改变”等内容,在当时,确实是集成测试中的一些难点。但随着技术的革新,目前这类问题已经有了更好解决方案,那就是服务虚拟化。至于服务虚拟化的内容,以后有时间,我再另文介绍吧,这里就不细讲了。我在今年成都的BQConf上面,正好有介绍这方面的话题,感兴趣的同学可以参考一下这里。另外,这本书《Testing Microservices with Mountebank》则更加详尽的介绍了虚拟化服务在微服务场景下的使用,值得推荐。

  • Data Mappers/ORM 集成测试,对于数据持久层的集成测试,关注的重点是获取的数据源能够被正确的映射和转换。特别是那些成熟的ORM组件,他们往往有非常复杂的数据处理逻辑,我们的集成测试应该尽量的覆盖所有可能涉及的数据操作,比如各种增删查改和断言处理等等。另外,越来越多的外部数据存储使用了网络分隔或分布式存储,那么就有可能会面临响应超时等网络异常问题,这些场景也应该在数据持久层的集成测试中予以考虑。 通过这样的集成测试,特别是持续的自动化集成测试,在对微服务的集成模块进行修改或重构时,我们就可以得到快速有效的反馈。有时,内部集成模块的功能回滚,或者外部模块的修改,都可能造成我们的集成测试的失效,而光靠集成测试是无法快速的发现造成失效的问题,这时如果需要通过测试来快速定位缺陷,就要依赖于足够的单元测试、集成测试以及必要的契约测试。

组件测试

组件测试是指在系统内部,对一部分整合在一起的具备独立功能的模块进行的测试。组件内部的模块应该是强耦合的,其本身也应该具备良好的封装性,而且可以被独立的替换。组件是模块的组合,不同的组件又共同组成了整个应用系统,组件测试的目的是测试单个特定组件的正确性,而不同组件之间往往都是协同工作才能完成各自的功能的,所以在组件测试中,常常用到测试替身模拟被测组件的外部依赖,这样既可以避免去和复杂的外部组件交互(特别是当失效发生时,难于定位缺陷的位置),又可以确保测试环境是固定和可控的,而且还便于触发各种异常的测试场景。

在微服务中,从组件的定义就可以知道每个微服务本身就是最完整的组件。但测试某一个微服务时,其它与之相关联的微服务就使用测试替身来替换,从而限定我们的测试范围和缺陷可能发生的范围都在被测的微服务当中。

对微服务的组件测试是一种动态的测试,需要在微服务的运行时进行测试。在策略上我们有许多地方需要考虑,比如测试应该和微服务运行在同一个进程当中,还是单独运行再通过网络来访问被测微服务?我们的测试替身应该建立在微服务内部,还是单独搭建再和被测微服务交互?外部数据库应该连接真实的数据库,还是只是和加载在内存中的数据库镜像进行交互?一般说来,与内存中通过测试替身实例化的外部微服务或数据存储进行交互,都能够得到较快的测试运行速度,并且减少测试系统的复杂度。但这也要求我们的被测组件有比较灵活的配置管理方式,能够通过配置来很好的在测试模式和生产模式之间切换。比如我需要被测组件根据配置文件来自动的识别应该访问测试用的内存镜像数据,还是直接访问真实的外部依赖服务。为此,我们既可以使用配置文件来手动配置,也可以使用依赖注入框架。

  • 同进程组件测试,使用同进程组件测试,就是在微服务本身的内部模块之外,构建用于测试的子模块,运行测试就会将测试数据和被测的微服务加载和运行在同一个进程当中。

component-test-1

同进程组件测试通常会自定义shim这样的套件,来给被测微服务发送请求消息和检索返回消息,当然,也有现成的工具可供使用,比如JVM的inproctester和.NET的plasma。不管是使用自定义套件还是使用现成工具,我们的目的都是替代真实的服务调用者,来和被测微服务进行尽量最多的消息交互。由于这样的交互过程完全是在内存中进行的,所以速度是远远高于真实的网络访问的。

前面我们提到,组件功能的完成往往都还需要依赖与其它组件或微服务的交互,所以我们还需要将这部分和其它组件交互的过程在同进程中实现,一般的做法就是使用内部数据源和测试桩客户端,由此,通过使用我们预定义的测试数据,不仅可以加快获取“外部”数据的速度,还可以重复、稳定的模拟外部依赖出现异常的情况,比如通讯超时、返回脏数据等等。

对于有挂载外部数据库的微服务来说,我们还可以使用内存数据存储来断开真正的数据库访问,它在提高测试性能方面的优势是不言而喻的。内存数据存储的实现,根据情况,我们既可以简单的自定义实现,也可以选择使用专门的数据存储工具,比如H2数据库引擎。需要提到的是,在使用内存数据存储的时候,我们需要足够的集成测试来确保数据持久层和真正的外部数据库交互的正确性。

最后,由于同进程组件测试隔绝了所有的外部依赖,这就保证了当需要依赖的外部组件,比如其他的微服务甚至是数据库,在进行他们自己的修改更新时,不会对我们被测微服务的组件测试造成影响,从而保证了测试的稳定性。

component-test-2

  • 异进程组件测试,相较于同进程的组件测试,异进程的组件测试将被测的组件单独的运行起来,外部依赖使用桩服务或真实的数据源来进行测试。异进程的组件测试在测试组件的基本功能的同时,还能验证和部署相关的事物,比如对配置外部依赖的配置文件的读取是否正确等。而由于把外部依赖都纳入了组件测试的边界,所以我们在执行测试的时候,就必须还要兼顾外部依赖的启动关闭、端口配置等等额外的问题。当然,在异进程的组件测试中,因为使用了通过网络访问的桩服务和真实的数据库,测试运行的速度势必会低于同进程的测试,可是,如果被测的微服务存在非常复杂的外部依赖,或者持久层和数据库的集成很繁琐,那么异进程的组件测试方式还是比较推荐的。

异进程的组件测试中,微服务本身是监听真实的服务端口,所以我们在测试其基本的消息处理功能之外,还可以验证其网络配置的正确性,这是同进程组件测试做不到的。同样的,对外部桩服务的访问,以及持久层和数据库的交互,都可以验证微服务对依赖的配置是否正确,比如是否可以正确的访问配置的URL和端口。

当然,搭建外部依赖的桩服务是不可缺少的,我们可以选择使用动态的API访问,也可以使用固定的死数据,或者使用通过记录回放机制进行的交互。比如moco,stubby4j和mountebank既可以提供动态的API访问,又可以读取固定数据,而vcr则可以提供记录回放的功能。

在有了足够的单元测试、集成测试和组件测试的基础上,我们的微服务就已经有了一定的质量保证了。但这样的质量保证只对于单个的微服务。使用微服务构建的应用不可能只是单个微服务,都是多个微服务协同工作的来实现业务价值的,所以为了保证整个应用系统的质量,我们还需要使用契约测试来时刻保证微服务之间相互契约的完备,以及使用端到端测试来保证所有微服务作为一个整体能够满足真实的业务需求。

契约测试

契约测试是用来验证服务生产者和服务消费者彼此之间契约完备正确的测试活动(这里的服务不仅限于微服务,也可以是传统的单体服务)。

什么事契约呢?当一个微服务被另一个微服务访问的时候,被访问的微服务称之为服务的“生产者”,发起访问的微服务称之为服务的“消费者”,生产者与消费者之间进行的交互就是他们两者之间的契约。比如消费者发起的请求消息,以及生产者返回的应答消息都是契约的组成部分。契约是存在于一对消费者与生产者之间的,如果有多对消费者和生产者,那么就会有多种契约的存在。比如,同一个生产者被三个不同的消费者访问,就存在三个不同的契约,类似的,同一个消费者访问三个不同的生产者,也存在三个不同的契约。

契约测试在形式上比较类似组件测试,因为它们都是通过收发消息来模拟服务的调用,但组件测试需要关注全部的调用场景,比如各种正向和负向的消息交互,而契约测试则不会深入的检测消息交互的场景,它一般只关注正向的消息结构是否完备正确,比如对于一个返回消息,契约测试关注它有多少个属性、每个属性的值是什么类型,而不会像组件测试那样去验证在什么情况下每个属性的值具体是多少。所以契约测试并不是组件测试。

一般而言,每个具体的契约测试都是由各个消费者来编写,它们只关注自己与生产者之间的契约关系,而执行这个契约测试则往往是在生产者的持续集成环境中进行的。也就是说,对于某一个服务生产者,在它的持续集成环境中可能同时运行多份和不同服务消费者之间的契约测试,其目的就是去发现每一次生产者的自身更新是否会对任何它的服务消费者造成影响。这一点对于内部由微服务构成的管道型结构的应用来说,是非常有意义的,因为其内部时常存在着复杂的相互调用关系,在这种关系下,对每个微服务的变更(无论是正确还是错误的)都有可能导致其它微服务的局部失效,致使整个系统可能会出现错综复杂的异常,而契约测试通过预警每个服务生产者的变更是否会造成已有契约的失效,来最大程度的避免了这种乱象的产生。

contract-test

我们来看一个例子,如图的一个调用关系,四个微服务,其中,一个生产者,它提供的服务消息里面包含id、name和age三条数据。三个消费者A、B、C,它们都与生产者有着各自的契约,消费者A只需要生产者提供的id和name,即为契约A;消费者B只需要生产者提供的id和age,即为契约B;消费者C则需要生产者提供的全部id,name和age,即为契约C。此时,如果生产者需要对name进行修改,将字符串改成一个包含firstname和surname的对象,那么消费者A和消费者C就很可能因为这样的修改而在解析消息的时候出现错误,但消费者B则不会受到影响。所有,通过在生产者的开发环境中进行契约测试,我们便可以在部署之前预警这样的风险。那么对于生产者来说,如果这样的修改是必须的,那么就应该告知消费者A和C在适当的时候进行相应的修改,然后更新契约测试。

在上面的列子中,如果对name的修改不是生产者计划的,而是某个消费者期望生产者改变的,那对生产者而言,这就形成了一种“消费者驱动”的设计开发模式,这种消费者驱动的模式可以更好的确保生产者的开发工作从设计上都是满足其消费者需求的,避免了不必要的开发。

目前主流的契约测试工具,主要是Pact和Spring Cloud Contract。

E2E测试

端到端的测试活动是为了验证整个系统能够满足预定的设计需求,它完全抛开系统内部任何的微服务架构,只从整体全局上去检验系统的业务价值是否得以实现。所以,在端到端的测试当中,我们进行的是对整个系统的黑盒测试,测试的方法也是遍历真实的GUI界面或者调用真实的API接口。而为了体现真正的业务价值,在端到端测试中设计测试案例往往也使用面向业务的DSL。

端到端测试除了展现整个系统的业务功能,还能确保与整个系统相关的外围环境可以正常的协作,比如防火墙、代理以及负载均衡等等问题。另外,当整个系统架构需要进行较大规模的修改,甚至重构的时候,端到端测试还能始终保证系统业务功能的正确性。

对于端到端的测试是所有测试类型中粒度最大的,我们需要模拟真实的用户场景来进行测试。对于GUI的应用,我们可以使用Selenium来进行Web的自动化测试,而对于没有GUI的单纯的API服务,我们可以使用Postman进行手动测试,或者REST Assured进行自动化测试。

测试边界对于端到端测试来说是一个需要权衡的问题,特别是在我们的应用系统存在外部依赖,而被依赖的对象又不在我们团队的控制范围之内时,是否要将外部依赖纳入我们的测试边界就值得去思考了。如果将依赖至于测试边界之外,每次测试的时候都去访问正在的依赖对象,我们就可以得到最真实的系统行为结果,但问题是这样的结果有可能不是稳定和可复现的,比如当依赖对象发生异常时,就会给端到端测试带来不小的麻烦,特别是对于运行在持续集成系统中的自动化端到端测试而言,更是如此;如果将依赖至于测试边界之内,就意味着我们需要自己搭建被依赖的桩服务,这样虽然可以保证测试的稳定性,但却给测试的真实性打上了折扣。所以如何选择针对外部依赖的端到端测试边界,是需要团队根据项目的整体开发策略,特别是风险策略来判断的。

除了测试的边界问题,端到端测试还面临一些普遍的难点,比如由系统的消息异步问题,所以我们总结了一些小的策略来帮助更好的进行端到端测试:

  • 编写尽量少的端到端测试,因为通过其它低层级的测试,我们已经对微服务有了不错的质量保证,而端到端测试只是确保系统业务功能达到设计要求,所以不需要在过多的场景上消耗时间,比如一些很少遇到的场景或者是负向的场景。一个不错的实践方式是使用“时间预算”,团队成员共同决定一个可接受的以分钟为单位的端到端测试时间长度,之后所有的端到端测试都只运行这么长的时间,如果有新的测试案例需要添加进来,那就移除一些老旧的案例,始终保证测试的总时长不变。(不要告诉我你不知道我们这里说的是’自动化的端到端测试’)

  • 测试的设计关注用户角色和场景,设计案例的时候,保证系统中每一种用户角色和他会进行的使用场景都被纳入测试范围就可以了,而其他的一些极端的场景就可以交由另外的测试去检验。

  • 合理的选择你的测试端点,就像我们在前面讨论测试边界时一样,如果你的端到端测试严重受限于GUI或外部依赖,那么就应该考虑将依赖和GUI的部分放到测试边界之外,以确保测试的稳定性。

  • 使用代码来描述和管理基础设施,雪花状的系统环境在给系统部署带来一定麻烦的同时,也给端到端测试带来一些额外的配置管理工作,特别是需要搭建一个新的测试环境时,需要顾及不少的环境部署问题,所以一个比较好的实践就是通过代码化的方式来实现全部基础环境的可重复式的构建,比如使用Ansible工具。(嗯,2014年,DevOps肯定不像现在这么深入人心~)

  • 使用独立的测试数据; 测试数据是端到端测试的一个重点问题,特别是当测试数据不稳定,或是跟随时间基进行状态变化的时候,同样的测试往往可能失效,而这种“假失效”无法反应系统的问题,却会给测试带来困惑。所以保证测试数据的独立和可重复是非常重要的,比较好的方法就是在每次测试运行时,往数据库中单独加载固定的测试数据集合。 以上的这些策略在技术实现上有一定的难度,所以有的团队会选择绕过测试环境,而直接在生产环境上进行端到端测试。这样的做法也是无可厚非的,虽然它的测试效率会受到限制,但它能够反映最真实的系统行为,甚至发现一些只有在生产环境上才可能发现的问题。

总结

summary

总的来说,微服务的环境下,以上的这些策略能够给测试哪些和怎么测试带来一些方向,虽然它们中的一些观点同样适用于传统的单体应用,但我们更希望它能给微服务的测试带来更多的针对性。当然,在实际的项目运行中,基于成本的考虑(包括人员、技术、时间等),很少有团队会实践所有上面述及的策略和方法,所以如何去制定一个最适合自己项目的微服务测试策略,永远都是留给团队自己需要回答的问题。