微服务架构下的软件测试实践

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_41978708/article/details/80025231

随着企业开发模式逐渐从传统的整体式(Monolithic)产品交付,向快节奏的微服务架构迁移,软件测试人员也必须相应地调整自己的测试方法和工具,才能多快好省地提高测试覆盖率,尽早发现潜在的缺陷。在快速迭代的背景之下,依然能够满足企业对产品质量的严格要求。

本文将结合 Martin Fowler、Rick Osowski 等行业大师们关于微服务的理论观点,以及我在 DevOps、自动化测试领域所积累的经验,向大家介绍怎样快速地构建起微服务的测试流水线(Pipeline)。本文主要面向的对象为:计划或者已经采用微服务架构的开发团队和测试人员。不敢奢言面面俱到,但求以实践经验的干货为主,避免重复读者们已经熟悉的概念,让大家有所收获或启示。

当我们提到微服务的时候,我们在说什么?

坊间关于微服务的介绍已经连篇累牍,相信读者都或多或少有所了解。那么对于测试人员而言,“微服务”到底有什么特点呢?

enter image description here

(1) 每个服务承担一定的职责:“尽可能小,但是又达到必要的规模。(as small as possible but as big as necessary)” 。

在问答网站 Quora 上,有一个著名的问题:什么是程序员觉得最浪费时间的事情?排名第一的回答中提到:“不必要的微服务。” 这句话揭示了企业在转向微服务架构时经常走入的误区。“微”固然重要,但是首要的是提供“服务”,这才构成“微服务”的价值。盲目地切分功能(Feature),却没有起到解耦合的作用,只是会增加维护、测试的成本。毕竟,多一项服务,就会多出一系列的流水线和测试要求。

(2)微服务之间通常通过 Rest over HTTP 连接。

最常见的连接/交互方式,即通过 POST、GET、PUT、DELETE 这些命令操作 API,通过 JSON 传递参数。这种简易、明确的交互方式为契约测试(Contract Test)提供了基础,本文《契约测试入门》小节将详细介绍。

(3)每种服务不一定提供用户界面。

这意味着每种服务的测试,并不一定能够或者需要从 UI 完成。这对 API 级别的集成化测试提出了要求,详见本文《了解集成测试》小节。

(4)微服务通常还可以划分为更小的模块。

如下图所示,一个典型的微服务可以分为这几个模块:资源、业务逻辑、数据存储接口、外部通信接口等。

enter image description here

微服务架构对于软件测试意味着什么?

综合微服务的上述特点,对于测试提出了什么要求呢?

开发团队采用的任何测试策略,都应当力求为服务内部每个模块的完整性,以及每个模块之间、各个服务之间的交互,提供全面的测试覆盖率,同时还要保持测试的轻便快捷。

以一个常见的开发团队为例,可能同时开发多个功能模块,有不同的开发进度和交付期限,但是整个团队又必须保证在固定的时间节点(譬如每月一次、每个 Sprint 一次,甚至每天一次),持续地为用户提供可以部署、使用的产品。这意味着,过去那种等待产品经理、业务部门提供需求,开发人员进行开发,最后交给测试人员集成测试的方法,已经无法提供足够的测试粒度和足够快的响应速度。

归结起来,与基于整体式架构的传统测试方法相比,微服务架构对测试提出了以下挑战:

  • 服务/模块/层次(layer)之间存在复杂的依赖性:这意味着,如果想单独测试某一个服务,或者服务中的某个模块,就必须剥离它们对于其他环节的依赖关系。这需要通过 mock 等方式来实现,具体请见下文。
  • 不同的服务可能会在不同的环境/设置下运行:特别是一些后端服务,与前端服务的运行环境可能截然不同。这时在考虑对每种服务设立自动化管线时,就必须有针对性的设置相应的环境配置。
  • 涉及多个服务的 UI 端到端测试(End-to-End 测试,简称 E2E 测试)非常容易出错:因为每种服务的开发进度不同,集成不同服务的端到端测试往往会因为某一个服务的微小改动而出错。这种出错是测试人员希望避免的干扰信息。这意味着,对端到端测试的设计,必须采取一定的防干扰、防误报策略,详见本文《端到端测试的优化策略》小节。
  • 测试结果可能取决于网络的稳定性:特别是涉及到数据存储和外部通信的部分,如果在测试中不摆脱这些因素的影响,就可能会得到一些随机性的误报,干扰测试结果。
  • 与交付周期不同的开发团队之间的交流成本:这一点虽然跟技术无关,但是实际上会对测试人员的工作造成很大的困扰。因为开发模式分解为负责不同服务的多个小组,测试人员往往每天要花费大量的时间,了解不同团队的开发进度。如果还需要手动进行回归测试(Regression Test),最终将会不堪重负。所以自动化是必须采取的手段和方向

如何应对这些挑战,我总结了下面这三个原则:

  • 自动化:测试任务的增加,要求测试人员必须把主要的精力用于将测试自动化,摆脱手动测试带来的沉重负担。当然,自动化测试必须足够稳定、稳健,不能动辄误报,否则反而会导致很高的维护成本。
  • 层次化:这意味着采用分层次的测试方法,粒度由细到粗,范围由小到大。下面这幅度说明了几个主要层次之间的关系:

enter image description here

最底层的是单元测试(Unit Test),粒度最细,速度最快,维护成本也最低。往上是针对每种服务内部的各种模块、业务流程的测试。最上面是基于前端 UI 的测试,这部分的粒度最粗,范围最大(因为会覆盖大多数服务),但是维护成本最高,因为稍微有些细微的变化就可能需要调整脚本。而且,由于基于前端,需要设置很多响应时间和等待时间,所以速度越最慢。

  • 可视化:为了降低交流成本,最好的办法就是让所有的测试结果可视化。这意味着将构建(Build)、测试(Test)、部署(Deploy)所有这些相关任务构建在一个流水线之中,让所有团队成员都可以随时监控项目进度,找到阻碍项目的瓶颈。本文《揭开测试流水线的奥秘》小节将会详细介绍如何建立这样的流水线。

下面将以层次化的方式,逐一介绍在微服务架构中所采用的主要测试方法,如下图所示,它们包括:

  • 单元测试(Unit Test)
  • 集成测试(Integration Test)
  • 组件测试 (Component Test)
  • 端到端测试(End-to-end Test)
  • 探索测试( Exploratory Test,即手动测试)

enter image description here

怎么针对微服务架构做单元测试?

单元测试的目的是执行软件程序中最小的可测试单元,验证其运行结果是否符合预期。

单元测试的工具有很多,例如:

  • C++:Googletest、GMock
  • Java:Junit、TestNG、Mockito、PowerMock
  • JavaScript:Qunit、Jasmine
  • Python:unittest
  • Lua:luaunit

其实现方法主要遵循:构建(Setup)-> 执行(Exercise)-> 验证(Verify)—> 清理(Teardown)这个过程。

定义测试边界是实现高效测试的第一步。测试的目的是为了验证边界里“黑盒”的行为是否符合预期,我们向黑盒输入数据,然后验证输出的正确性。单元测试里,黑盒指的是函数或者类的方法,目的是单独测试特定代码块的行为。但是在微服务架构中,很多时候黑盒的输出需要依赖于其他的功能或者服务,即存在外部依赖。

为了在不依赖于外部条件的情况下制造出各种输入数据,就需要使用 Stub,也叫作 Mock。这个可以使用依赖注入或方法搅拌(Swizzle)来实现。测试框架在运行被测试的函数时可以确保对底层依赖项的调用会被重定向到 Stub 上,这样单元测试就可以在没有外部服务的情况下进行,即保证了速度,又避免了网络条件的影响。创建 Stub 的工具有很多,包括 Node.js/JavaScript框架下的 sinon.js、testdouble.js 等; Python 下的 Mock 等。

重点需要提及的一点是,测试人员应当设法将单元测试的覆盖率作为一个重要的监控指标,记录并可视化。例如,Teamcity 或者 Jenkins 这样的流程化工具,支持用 dotCover 来统计流程中单元测试的覆盖率,并将结果以 TXT 报告或者 HTML 的方式显示在任务页面上。进一步也可以将覆盖率、测试结果的数据,自动输出到 SonarQube 这样的代码质量监控工具之中,以便随时检查出测试没有通过或者测试覆盖率不符合预期的情况。

enter image description here

高覆盖率的单元测试是保障代码质量的第一道也是最重要的关口。从分工上来说,测试人员可能不会参与单元测试的开发与维护,但是测试人员应当协助开发人员确保单元测试的部署和覆盖率,这是确保后续一系列测试手段发挥作用的前提。

了解集成测试

在微服务架构中,集成测试的主要目的是把一些子模块组合到一起,以“子系统”的方式工作,确保它们能够以预期的方式协作,并检查不同模块之间的通信和交互,核实接口上是否存在问题。

最常见的集成测试,是检查微服务对外的模块与外部服务的通信,以及与外部数据库的交互,即下图中用黄色虚线标出的部分。

enter image description here

在测试与外部的通信时,注意集成测试的目的是检查通信是否通畅,而不是对外部模块做功能上的验收测试,因而只需要检查基本的“核心路径”(Critical Path)即可。这种测试有助于发现任何协议层次的错误,例如丢失 HTTP 报头、SSL 使用错误,以及请求/响应不匹配等情况。

另外,因为大部分集成测试都涉及到网络连接,所以必须确认服务或者模块能够妥善地处理网络故障等情况。如果需要测试模块在外部服务进入特殊状态时的行为,也可以采用上文所述的 Stub,来模拟外部服务的状态,例如响应超时。测试与数据库的链接可以确保微服务所使用的数据模式(Scheme)符合数据库中的定义。

在单元测试通过的基础上,集成测试进一步完善了我们的测试覆盖率,即让我们知道:不仅微服务内的模块可以正常工作(根据单元测试的结果),而且这些模块也可以正常地组合到一起发挥作用,并与外部进行通信和交互。

下一步,我们需要了解单个微服务能否正常工作,这就引出了“组件测试” (Component Test)。

组件测试详解

这里说的组件,是指一个大型系统中,某一个可以独立工作的、包装完整的组成部分。在微服务架构中,组件实际上就代表着微服务本身。

这个测试的实质,就是把一项微服务周边依赖的所有其他服务或者资源全部模拟化,从该服务外部“用户”的角度来检查服务能否提供预期的输出。

为了将这些依赖关系模拟化,通常有两种方式,一种是把所有服务和调用关系都放在一个进程之中,再使用 Inproctester(用于 Java 环境)和 Plasma(用于 .NET 环境)等工具模拟依赖关系。这样做的好处是降低复杂性,缺点是需要更改生产代码。另外一种方法则是将模拟的依赖关系放在微服务进程之外,使用真实的网络连接调用为服务的对外 API。好处是适用于高度复杂的微服务,缺点是依赖关系的模拟难度大大提高。可以选用的工具包括 moco、stubby4j 和 Mountebank 等。

以 Mountebank 为例,它可以模拟出一个虚拟的 API,供微服务调用。例如针对下面这段数据:

    {
      "port": 4545,
      "protocol": "http",
      "stubs": [{
          "responses": [{
            "is": {
              "statusCode": 200,
              "headers": {
                "Content-Type": "application/json"
              },
              "body": ["Australia", "Brazil", "Canada", "Chile", "China", "Ecuador", "Germany", "India", "Italy", "Singapore", "South Africa", "Spain", "Turkey", "UK", "US Central", "US East", "US West"]
            }
          }],
          "predicates": [{
            "equals": {
              "path": "/country",
              "method": "GET"
            }
          }]
        }, {
          "responses": [{
            "is": {
              "statusCode": 400,
              "body": {
                "code": "bad-request",
                "message": "Bad Request"
              }
            }
          }]
        }]
    }

写一个简短的脚本,就能在浏览器中输入地址:http://localhost:2525/country 时返回一个列表。

    #!/bin/sh
    set -e
    RUN_RESULT=$(docker ps | grep hasanozgan/mountebank | wc -l)
    MOUNTEBANK_URI=http://localhost:2525
    BANK_IS_OPEN=1

    if [ "$RUN_RESULT" -eq 0 ]; then
      docker run -p 2525:2525 -p 4545:4545 -d hasanozgan/mountebank
    fi

    curl $MOUNTEBANK_URI/imposters || BANK_IS_OPEN=0
    if [ $BANK_IS_OPEN -eq 1 ]; then
      break
    fi

    curl -X DELETE $MOUNTEBANK_URI/imposters/4545
    curl -X POST -H 'Content-Type: application/json' -d @stubs.json $MOUNTEBANK_URI/imposters

至此,我们完成了对服务本身的各项测试。接下来,我们怎么确保不同的服务之间都能够正常地协作呢?这就要引入契约测试(Contract Test)的概念。

契约测试入门

契约测试 ,通常又被称为消费者驱动的契约测试(Consumer-Driven Contract Test,简称 CDC)。我们可以将服务分为消费者端和生产者端,而 CDC 的核心思想在于是从消费者业务实现的角度出发,由消费者端定义需要的数据格式以及交互细节,生成一份契约文件。然后生产者根据契约文件来实现自己的逻辑,并在持续集成环境中持续验证该实现结果是否正确。

enter image description here

注意,CDC 型契约测试有几个核心原则:

  1. CDC 是以消费者提出接口契约,交由服务提供方实现,并以测试用例对契约进行产生约束,所以服务提供方在满足测试用例的情况下,可以自行更改接口或架构实现而不影响消费者。
  2. CDC 是一种针对外部服务接口进行的测试,它能够验证服务是否满足消费方期待的契约。它的本质是从利益相关者的目标和动机出发,最大限度地满足需求方的业务价值实现。
  3. 契约测试不是组件测试,它们并不需要深入地检查服务的功能,而是只检查服务请求的输入、输出是否包含了必要的数据结构和属性,以及响应延时、速度等是否在预期的范围之内。

在下面这个例子中,我们会通过契约测试检查消费端和服务提供方之间交互的数据包(通常以 JSON 形式存在)中,是否包含了 ID、name 和 age 这三个条目(Item),以及这三个条目的数据结构是否符合预期。

enter image description here

目前,契约测试最常用的工具是 Pact。 它的工作流程简单来说就是这两步:

  1. 在消费端写一个对接口发送请求的单元测试,在运行这个单元测试的时候,Pact 会将服务提供者自动用一个 MockService 代替,并自动生成契约文件,这个契约文件是 JSON 形式存在。
  2. 在服务供应端做契约验证测试,将供应端服务启动之后,通过 Pact 插件可以运行一个命令,例如如果是用 Maven,就是 mvn pact:verify,然后它会自动按照契约生成接口请求并验证接口响应是否满足契约中的预期。

可以看到这个过程中,在消费端不用启动服务供应端,在服务提供端不用启动消费端,却完成了与集成测试类似的验证,这是 Pack 最强大的地方,此外它还有其他一些特性:

  • 测试解耦,就是服务消费端与提供端之间解耦(Decoupling),甚至可以在没有提供者实现的情况下开始消费端的测试。
  • 一致性,通过测试保证契约与现实是一致性的。
  • 测试前移,可以在开发阶段运行,并作为 CI 的一部分,甚至在开发本地就可以去做,而且可以看到一条命令就可以完成,便于尽早发现问题,降低解决问题的成本。
  • Pact 提供的 Pact Broker 可以自动生成一个服务调用关系图,为团队提供了全局的服务依赖关系图。
  • Pact 提供 Pact Broker 这个工具来完成契约文件管理,使用 Pact Broker 后,契约上传与验证都可以通过命令完成,且契约文件可以制定版本。
  • 使用 Pact 这类框架,能有效帮助团队降低服务间的集成测试成本,尽早验证当提供者接口被修改时,是否破坏了消费端预期的数据格式。
  • Pact 目前仅支持 REST HTTP 通信,但对于 RPC 的通信机制暂不支持。

端到端测试的优化策略

契约测试解决了我们对微服务之间协作的测试。自动化测试的最后一步,就是所谓的端到端测试(End-to-End Test),即验证整个系统的功能能否符合用户的预期。

前面的测试大多是后端或者 API 级别的测试,但是端到端测试应当从 UI 执行,这样才能确保用户看到的界面是符合预期的。但是,正如大家都曾经遇到过的,UI 的测试往往是非常脆弱、不稳定的,往往会因为一点点 UI 的变化而失败。为了确保端到端测试起到弥补其他测试的不足,提高覆盖率,但是又不会经常误报的目的,需要注意以下几点:

  1. 端到端测试应当尽量简洁。“简洁”的意思是说,它应当覆盖用户使用功能的核心路径,但是不需要覆盖太多的分支路径。力求 UI 测试的轻量化,才能降低维护成本。否则,整个测试团队就会陷入更新前端脚本的泥潭之中。
  2. 谨慎地选择测试范围。如果某个特定的外部服务或者界面很容易导致测试随机出错,那么可以考虑将这些不确定性排除到端到端测试之外,再通过其他形式的测试加以弥补。
  3. 通过“自动化部署”(Infrastructure-as-code)来提高测试环境的可重复性。在测试不同版本或者不同分支的产品时,自动化测试往往会因为测试环境的不同给出不同的测试结果。这要求环境必须具备可重复性,解决的途径就是通过脚本进行自动化部署,避免手动部署的影响。
  4. 尽可能摆脱数据对于测试的影响。端到端测试的一个常见难题就是怎么管理测试数据。有些团队选择导入已有数据,以加快测试速度,避免了新建数据的时间,但是随着生产代码的变化,这些预先准备的数据必须要随之变化,否则就可能导致测试失败。为此,笔者比较倾向于在测试过程中新建数据,虽然花些时间,但是这样避免了数据维护的成本,也保证了对用户行为的全面测试。

UI 测试的框架和工具很多,目前对于网页测试,最为常见的是“Protractor + Selenium Server + Jasmine测试框架”这个组合,过程如下图所示。

UI测试流程

揭开测试流水线的奥秘

上面我们已经介绍了对于微服务架构,主要的测试类型。那么,如何选择适合自己的测试策略呢?现在我们再回顾一下这几种测试的特点:

  • 单元测试:对生产代码中最小的可测试片段进行检查,判断其是否符合预期。
  • 集成测试:检查模块的组合能否发挥作用,以及模块和外部服务、资源的通信是否正常。
  • 组件测试:以单个微服务作为对象,通过内部接口和外部模拟,将微服务与外界隔离开,测试其功能。
  • 契约测试:在各个微服务之间的接口上,检查它们的交互是否符合预期标准。
  • 端到端测试:从整个产品/系统的角度,进行端到端的检查,判断是否符合外部要求和达到其目标。

总而言之,从上到下,测试的粒度由细到粗。一种测试的粒度越粗,涉及的部分就越多,也就越脆弱(容易误报),执行和维护的成本就越高。

选择了测试策略以后,就可以使用 TeamCity 或者 Jenkins 这样的调度工具来建立持续集成/持续交付的流水线。一个常见的流水线可以表现为:

enter image description here

只有上一步成功通过,才会触发下一步操作。在单个微服务的测试完成之后,再会触发下一步、结合多个微服务的端到端测试。

上面介绍完了微服务自动化测试的各个阶段工作,最后一步是手动测试。如果有了完善的自动测试,手动确认的工作实际上可以非常简单。这一步的关键是要引入业务知识专家(Domain Expert),从用户的角度来探索产品的功能。可以借助微软 Azure 的 ApplicationInsight,或者谷歌云的 Analytics 等工具,记录下这些专家的行为,作为以后自动化测试的用例参考。

云端测试与本地测试的不同

大部分开发团队在开发阶段,都会先把产品部署在本地环境中,进行各种测试。但是在最终部署到生产环境时,现在很多产品都需要发布到云端,不管是微软 Azure、谷歌云、亚马逊 AWS 还是国内的阿里云等。那么,在本地执行的测试流程、代码,能否平稳地覆盖部署到云端的产品呢?

根据我的经验,这两种测试环境的主要区别包括以下两点:

  • 登录机制:在本地环境中,因为大部分都是位于企业网络内部,所以登录机制可能较为简单。但是在公共云环境中,处于安全考虑,云服务供应商都提供了一系列的登录机制,这可能会使得本地的测试代码失效。针对这种方式的不同,就需要开发人员在开发阶段就考虑到云端测试的需要,提供一定的 API 级访问方式。如果是前端的 UI 测试,一般可以直接通过鼠标点击、输入账号的方式直接进入程序界面,但是这面临着是否需要在测试代码中写入登录密码的安全问题。
  • 网络状态:在本地企业网络中,网络条件是非常可以预期的,但是在公有云中,网络和虚拟机的配置往往是存在一定不确定性的。这意味着测试可能会因为一些未知因素而失败。这意味着在本地进行测试时,也要模拟出一定的网络故障、配置错误,检查生产程序对于这些情况的处理。

当然,云端测试也提供了很多有用的功能,譬如云服务供应商一般都提供了全面的监控、诊断工具,便于测试人员、维护人员分析运行状态和查找日志。

性能测试/容量测试的工具选择

性能/容量测试也是微服务测试的一个不可获取的组成部分,特别是对于网页端程序,在流量极具增加时还能否保持稳定运行,是每个产品经理都需要了解的信息。

性能测试包括负荷测试、压力测试、尖峰测试、持久性测试、可扩展性测试等,它可以证明系统能否符合预期的性能指标(SLA),也可以找出系统中导致性能下降的部分。

它的总体流程包括:

  • 确定测试环境
  • 确定性能验收标准(SLA)
  • 计划和设计测试方案
  • 配置测试环境
  • 部署测试方案
  • 执行测试
  • 分析测试结果

目前可供选择的主要工具包括:

  • Microsoft Visual Studio Load Testing
  • HP LoadRunner
  • Neo Load
  • Apache JMeter
  • Rational Performance Monitor
  • Silk Performer
  • Gatling

其中,我用的最多的是 Microsoft Visual Studio Load Tester。它完全基于 HTTP 协议,所以不需要使用浏览器。换句话说,就是和前端的所有 JS 方法都无关,它只记录 HTTP 的请求。除此之外,它和 UI 的端到端测试很接近,都是基于请求响应,从返回结果中提取验证规则,判断是否成功。它的参数化、数据源管理功能都很全面,自定义的验证规则(Validation Rule)也可以应付大多数的情况。另外,它所记录下来的脚本还可以用做手动的测试代码,这是一个额外的好处。

测试人员在新时代的角色演变

最后,我想谈一下我对 QA(测试人员)这个角色在新型开发架构下的演变。下面这张图显示的是开发、测试和运维之间的关系。其中的 DevOps 是目前一个很热门的概念,而 QA+Ops 构成的 TestOps,在我眼里是未来的发展方向。原因是,随着自动化的深化,产品发布频率的提升,单纯拿到产品进行手动或者自动化测试的测试人员已经无法满足企业的需要。

enter image description here

在传统的工作模式中,开发人员发布代码,测试人员进行测试,运维人员推广产品。这种模式的缺点是每个团队之间存在着很高的沟通成本,如下图所示。

enter image description here

而在未来的 TestOps 模式下,TestOps 人员要承担起测试、持续集成/交付和最终推广的职责。

enter image description here

这种模式对测试人员的技术水平提出了更高的要求,但是好处是非常明显的。

对于团队:

  • 促进了合作,降低了交流成本;
  • 更加有效地控制持续交付生命周期;
  • 高质量的持续集成。

对于测试人员自己:

  • 可以掌握运维的技能;
  • 必须利用自动化测试来实现持续交付;
  • 主动控制开发生命周期,在整个团队中有很大的发言权。

以上,是我目前对测试工作和测试人员职业发展的一些小想法,仅供参考。

欢迎大家随时扫描下方二维码,向我提问关于自动化测试、微服务架构和DevOps的问题。


参考文献

  1. Martin Fowler: Microservice Architecture.
展开阅读全文

没有更多推荐了,返回首页