社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
随着企业开发模式逐渐从传统的整体式(Monolithic)产品交付,向快节奏的微服务架构迁移,软件测试人员也必须相应地调整自己的测试方法和工具,才能多快好省地提高测试覆盖率,尽早发现潜在的缺陷。在快速迭代的背景之下,依然能够满足企业对产品质量的严格要求。
本文将结合 Martin Fowler、Rick Osowski 等行业大师们关于微服务的理论观点,以及我在 DevOps、自动化测试领域所积累的经验,向大家介绍怎样快速地构建起微服务的测试流水线(Pipeline)。本文主要面向的对象为:计划或者已经采用微服务架构的开发团队和测试人员。不敢奢言面面俱到,但求以实践经验的干货为主,避免重复读者们已经熟悉的概念,让大家有所收获或启示。
坊间关于微服务的介绍已经连篇累牍,相信读者都或多或少有所了解。那么对于测试人员而言,“微服务”到底有什么特点呢?
(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)微服务通常还可以划分为更小的模块。
如下图所示,一个典型的微服务可以分为这几个模块:资源、业务逻辑、数据存储接口、外部通信接口等。
综合微服务的上述特点,对于测试提出了什么要求呢?
开发团队采用的任何测试策略,都应当力求为服务内部每个模块的完整性,以及每个模块之间、各个服务之间的交互,提供全面的测试覆盖率,同时还要保持测试的轻便快捷。
以一个常见的开发团队为例,可能同时开发多个功能模块,有不同的开发进度和交付期限,但是整个团队又必须保证在固定的时间节点(譬如每月一次、每个 Sprint 一次,甚至每天一次),持续地为用户提供可以部署、使用的产品。这意味着,过去那种等待产品经理、业务部门提供需求,开发人员进行开发,最后交给测试人员集成测试的方法,已经无法提供足够的测试粒度和足够快的响应速度。
归结起来,与基于整体式架构的传统测试方法相比,微服务架构对测试提出了以下挑战:
如何应对这些挑战,我总结了下面这三个原则:
最底层的是单元测试(Unit Test),粒度最细,速度最快,维护成本也最低。往上是针对每种服务内部的各种模块、业务流程的测试。最上面是基于前端 UI 的测试,这部分的粒度最粗,范围最大(因为会覆盖大多数服务),但是维护成本最高,因为稍微有些细微的变化就可能需要调整脚本。而且,由于基于前端,需要设置很多响应时间和等待时间,所以速度越最慢。
下面将以层次化的方式,逐一介绍在微服务架构中所采用的主要测试方法,如下图所示,它们包括:
单元测试的目的是执行软件程序中最小的可测试单元,验证其运行结果是否符合预期。
单元测试的工具有很多,例如:
其实现方法主要遵循:构建(Setup)-> 执行(Exercise)-> 验证(Verify)—> 清理(Teardown)这个过程。
定义测试边界是实现高效测试的第一步。测试的目的是为了验证边界里“黑盒”的行为是否符合预期,我们向黑盒输入数据,然后验证输出的正确性。单元测试里,黑盒指的是函数或者类的方法,目的是单独测试特定代码块的行为。但是在微服务架构中,很多时候黑盒的输出需要依赖于其他的功能或者服务,即存在外部依赖。
为了在不依赖于外部条件的情况下制造出各种输入数据,就需要使用 Stub,也叫作 Mock。这个可以使用依赖注入或方法搅拌(Swizzle)来实现。测试框架在运行被测试的函数时可以确保对底层依赖项的调用会被重定向到 Stub 上,这样单元测试就可以在没有外部服务的情况下进行,即保证了速度,又避免了网络条件的影响。创建 Stub 的工具有很多,包括 Node.js/JavaScript框架下的 sinon.js、testdouble.js 等; Python 下的 Mock 等。
重点需要提及的一点是,测试人员应当设法将单元测试的覆盖率作为一个重要的监控指标,记录并可视化。例如,Teamcity 或者 Jenkins 这样的流程化工具,支持用 dotCover 来统计流程中单元测试的覆盖率,并将结果以 TXT 报告或者 HTML 的方式显示在任务页面上。进一步也可以将覆盖率、测试结果的数据,自动输出到 SonarQube 这样的代码质量监控工具之中,以便随时检查出测试没有通过或者测试覆盖率不符合预期的情况。
高覆盖率的单元测试是保障代码质量的第一道也是最重要的关口。从分工上来说,测试人员可能不会参与单元测试的开发与维护,但是测试人员应当协助开发人员确保单元测试的部署和覆盖率,这是确保后续一系列测试手段发挥作用的前提。
在微服务架构中,集成测试的主要目的是把一些子模块组合到一起,以“子系统”的方式工作,确保它们能够以预期的方式协作,并检查不同模块之间的通信和交互,核实接口上是否存在问题。
最常见的集成测试,是检查微服务对外的模块与外部服务的通信,以及与外部数据库的交互,即下图中用黄色虚线标出的部分。
在测试与外部的通信时,注意集成测试的目的是检查通信是否通畅,而不是对外部模块做功能上的验收测试,因而只需要检查基本的“核心路径”(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 的核心思想在于是从消费者业务实现的角度出发,由消费者端定义需要的数据格式以及交互细节,生成一份契约文件。然后生产者根据契约文件来实现自己的逻辑,并在持续集成环境中持续验证该实现结果是否正确。
注意,CDC 型契约测试有几个核心原则:
在下面这个例子中,我们会通过契约测试检查消费端和服务提供方之间交互的数据包(通常以 JSON 形式存在)中,是否包含了 ID、name 和 age 这三个条目(Item),以及这三个条目的数据结构是否符合预期。
目前,契约测试最常用的工具是 Pact。 它的工作流程简单来说就是这两步:
mvn pact:verify
,然后它会自动按照契约生成接口请求并验证接口响应是否满足契约中的预期。可以看到这个过程中,在消费端不用启动服务供应端,在服务提供端不用启动消费端,却完成了与集成测试类似的验证,这是 Pack 最强大的地方,此外它还有其他一些特性:
契约测试解决了我们对微服务之间协作的测试。自动化测试的最后一步,就是所谓的端到端测试(End-to-End Test),即验证整个系统的功能能否符合用户的预期。
前面的测试大多是后端或者 API 级别的测试,但是端到端测试应当从 UI 执行,这样才能确保用户看到的界面是符合预期的。但是,正如大家都曾经遇到过的,UI 的测试往往是非常脆弱、不稳定的,往往会因为一点点 UI 的变化而失败。为了确保端到端测试起到弥补其他测试的不足,提高覆盖率,但是又不会经常误报的目的,需要注意以下几点:
UI 测试的框架和工具很多,目前对于网页测试,最为常见的是“Protractor + Selenium Server + Jasmine测试框架”这个组合,过程如下图所示。
上面我们已经介绍了对于微服务架构,主要的测试类型。那么,如何选择适合自己的测试策略呢?现在我们再回顾一下这几种测试的特点:
总而言之,从上到下,测试的粒度由细到粗。一种测试的粒度越粗,涉及的部分就越多,也就越脆弱(容易误报),执行和维护的成本就越高。
选择了测试策略以后,就可以使用 TeamCity 或者 Jenkins 这样的调度工具来建立持续集成/持续交付的流水线。一个常见的流水线可以表现为:
只有上一步成功通过,才会触发下一步操作。在单个微服务的测试完成之后,再会触发下一步、结合多个微服务的端到端测试。
上面介绍完了微服务自动化测试的各个阶段工作,最后一步是手动测试。如果有了完善的自动测试,手动确认的工作实际上可以非常简单。这一步的关键是要引入业务知识专家(Domain Expert),从用户的角度来探索产品的功能。可以借助微软 Azure 的 ApplicationInsight,或者谷歌云的 Analytics 等工具,记录下这些专家的行为,作为以后自动化测试的用例参考。
大部分开发团队在开发阶段,都会先把产品部署在本地环境中,进行各种测试。但是在最终部署到生产环境时,现在很多产品都需要发布到云端,不管是微软 Azure、谷歌云、亚马逊 AWS 还是国内的阿里云等。那么,在本地执行的测试流程、代码,能否平稳地覆盖部署到云端的产品呢?
根据我的经验,这两种测试环境的主要区别包括以下两点:
当然,云端测试也提供了很多有用的功能,譬如云服务供应商一般都提供了全面的监控、诊断工具,便于测试人员、维护人员分析运行状态和查找日志。
性能/容量测试也是微服务测试的一个不可获取的组成部分,特别是对于网页端程序,在流量极具增加时还能否保持稳定运行,是每个产品经理都需要了解的信息。
性能测试包括负荷测试、压力测试、尖峰测试、持久性测试、可扩展性测试等,它可以证明系统能否符合预期的性能指标(SLA),也可以找出系统中导致性能下降的部分。
它的总体流程包括:
目前可供选择的主要工具包括:
其中,我用的最多的是 Microsoft Visual Studio Load Tester。它完全基于 HTTP 协议,所以不需要使用浏览器。换句话说,就是和前端的所有 JS 方法都无关,它只记录 HTTP 的请求。除此之外,它和 UI 的端到端测试很接近,都是基于请求响应,从返回结果中提取验证规则,判断是否成功。它的参数化、数据源管理功能都很全面,自定义的验证规则(Validation Rule)也可以应付大多数的情况。另外,它所记录下来的脚本还可以用做手动的测试代码,这是一个额外的好处。
最后,我想谈一下我对 QA(测试人员)这个角色在新型开发架构下的演变。下面这张图显示的是开发、测试和运维之间的关系。其中的 DevOps 是目前一个很热门的概念,而 QA+Ops 构成的 TestOps,在我眼里是未来的发展方向。原因是,随着自动化的深化,产品发布频率的提升,单纯拿到产品进行手动或者自动化测试的测试人员已经无法满足企业的需要。
在传统的工作模式中,开发人员发布代码,测试人员进行测试,运维人员推广产品。这种模式的缺点是每个团队之间存在着很高的沟通成本,如下图所示。
而在未来的 TestOps 模式下,TestOps 人员要承担起测试、持续集成/交付和最终推广的职责。
这种模式对测试人员的技术水平提出了更高的要求,但是好处是非常明显的。
对于团队:
对于测试人员自己:
以上,是我目前对测试工作和测试人员职业发展的一些小想法,仅供参考。
欢迎大家随时扫描下方二维码,向我提问关于自动化测试、微服务架构和DevOps的问题。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!