随着行业主流开发方式从传统的整体式产品交付,向快节奏的微服务架构迁移,软件测试人员也要相应地调整自己的测试方法和工具,才能多快好省地提高测试覆盖率,尽早发现潜在的缺陷,在快速迭代的背景之下,确保所有微服务满足企业的质量要求。
目前有很多关于微服务架构测试方法的文章,但都是从某个角度切入,缺乏从零开始逐步深入讲解的系统化课程。本达人课共计10篇文章,结合作者的生产实战经验,由浅入深,层层剖析,帮助读者从零开始,掌握微服务架构下进行软件测试的主要方法、技巧和工具,能够高效地运用到自己的实际开发项目之中。
本课程主要分为三大部分:
第一部分(第01-02课)将带领大家初步认识微服务架构,包括它的主要特征和目前的主流部署方式,尤其是它对于软件测试所带来的新挑战和要求。
第二部分(第03-09课)将通过实战开发,循序渐进地带您掌握微服务架构下的软件测试流水线的所有组成部分,全方位提升您的技术实力和思维方式。
第三部分(第10课)则会从行业发展趋势的角度,指引您在微服务架构逐渐成为主流的情况下,如何准确地定位测试工作的角色,适应行业的演变。
Frank,在跨国公司的软件开发部门深耕多年,经历各个岗位磨练,擅长 DevOps、自动化测试、持续集成/交付和前端开发。曾经云游四海,如今埋首于宇宙中心,用心打造产品,也乐于分享自己的所知所得。
微服务的前身是 Peter Rodgers 博士在 2005 年度云端运算博览会上提出的微 Web 服务 (Micro-Web-Service) 。微软的 Juval Löwy 随后也提出了类似的想法,并提议将其作为微软下一阶段最主要的软件架构。
2014年,Martin Fowler 与 James Lewis 共同提出了微服务的概念,给出了微服务的具体定义:从本质上来说,微服务是一种架构模式。它是面向服务型架构(SOA)的一种变体,提倡将单一应用程序划分成一组小的服务,服务之间互相协调、互相配合,为用户提供最终价值。每个服务运行在其独立的进程中,服务与服务之间采用轻量级的通信机制互相沟通(通常是基于 HTTP 的 RESTful API)。每个服务都围绕着具体业务进行构建,并且能够被独立地部署到生产环境、类生产环境等。另外,应尽量避免统一的、集中式的服务管理机制,对具体的一个服务而言,应根据业务上下文,选择合适的语言、工具对其进行构建。
Martin Fowler 是国际著名的软件专家,敏捷开发方法的创始人之一,现为 ThoughtWorks 公司的首席科学家。在面向对象分析设计、UML、模式、软件开发方法学、XP、重构等方面,都扮演着举足轻重的开创者角色。早在20世纪80年代,Fowler 就是使用对象技术构建多层企业应用的倡导者,他著有几本经典书籍: 《企业应用架构模式》、《UML精粹》和《重构》等。
与微服务架构相对应,传统开发方式通常被称为单体式架构(Monolithic Architecture)。所有功能都打包在一起,基本没有外部依赖,其中包含了数据输入/输出、数据处理、业务实现、错误处理、前端显示等所有逻辑。
下图显示的一个典型的单体式架构示意图:
这种架构有其优点,包括:
但是,随着现代应用程序的日益复杂化,加上对于迭代速度的要求越来越高,这种架构的不足开始暴露出来:
如何解决这些问题?微服务架构逐渐浮出水面。从软件开发的组织上来说,它的核心理念是按照业务边界把整个系统划分为若干个“子系统”。每个子系统的开发团队之间,保持着合作(Inter-Operate)而不是整合(Intergrate)的关系。定义好每个子系统的边界和接口,在一个团队内自治。团队按照这样的方式组建,沟通的成本维持在系统内部,每个子系统就会更加内聚,彼此的依赖耦合能变弱,跨系统的沟通成本也就能降低。
这里不得不提及著名的“康威定律”(Conway's Law),这是微服务架构的一个核心理念。
Melvin Conway 在1967年提出了这个理念,原文是:“Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations.”
用简单的话来说,就是组织形式等同于系统设计,组织的沟通方式会通过系统设计表现出来。下面这幅著名的软件企业组织图,与这些企业的产品架构有着异曲同工的对应关系。
再以上面提到的单体式 App 为例,通过用微服务架构方式对其进行改造,将会变成下面这种结构:
除了解决单体式架构的几个缺陷以外,微服务架构还具有下面这些优点:
当然也需要提到,微服务架构也存在着它的不足:
DevOps(Development 和 Operations 的组合词)是一种重视软件开发人员和 IT 运维技术人员之间沟通合作的文化、流程或者实践方式。透过完全自动的“软件交付”和“架构变更”流程,使得构建、测试、发布软件更加快捷、频繁和可靠。本课程将不单独介绍这方面的内容,以后可能会单独开课。
介绍完微服务架构以后,回到本课程的主题上来:对于测试人员而言,微服务架构到底有什么特点呢?我把它归结为以下几点:
1.每个服务承担一定的职责:“尽可能小,但是又达到必要的规模(as small as possible but as big as necessary)” 。
在问答网站 Quora 上,有一个著名的问题:什么是程序员觉得最浪费时间的事情?排名第一的回答中提到:“不必要的微服务。”
这句话揭示了开发团队在转向微服务架构时经常走入的误区。“微”固然重要,但是首要的是提供“服务”,这才构成“微服务”的价值。盲目地切分功能(Feature),却没有起到解耦合的作用,只是会增加维护、测试的成本。毕竟,多一项服务,就会多出一系列的流水线和测试要求。因此,测试、质量人员在面临团队计划采取微服务架构的决策时,必须要敢于质疑:是否有这样做的必要?目的是让决策人员意识到这种转型的潜在成本,避免花无用功。
2.微服务之间通常通过 Rest over HTTP 连接。
最常见的连接/交互方式,即通过 POST、GET、PUT、DELETE 这些命令操作 API,通过 JSON 传递参数。以下面这个典型的制造型企业的运营系统为例。在从单体式架构转为微服务之后,不同功能模块之间将通过 Rest 方式互相访问。
这种简易、明确的交互方式为契约测试(Contract Test)提供了基础,本课程的第七课《契约测试入门》将详细介绍这方面的内容。
3.每种服务不一定提供用户界面。
这意味着每种服务的测试,并不一定能够或者需要从 UI 完成。这对 API 级别的集成化测试提出了要求。本达人课的第四课《怎么针对微服务架构做集成测试?》将具体介绍如何进行集成化测试。
4.微服务通常还可以划分为更小的模块。
如下图所示,一个典型的微服务可以分为这几个模块:资源、业务逻辑、数据存储接口、外部通信接口等。
这意味着,在对微服务架构进行测试时,可以从不同的模块着手,进行相应的模块测试。本达人课的第五课《模块测试详解》将带您了解如何切分模块和执行相应的自动化测试。
简单总结一下本课程所学习的内容:
在上一课里,我们学习了微服务的来源和主要特点。对于软件测试人员而言,微服务架构对软件测试带来了哪些新的挑战呢?我们应该用什么样的策略和方法来迎接这些挑战?
软件测试的目的是确保软件产品的质量符合预期。衡量测试质量的指标有很多,最常见的是测试覆盖率和测试成本(包括测试所用时间、测试维护成本),而衡量测试效果的主要手段则是最终产品在实际使用中暴露出来的问题数量(Bug Number)。
具体到采用微服务架构的产品而言,Martin Fowler 在关于软件测试的论述中提出了其目的:
开发团队采用的任何测试策略,都应当力求为服务内部每个模块的完整性,以及每个模块之间、各个服务之间的交互,提供全面的测试覆盖率,同时还要保持测试的轻便快捷。
因此,我们需要采取下面几点测试策略:
以一个常见的开发团队为例,在采用了微服务架构之后,很可能同时会开发多个模块(即微服务),每个微服务有不同的客户要求、开发周期、开发进度和交付期限,但是整个团队又必须保证能够在固定的时间节点(譬如每月一次、每两周一次,甚至每天一次或者多次),持续地、稳定地为用户提供可以部署、使用的产品。这意味着,过去那种先等产品经理、业务部门提供需求,开发人员再进行开发,最后交给测试人员执行集成测试、端到端测试的方法,已经无法提供足够的测试粒度和足够快的响应速度。
归结起来,与基于单体式架构的传统测试方法相比,微服务架构对测试提出了以下挑战:
如何应对这些挑战,我总结了下面这三个原则:
1.自动化:测试任务的增加,要求测试人员必须把主要的精力用于将测试自动化,摆脱手动测试带来的沉重负担。当然,自动化测试必须足够稳定、稳健,不能动辄误报,否则反而会导致很高的维护成本。
2.层次化:这意味着采用分层次的测试方法,粒度由细到粗,范围由小到大。下图说明了几个主要层次之间的关系:
这就是 Mike Cohn 提出的测试金字塔(Test Pyramid),其中最重要的两个原则是:
最底层的是单元测试(Unit Test),粒度最细,速度最快,维护成本也最低。往上是针对每种服务内部的各种模块、业务流程的测试。最上面是基于前端 UI 的测试,这部分的粒度最粗,范围最大(因为会覆盖大多数服务),但是维护成本最高,因为稍微有些细微的变化就可能需要调整脚本。而且,由于基于前端,需要设置很多响应时间和等待时间,所以速度最慢。
Mike Cohn 是 Scrum 软件开发方法的提出者之一,也是 Scrum 联盟的创始成员。他目前是 Mountain Goat Software 公司的所有者,致力于提供关于 Scrum 和 Agile 软件开发技术的培训。
3.可视化:为了降低交流成本,最好的办法就是让所有的测试结果可视化。这意味着将构建(Build)、测试(Test)、部署(Deploy)所有这些相关任务构建在一个流水线之中,让所有团队成员都可以随时监控项目进度,找到阻碍项目的瓶颈。
以下面这个典型团队为例,整个从开发、测试、构建到部署的一系列过程,都可以借助 Jenkins 或者 TeamCity 这样的任务调度工具,完全可视化,再借助 SonarQube 这样的代码质量监控工具监控测试结果。Google Analytics 或者 Microsoft 的 Azure ApplicationInsight 等云端监控工具,则可以提供实时生产环境的客户使用信息或者测试数据,让整个团队可以随时把握产品的整个流水线的运行状态。
本达人课的后面几节内容,将会以层次化的方式,逐一介绍在微服务架构中所采用的主要测试方法。如下图所示,它们主要包括:
探索测试( Exploratory Test,即手动测试)
简单总结一下本课程所学习的内容:
单元测试是开发人员编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。例如,你可能把一个很大的值放入一个有序 list 中去,然后确认该值出现在 list 的尾部。或者,你可能会从字符串中删除匹配某种模式的字符,然后确认字符串确实不再包含这些字符了。
对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如 C 语言中单元指一个函数,Java 里单元指一个类,前端应用中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。
这节课,我们将探讨在微服务架构下,单元测试的设计、实现和质量控制。
要设计高效率(既运行快速又覆盖率高)的单元测试,首要要准确地定义测试边界。测试的目的就是为了验证边界里“黑盒”的行为是否符合预期,我们向黑盒输入数据,然后验证输出的正确性。在单元测试里,黑盒指的是函数或者类的方法,目的是单独测试特定代码块的行为。
但是在微服务架构中,很多时候黑盒的输出需要依赖于其他的功能或者服务,即存在外部依赖。为了更好地理解这个概念,我们以一个简单的注册功能为例:
从图中可以看出,这个函数包含了一些输入和输出。输入参数包括基本的用户注册信息(姓名、用户名和密码),而返回新创建的用户 ID。
但是在此过程中,还有一些不是很明显的输入数据。这个函数调用了两个外部函数:db.user.inser() 是向数据库插入数据;Password.hashAndsave() 是一个微服务,用于生成密码的哈希值,再加以保存。在某些情况下,数据库可能会返回错误,比如用户名已经存在,导致数据库插入失败。另外,因为需要调用外部的微服务生成密码哈希值,如果网络连接出现问题,或者哈希值生成服务由于发生过载而导致服务超时,那么密码保存就会返回错误。User.create() 函数必须能够妥善地处理这两种错误,这是测试的重点。
也就是说,为了全面地测试用户注册功能,单元测试所要做的不仅仅是简单地输入各种不同的参数,它还要能够让外部函数/微服务,能够产生出指定的错误,再验证函数的错误处理逻辑是否符合预期。
因此,为了在不依赖于外部条件的情况下制造出各种输入数据,就需要使用 Stub 或者 Mock,中文可以理解为对函数外部依赖的模拟器。简而言之,它意味着用一个假的版本替换了真实的对象(例如一个类、模块、函数或者微服务)。假的版本的行为特征和真实对象非常类似,采用相同的调用方法,并按照你在测试开始之前预定义的返回方式,提供返回数据。测试框架在运行被测试的函数时,可以把对外部依赖函数/服务的调用,重定向到 Stub 上,这样单元测试就可以在没有外部服务的情况下进行,即保证了速度,又避免了网络条件的影响。
这里再强调下 Stub 和 Mock 的区别,很多人经常搞混。Stub 就是一个纯粹的模拟器,用于替代真实的服务/函数,收到请求返回指定结果,不会记录任何信息。Mock 则更进一步,还会记录调用行为,可以根据行为来验证系统的正确性。
创建 Stub 的工具有很多,包括 Node.js/JavaScript 框架下的 sinon.js, testdouble.js 等;Python 下的 mock 等。
在刚刚提到的注册函数和密码哈希值生成、保存服务之间,插入一个 Stub(模拟器)的示意图如下:
我们可以使用模拟器来达到各种目的:
通过对外部依赖函数使用模拟器,通常可以在几秒钟内,执行数千个单元测试。这样,开发人员就可以把单元测试加入到日常的开发工作管线(Pipeline)当中,包括直接集成到常用的 IDE 里,或者通过终端命令行触发。通过在编写代码的同时,频繁运行单元测试,有助于尽早发现代码中的问题。对于程序员来说,如果养成了对自己写的代码进行单元测试的习惯,不但可以写出高质量的代码,而且还能提高编程水平。
顺便说一句,在微服务架构中,单元测试的作用不仅限于代码开发,它们还对 DevOps/CI(持续集成)有很大的帮助,可以集成到代码合并(Merge)流程里。
譬如,GitHub 支持对一些主流 CI 服务的状态检查。一般它会限制对“Master”主分支的提交权限,不允许开发人员直接向该分支提交代码,而是要求他们把代码先提交到其他分支上(提交 Pull Request),再由其他开发人员进行代码审查(Code Review)。最后,在将代码合并到主分支的时候,GitHub 要求先通过状态检查。这时,Jenkins、CircleCI 和 TravisCI 等 CI 服务都提供了状态检查钩子(hook),它们会从分支上获取代码并运行单元测试。如果通过了,就允许合并代码,否则就不允许。整个过程如下图所示:
单元测试的工具有很多,例如:
一个单元测试的实现主要分为以下几步:
这三步可以简化为“三 A 原则”: Arrange(设置)、Act (调用)、Assert(检查)。
或者也可以借用 BDD(行为驱动测试)的概念,把单元测试的流程分为三步:Given(上下文)、When (事件)、Then(结果)。
下面我们来看一个真实的例子,这是一个名为 ExampleController 的类,用于在人名库(PersonRepository)中查找人名。
@RestControllerpublic class ExampleController { private final PersonRepository personRepo; @Autowired public ExampleController(final PersonRepository personRepo) { this.personRepo = personRepo; } @GetMapping("/hello/{lastName}") public String hello(@PathVariable final String lastName) { Optional foundPerson = personRepo.findByLastName(lastName); return foundPerson .map(person -> String.format("Hello %s %s!", person.getFirstName(), person.getLastName())) .orElse(String.format("Who is this '%s' youre talking about?", lastName)); }}
下面,我们将用 Junit,对类中的 hello(lastname)方法进行单元测试。
JUnit 是 Java 社区中知名度最高的单元测试工具,用于编写和运行可重复的测试用例。JUnit 设计得非常小巧,但是功能却非常强大。它诞生于 1997 年,由 Erich Gamma 和 Kent Beck 共同开发完成。其中 Erich Gamma 是经典著作《设计模式:可复用面向对象软件的基础》一书的作者之一,并在 Eclipse 中有很大的贡献;Kent Beck 则是一位极限编程(XP)方面的专家和先驱。
public class ExampleControllerTest { private ExampleController subject; @Mock // 模拟器 private PersonRepository personRepo; @Before // 在每个测试方法之前执行 public void setUp() throws Exception { initMocks(this); subject = new ExampleController(personRepo); } @Test // 测试用例1 public void shouldReturnFullNameOfAPerson() throws Exception { Person peter = new Person("东", "王"); given(personRepo.findByLastName("王")) .willReturn(Optional.of(东)); String greeting = subject.hello("王"); assertThat(greeting, is("你好王东!")); } @Test // 测试用例2 public void shouldTellIfPersonIsUnknown() throws Exception { given(personRepo.findByLastName(anyString())) .willReturn(Optional.empty()); String greeting = subject.hello("王"); assertThat(greeting, is("这位王先生是谁?")); }}
Arrange(设置)、Act (调用)、Assert(检查)。
可以看到,首先我们用一个 Stub(模拟器),替换真正的 PersonRepository 类,这样我们可以预先定义我们希望返回的值。
记下来,我们按照 3A 原则,编写了两个单元测试。第一个是正常运行的用例:
第二是异常运行的测试用例:
通过这样的正面和反面的测试用例,我们可以彻底地检查 hello(lastname) 方法是否工作正常。
着重需要提及的一点是,测试人员应当设法将单元测试的覆盖率作为一个重要的监控指标,记录并可视化。例如,Teamcity 或者 Jenkins 这样的流程化工具,支持用 dotCover 来统计流程中单元测试的覆盖率,并将结果以 TXT 报告或者 HTML 的方式显示在任务页面上。进一步也可以将覆盖率、测试结果的数据,自动输出到 SonarQube 这样的代码质量监控工具之中,以便随时检查出测试没有通过或者测试覆盖率不符合预期的情况。
高覆盖率的单元测试是保障代码质量的第一道也是最重要的关口。从分工上来说,测试人员可能不会参与单元测试的开发与维护,但是测试人员应当协助开发人员确保单元测试的部署和覆盖率,这是确保后续一系列测试手段发挥作用的前提。
简单总结一下本课程所学习的内容:
下一课中,我们将重点介绍单元测试的下一个层级——集成测试。
阅读全文: http://gitbook.cn/gitchat/column/5afd51741f74b159eb38f2da