一次Testing in Production方案的探索

引子

传统的软件测试大多是在测试环境下进行的。人们普遍认为生产环境是服务于最终用户的,只有在测试环境下进行充分测试后才会发布给用户。

基于非生产环境的测试-单元测试、集成测试、功能测试等,很多都是基于预期结果的测试,测试人员一般是带着这样的思路来工作 “如果这样做会发生什么呢” -属于known-unknowns。而生产环境往往充满了惊喜-属于unknown-unknowns。我们不知道最终用户怎么操作(参考同事姚琪琳的文章《被踢出去的用户》),数据是什么样的,基础设施有什么差异等。

Stage作为类生产环境,是和生产环境最接近的一个测试环境。然而每一次新的发布都是一组代码和环境的组合,只有真正部署到了对应的环境,我们才能确定到底有没有问题。Stage环境也是测试环境,是抱着一定目的进行的操作,并不能完全反应真实用户的行为。

项目背景

我们当前的测试流程如下:

产品经受了多个测试环境的考验,但是在部署生产环境后依然暴露出很多意想不到的问题,初步分析后归结为下面两个因素:

1. 生产环境下数据复杂多样

我们对客户报过来的production问题进行了分析,下图可以看出由于数据问题导致的功能/性能问题在10%左右的区间波动。

软件系统的灵活性给与了用户各种各样的操作可能,代码/脚本的不确定性也会造成数据的不一致。这些都赋予了生产环境下数据的多样性,是其他环境无法模拟的。

2. 软件配置的集中化

ThoughtWorks团队主要负责软件的开发,而Stage和Prod环境部署在云平台上,这些访问权限严格控制在客户手中,基础设施严重依赖于客户。

项目即将大规模将配置由原来的SVN迁移到ZooKeeper实现集中管理。作为一个技术的改进,同时也蕴含着风险 – Stage和Prod的配置将由客户进行单点手工维护,对ThoughtWorks团队不可见,因此我们无法预知某个配置是否已经被添加/修改以及是否赋予了正确的值。

Testing in Production如何做

环境的特殊性带来了产品的不确定性,我们希望把测试的触角向前延伸,到生产环境去做测试,提前暴露产品的潜在问题,提高用户的满意度。

由于各种因素的约束,在生产环境能做的事情往往有限。比如我们项目的安全等级很高,开发团队是不能够访问生产环境的服务器的,甚至连脱敏的数据也接触不到。Stage环境下的数据也仅仅是客户的测试数据,不能把生产环境下的数据迁移过来。

业界实践

TiP并不是一个全新的事物,业界已经有了很多成熟实践:蓝绿部署、金丝雀测试、A/B测试等。

蓝绿部署是在有两个一样环境的前提下,不停老版本,部署新版本进行测试。测试没问题之后直接把流量切到新版本上,再把老版本也升级到新版本。一般适用于对用户体验有一定的忍耐度、机器资源丰富的团队。

“金丝雀测试”得名于以前旷工下井前会先放一只金丝雀去看是否有有毒气体,以金丝雀能否存活进行判断。一般是部署新版本到很小比例的服务器上,并允许小部分用户来使用新版本,测试通过则把剩余的服务都升级为新版本。一般适用于对新版本缺乏信心的团队。

A/B测试主要用于产品功能对比,版本A和版本B分别部署在不同的服务器上并开放给不同的用户使用,一般适用于收集用户反馈辅助产品功能设计。

蓝绿部署

基于当前产品环境的复杂架构,构建另一套相同的生产环境来实现蓝绿部署作为第一方案被提出来。蓝绿部署的思路如图:

在同一个时间段,蓝作为当前的生产环境供线上用户使用,绿作为部署新功能的测试环境供部分用户使用。两个环境的基础设施相同,配置一样,数据都是真实的生产环境数据。绿环境下发现的问题可以随时诊断修复,确认满足上线需求后即可把线上用户引流到绿环境,实现了最小化的宕机时间。

蓝绿部署的这个优势看似极好的契合了项目当前的诉求,但是准备一套同样的生产环境需要的成本在可视化出来之后也是令人震惊的!新的服务器就需要7台,而且每个月还需要预留出足够的时间来同步数据。在功能交付的压力之下,客户是不会为这样一个昂贵且成果未知的方案买单的,我们连自己都说服不了。

改进的方案

就在焦灼的时候,在一次头脑风暴中我们获取到一条线索-客户的灾备环境(Disaster Recovery)在定期从生产环境同步数据,但也仅仅是同步数据,代码已经很久没有部署过。也就是说灾备环境没有真正起到它应有的灾难备份和恢复,只是一个数据的备份而已。

方案就此而得到转机 – 是否可以复活灾备环境,利用它可以访问生产环境数据的天然优势来解决前面的痛点呢?在蓝绿部署方案的基础上,改进的方案如下:

鉴于灾备环境的基础设施不足以支撑其作为线上环境供所有用户使用,但是它的配置是等同于产品环境的。DR的定位为分时的灾备和测试环境 – 大部分时间用于灾备,小部分时间作为金丝雀进行新版本的测试。

灾备环境测试通过后的版本按照当前的部署流程进行生产环境的部署。这样一来不仅能恢复其本来的灾备作用,也解决了之前数据和配置集中化问题带来的痛点。

展望

从当前的测试流程来看,QA和Stage环境承担的工作有很大一部分重叠,带来了一定的浪费。希望未来有一天能去掉Stage环境,直接把这些server用在生产环境下构建一套新的环境,做到充分的基于生产环境的测试,实现新老版本的无缝切换。 期待测试流程会变成如下所示:

当今软件的部署越来越多的基于第三方的云平台,给团队带来了不可控因素。Testing in Production是基于生产环境下真实用户的行为和数据进行的一系列QA活动。传统的基于测试环境进行的测试活动,辅助以生产环境下的QA活动为提高软件的质量注入了新的活力。


更多精彩洞见,请关注微信公众号:思特沃克

Share

让你的系统在上线之前就接受炮火的洗礼-影子流量

随着持续集成,持续交付等理念的传播,很多软件开发团队都搭建了自己的staging、UAT等类生产环境。这些环境的软硬件及网络配置会尽量贴近真实的生产环境,起到沙盘演练的作用。

类生产环境毕竟前面还有一个类字,沙盘毕竟不是真实的战场,尽量贴近毕竟还不是完全吻合。

类生产环境与真实生产环境的一个重要差异就是访问量。稍具规模的互联网应用每天几百万访问量是很正常的,而类生产环境的访问量一般都会相形见绌。

有各种工具可以弥合这个差异,比如Apache JMeter,Gatling。测试人员可以和开发人员一起设计测试用例,以自动化或者半自动化的方式对类生产环境进行压力测试

不过即便是精心设计出来的用例也还是用例,不是真实请求。真实请求具有多样性,会随着昼夜交替而变化,会随着时事热点而波动,这是很难用工具模拟出来的。

这就引出了这篇文章的主角-影子流量(shadow traffic)。

简言之,影子流量(shadow traffic)就是将发给生产环境的请求复制一份转发到类生产环境上去,以此来达到压力测试和正确性测试的目的。

这就如同把真实战场上的敌方炮火投放到演习场里去。

实现方式

Shadow traffic通常有两种实现方式:服务端实现,客户端实现。

下图描述的是服务端实现的简化示例。

生产环境接收到来自于用户或者是上游系统的请求,在响应该请求的同时,将这个请求原封不动的也发送给类生产环境。

下图描述的是客户端的实现。

客户设备或者上游系统在发给生产环境请求的同时,给类生产环境也发送一个一模一样的请求。

这两种实现方式各有优劣,放到服务端做可以减少客户端设备的流量消耗,这一点对于移动应用很重要。

客户端的实现则较简单,通常只需要几行代码即可。如果后端架构较复杂,则可以选择前端实现。

无论前端还是后端实现,都需要遵循发射后不管(fire and forget)的原则,以免阻塞正常流程或者增加响应时间。

适用场景

笼统来说,shadow traffic可以适用于所有互联网应用。而在以下场景中,shadow traffic的作用格外明显:

  • 要用新系统替换掉老旧系统
  • 系统经历了大规模改造,直接上线面对客户风险较大
  • 系统更新,需要提供向后兼容性
  • 试验性质的架构调整

在以上场景运用shadow traffic,可以在不影响终端用户的情况下完成验证与测试。

启用时机

在上线之前一段时间集中地进行测试固然是一种可行的方式,不过我个人更倾向于在项目运转的早期引入shadow traffic。

这样做可以让开发团队尽早的并且持续的接触到真实的外界压力。相当于用一种成本并不怎么高的方式构建出了具有产品运维经验的开发团队。

配套机制

Shadow traffic的原理和实现方式并不深奥,但要让它发挥出应有的价值还需要一些前期工作的配合。

基础设施监控

要了解系统的表现,基础设施监控是必不可少的。

上图是我所经历过的一个项目的可视化监控界面。监控范围涵盖了docker container的数量,请求数量,响应时间,以4或者5打头的HTTP状态码的数量,网络、内存、CPU用量等等。

通过如上的可视化图表,开发团队可以实时得到反馈。

日志

基础设施监控可以提供一个外部视角,日志则能够窥见应用内部。

日志可以帮助开发团队定位shadow traffic中发现的问题,shadow traffic也可以促使开发团队提升日志的质量。这二者可以起到双向的积极促进作用。

下游系统的配合

如果一个系统开启了shadow traffic,可以想见它的下游系统所面对的压力也会陡升。

这时有必要与下游系统负责团队做好事先沟通。

用法变式

Shadow traffic并非是一成不变的技术实践,可以按需微调。

请求挑取

并非每一个请求都有被转发的必要。可以优先选取流量大或者业务价值高的请求。

流量控制

如果想做极限压力测试,可以把每一个请求重复发送多次给类生产环境。

当然也可以只挑取10%的请求来发送给类生产环境,随着团队信心的提升而逐步升高。

重播

可以截取并保存每天尖峰时刻的请求,在其他时段反复重播。

这种考验可以有效的锻炼团队的心理素质,并促使团队形成应急预案。

小结

如果明天要上线,今天会是一个让人惴惴不安的日子。

系统性能表现如何?会不会有奇形怪状的用户行为导致系统异常?与上下游系统的衔接会不会出现问题?

这些问题的答案,可以通过测试人员的精心模拟来寻找。但仍难免会挂一漏万。

启用shadow traffic,如果开发团队可以习惯于有shadow traffic的日常,也就具有了应对线上运维问题的能力。


更多精彩洞见,请关注微信公众号:思特沃克

Share

测试金字塔实战

“测试金字塔”是一个比喻,它告诉我们要把软件测试按照不同粒度来分组。它也告诉我们每个组应该有多少测试。虽然测试金字塔的概念已经存在了一段时间,但一些团队仍然很难正确将它投入实践。本文重新审视“测试金字塔”最初的概念,并展示如何将其付诸实践。本文将告诉你应该在金字塔的不同层次上寻找何种类型的测试,如何实现这些测试。

2018 年 2 月 26 日 作者:Ham Vocke


Ham 是德国 ThoughtWorks 的一名软件开发和咨询师。由于厌倦了在凌晨 3 点手动部署软件,他开始持续交付实践,加紧自动化步伐,并着手帮助团队高效可靠地交付高质量软件。这样他就可以把省出来的时间用在别的有趣的事情上了。

目录
  • 测试自动化的重要性
  • 测试金字塔
  • 我们用到的工具和库
  • 应用例子
    • 功能
    • 整体架构
    • 内部架构
  • 单元测试
    • 什么是单元?
    • 社交和独处
    • 模拟和打桩
    • 测试什么?
    • 测试架构
    • 实现一个单元测试
  • 集成测试
    • 数据库集成\
    • REST API 集成
    • 几个独立服务的集成
    • JSON 的解析和撰写
  • 契约测试
    • 消费者测试(我们团队)
    • 提供者测试(其他团队)
    • 提供者测试(我们团队)
  • UI 测试
  • 端到端测试
    • 用户界面端到端测试
    • REST API 端到端测试
  • 验收测试 – 你的功能工作正常吗?
  • 探索测试
  • 测试术语误解
  • 把测试放到部署流水线
  • 避免测试重复
  • 整洁测试代码
  • 结论

准备上生产环境的软件在上生产之前需要进行测试。随着软件开发行业的成熟,软件测试方法也日趋成熟。开发团队正在逐渐自动化大部分的测试,以此取代大量测试人员手工测试。通过自动化测试,开发团队可以分分钟就知道他们的软件是否被破坏,而不是后知后觉几天后才知道。

自动化测试极大地缩短了反馈周期,这与敏捷开发实践、持续集成、DevOps 文化等是一脉相承的。拥有高效的软件测试方法,可以让你的团队快速而自信地前行。

本文将探讨一个具备高响应力的、可靠并且可维护的测试组合应该如何构建,这与你具体构建的是一个微服务架构、移动应用程序或者物联网生态系统都无关。此外,我们还将详细介绍如何写出高效且可读的自动化测试。

(测试)自动化的重要性

软件已经成为我们日常生活中的一个重要组成部分。早期它仅仅用于提高企业的效率,但如今它的作用远不止如此。如今许多公司都想方设法成为一流的数字化公司。作为用户,我们每天使用的软件越来越多。创新的车轮正加速向前滚动。

如果你想跟上时代的步伐,你必须研究如何在不牺牲质量的情况下更快地交付你的软件。持续交付——一种高度自动化的、确保你可以随时将软件发布到生产环境中的实践——正能帮你达到这个目的。它通过构建流水线自动测试你的软件,自动将其部署到测试和生产环境中。

软件的数量正以前所未有的速度增长,手动进行构建、测试和部署,很快就会变得不可能——除非你想把所有的时间都用来进行手动重复的工作,而不是用来开发可工作的软件。将一切自动化,从构建到测试,从部署到基础架构,这是你唯一的出路。

(使用构建流水线来自动并可靠地将你的软件部署到生产环境)

传统的软件测试过于依赖手工操作:首先将应用程序部署到测试环境,然后执行一些黑盒测试,例如,通过点击用户界面来查看一切是否工作如常。通常这些测试将由文档指定,以确保测试人员每次测试的内容是一致的。

很明显,手动测试所有更改非常耗时、重复而且繁琐。重复很无趣,无趣就容易犯错,这样子还没测到这周工作结束你就会想找下一份工作了。

幸运的是,重复性劳动还是有药可治的:自动化。

自动化繁琐重复的测试将给软件开发人员的生活带来重大改变。自动化这些测试后,你就不需要再一味遵循测试文档点点点以确保软件是否仍正常工作。自动化这些测试,你可以充满自信地修改你的代码。如果你曾试过在没有适当自动化测试的情况下进行大规模重构,那你应该知道这种体验多么恐怖。你怎么知道你是否意外地破坏了某些功能呢?显然,你需要将所有的测试用例手动点一遍。不过老实说,你真的享受这个过程吗?你想象一下,如果你对代码做了大规模改动后惬意地喝了一口咖啡,喝完咖啡后就能马上得知你的改动有没有破坏原有功能。这样的开发体验是不是听起来就让人舒服多了?

测试金字塔

如果你真的想为你的软件构建自动化测试,你必须知道一个关键的概念:测试金字塔。Mike Cohn 在他的着作《Succeeding with Agile》一书中提出了这个概念。这个比喻非常形象,它让你一眼就知道测试是需要分层的。它还告诉你每一层需要写多少测试。

(测试金字塔)

根据 Mike Cohn 的测试金字塔,你的测试组合应该由以下三层组成(自下往上分别是):

  • 单元测试
  • 服务测试
  • 用户界面测试

不幸的是,如果你仔细思考就会发现,测试金字塔的概念有点太短了。有人认为,Mike Cohn 的测试金字塔里的命名或某些概念不是最理想的。我也同意这一点。从当今的角度来看,测试金字塔似乎过于简单了,因此可能会产生误导。

然而,由于其简洁性,在建立你自己的测试组合时,测试金字塔本身是一条很好的经验法则。你最好记住 Cohn 测试金字塔中提到的两件事:

  • 编写不同粒度的测试
  • 层次越高,你写的测试应该越少

为了维持金字塔形状,一个健康、快速、可维护的测试组合应该是这样的:写许多小而快的单元测试。适当写一些更粗粒度的测试,写很少高层次的端到端测试。注意不要让你的测试变成冰淇淋那样子,这对维护来说将是一个噩梦,并且跑一遍也需要太多时间。

不要太拘泥于 Cohn 测试金字塔中各层次的名字。事实上,它们可能相当具有误导性:服务测试是一个难以掌握的术语(Cohn 本人说他观察到很多开发人员完全忽略了这一层)。在单页应用框架(如 react,angular,ember.js 等)的时代,UI 测试显然不必位于金字塔的最高层,你完全能够用这些框架对 UI 进行单元测试。

考虑到原始名称的缺点,只要在你的代码库和团队讨论中达成一致,你完全可以为测试层次提供其他名称。

我们将使用的工具和库

  • JUnit : 测试执行库
  • Mockito: 模拟依赖
  • Wiremock: 为外部服务打桩
  • Pact: 用于编写消费者驱动的契约测试
  • Selenium: 用于编写用户界面驱动的端到端测试
  • REST-assured: 用于编写 REST API 驱动的端到端测试

示例应用

我已经写好了一个简单的微服务应用,其中涵盖了测试金字塔各种层次的测试。

示例应用体现了一个典型的微服务的特点。它提供了一个 REST 接口,与数据库进行通信并从第三方 REST 服务中获取信息。它是使用 Spring Boot 实现的,即使你之前从未使用过 Spring Boot,它也简单到应该让你很容易理解。

请下载 Github 上的代码。Readme 里写了你在计算机上运行应用程序及其自动化测试所需的说明。

功能

应用的功能十分简单。它提供了三个 REST 接口:

  • GET /hello 总是返回”Hello World”
  • GET /hello/{lastname} 根据 lastname 来查询人,如果查到了结果将返回”Hello {Firstname} {Lastname}”
  • GET /weather 返回现在德国汉堡的天气情况

高层架构

从高层次来看,这个系统的结构是这样:

(我们微服务系统的高层架构)

我们的微服务提供了一个可以通过 HTTP 调用的 REST 接口。对于某些接口,服务将从数据库获取信息。在其他情况下,服务将通过 HTTP 调用外部天气 API来获取并显示当前天气状况。

内部架构

在内部,Spring Service 有一个典型的 Spring 架构:

(我们微服务的内部架构)

  • Controller 提供 REST 接口,处理 HTTP 请求和响应
  • Repository 和数据库打交道,关注数据在持久化存储里的读写操作
  • Client 和别的 API 交互,在我们的应用里它会通过 HTTPS 从 darksky.net 获取天气情况
  • Domain 这是我们的领域模型,它包含了领域逻辑(相对来说,在我们的示例中不甚重要)

有经验的 Spring 开发人员可能会注意到这里缺失了一个常用的层次:受Domain-Driven Design的启发,很多开发人员通常会构建一个由服务类组成的服务层。我决定不在这个应用中包含服务层。原因之一是我们的应用程序很简单,服务层只会成为一个不必要的中间层。另一个是我认为人们过度使用服务层。我经常遇到在服务类中写了全部业务逻辑的代码库。领域模型仅仅成为数据层,而不是行为(贫血域模型)。对于每一个稍有复杂度的应用来说,这浪费了很多让代码保持结构良好且易于测试的优秀方案,并且没能充分利用面向对象的威力。

我们的 repositories 非常简单,它提供简单的 CRUD 功能。为了保持代码简单,我使用了 Spring Data。 Spring Data 为我们提供了一个简单而通用的 CRUD 实现,我们可以直接使用而不需再造轮子。它还负责为我们的测试启动一个内存数据库,而不是像生产中一样使用真正的 PostgreSQL 数据库。

看看代码库,熟悉一下内部结构。这将有助于我们的下一步:测试我们的应用!

单元测试

单元测试将成为你测试组合的基石。你的单元测试保证了代码库里的某个单元(被测试的主体)能按照预期那样工作。单元测试在你的测试组合里测试的范围是最窄的。它的数量在测试组合中应该远远多于其他类型的测试。

(一个用测试替身隔绝了外部依赖的典型单元测试)

一个单元指的是什么?

如果你去问三个人同样的问题:“单元”在单元测试的上下文中意味着什么,你很可能会获得四种非常相似的答案。某种程度上讲,对“单元”的定义取决于你自己,因此这个问题没有标准答案。

如果你正在使用函数式语言,一个单元最有可能指的是一个函数。你的单元测试将使用不同的参数调用这个函数,并断言它返回了期待的结果。在面向对象语言里,下至一个方法,上至一个类都可以是一个单元(从一个单一的方法到一整个的类都可以是一个单元)。

群居和独居

一些人主张,应该将被测试主体下的所有合作者(比如在测试里被你的类调用的其他类)都使用模拟或者桩替换掉,这样可以建立完美的隔离,避免副作用和复杂的测试准备。而有些人主张,只有那些执行起来很慢或者有较大副作用的合作者(比如读写数据库或者发送网络请求的类)才应该被模拟或者打桩替代。

偶尔有人会把用桩隔离所有依赖的测试称为独居单元测试,把和依赖有交互的测试成为群居单元测试(Jay Fields 的《Working Effectively with Unit Tests》这本书里创造了这些概念)。如果有空你可以继续深究下去,读一读不同思想流派各自的利弊在哪。

说到底,决定采用群居方式还是独居方式的单元测试其实并不重要。写自动化测试才是重要的。就我自己而言,我发现我自己经常两种方式都用。如果使用真正的合作者很麻烦,我就会用模拟对象或者桩。如果我觉得引用真正的合作者能让我对测试更有信心,我会仅仅打桩替代掉 service 最外层的依赖。

模拟和打桩(这里以及下文的桩都指 stub)

模拟对象和桩是两种不一样的测试替身(测试替身还不止这两种)。很多人会混用模拟对象和桩这两个概念。我认为,准确的用词会好点,并且最好能将它们各自的特性谙熟于心。你可以使用测试替身来替换掉真实的对象,给它一个可以更方便测试的实现。

换句话说,这意味着你是用一个假的实现来代替真的那个(例如,一个类,一个模块或者一个函数)。这个假的实现外表和行为和真的很像(都能响应同样的方法调用),只不过真实的响应内容是你在单元测试开始前就定义好的。

并不是在单元测试时我们才使用测试替身。还有很多精妙的测试替身能以非常可控的方式来模拟整个系统的功能。然而,在单元测试里使用模拟对象和桩的概率会更高(取决于你是喜欢群居风格还是独居风格的开发者),这主要是因为现代语言和库使得构建模拟对象和桩变得更加简单了。

不管你的技术选型是怎么样的,一般来说,编程语言的标准库或一些比较有名的三方库都会提供一些优雅的方式来帮你构建 mocks。即使需要自己编写 mock 对象,也只是写一个假类/模块/函数的事,只需要让它与真实的合作者有相同的签名,并设置到你的测试中去即可。

你的单元测试跑起来应该非常快。在一般的机器上跑完数千个单元测试应该只需要几分钟。为了得到快速的单元测试,你应该独立地测试代码库的每一小块,并避免进行真实的数据库操作、文件系统操作,或者发送真实的 HTTP 请求(使用模拟对象和桩来隔离这一部分)。

一旦你掌握了写单元测试的诀窍,你写起来就能越来越顺畅。打桩隔离掉外部依赖,准备一些输入数据,调用被测试的主体,然后检查返回值是不是你所期待的。看看测试驱动开发,让单元测试指引你的开发;如果使用得当,测试驱动开发将帮你进入一个非常顺畅的工作流,它能帮你创造出一个良好且可维护的设计,顺便还能送你一套全面且自动化的测试。当然,测试驱动开发并不是银弹。但是建议你尝试一下,看看它是否适合你。

应该测试什么?

单元测试有个好处,就是你可以为所有的产品代码类写单元测试,而不需要管它们的功能如何,或者它们在内部结构中属于哪个层次。你可以对 controller 进行单元测试,也可以用同样的方式对 repository、领域类或者文件读写类进行单元测试。良好的开端,从坚持一个实现类就有一个测试类的法则开始。

一个单元测试类至少应该测试这个类的公共接口。私有方法不能直接测试的原因是你不能从测试类直接调用它们。受保护的和包私有的方法可以被测试类直接调用(如果测试类和生产代码类的包结构是一样的),但是测试这些方法可能就太过了。

编写单元测试有一条细则:它们应该保证你代码所有的路径都被测试到(包括正常路径和边缘路径)。同时它们不应该和代码的实现有太紧密的耦合。

为什么这样说呢?

测试如果与产品代码耦合太紧,很快就会令人讨厌。当你重构代码时(快速回顾一下:重构意味着改变代码的内部结构而不改变其对外的行为)你的单元测试就会挂掉。

这样的话你就损失了单元测试的一大好处:充当代码变更的保护网。你很快就会厌烦这些愚蠢的测试,而不会感到它能带来好处,因为你每次重构测试就会挂掉,带来更多的工作量。不过说起来这些愚蠢的测试又是谁把它写成这样的呢?

那么正确的做法是什么?是不要在你的单元测试里耦合实现代码的内部结构。要测试可观测的行为。你应该这样思考:

如果我的输入是 x 和 y,输出会是 z 吗?

而不是这样:

如果我的输入是 x 和 y,那么这个方法会先调用 A 类,然后调用 B 类,接着输出 A 类和 B 类返回值相加的结果吗?

私有方法应该被视为实现细节。这就是为什么你不应该有去测试他们的冲动。 我经常听单元测试(或者 TDD)的反对者说,编写单元测试是无意义的工作,因为为了获得一个高的测试覆盖率,你必须测试所有的方法。他们经常引用这样的场景:一个过于激昂的团队领导强硬地让他们为 getter、setter 及其他所有琐碎的代码施加测试,以达到 100%的测试覆盖率。

这就大错特错啦。

确实你应该测试公共接口。但是更重要的是,不要去测试微不足道的代码。别担心,Kent Beck 说这样是 OK 的。你不会因为测试 getter,setter 抑或是其他简单的实现(比如没有任何条件逻辑的实现)而得到任何价值。把时间省出来,你就能多参加一个会了,万岁!

测试结构

一个好的测试结构(不局限于单元测试)是这样的:

  1. 准备测试数据
  2. 调用被测方法
  3. 断言返回的是你期待的结果

这里有个口诀可以帮你记住这种结构:“Arrange,Act,Assert”。另一个口诀则是从 BDD 获取的灵感。就是“given”,“when”,“then”三件套,given 说的是准备数据,when 指的是调用方法,then 则是断言。

这种模式也可以应用于其他更高层次的测试。在任何情况下,它们都能让你的测试保持一致,易于阅读。除此之外,使用这种结构写出来的测试,往往更简短,更具表达力。

实现一个单元测试

知道了测什么、如何组织单元测试后,我们终于可以看一个真正的例子了。 让我们来看一个简化版的 ExampleController 类:

@RestController
public 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<Person> foundPerson = personRepo.findByLastName(lastName);

        return foundPerson
                .map(person -> String.format("Hello %s %s!",
                        person.getFirstName(),
                        person.getLastName()))
                .orElse(String.format("Who is this '%s' you're talking about?",
                        lastName));
    }
}

一个针对hello(lastname)方法的单元测试可能是这样的:

public class ExampleControllerTest {

    private ExampleController subject;

    @Mock
    private PersonRepository personRepo;

    @Before
    public void setUp() throws Exception {
        initMocks(this);
        subject = new ExampleController(personRepo);
    }

    @Test
    public void shouldReturnFullNameOfAPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        given(personRepo.findByLastName("Pan"))
            .willReturn(Optional.of(peter));

        String greeting = subject.hello("Pan");

        assertThat(greeting, is("Hello Peter Pan!"));
    }

    @Test
    public void shouldTellIfPersonIsUnknown() throws Exception {
        given(personRepo.findByLastName(anyString()))
            .willReturn(Optional.empty());

        String greeting = subject.hello("Pan");

        assertThat(greeting, is("Who is this 'Pan' you're talking about?"));
    }
}

我们写单元测试用的是JUnit,Java 实际意义上的标准测试框架。我们使用Mockito来打桩隔离掉真正的PersonRepository类。这个桩允许我们在测试里重新定义 PersonRepository 被调用后产生的响应。桩能让我们的测试更简单,更可预测,更容易组织测试数据。

依照 Arrange,Act,Assert 的结构,我们写了两个单元测试:一个是正常的场景,另一个是找不到搜索人的场景。首先,正常场景创建了一个新的 person 对象,然后告诉 mock 类,当你接受到以“Pan”作为参数的调用时,返回这个 person 对象。这个测试接着调用了被测试方法。最后它断言返回值是等于期待结果的。

第二个测试和第一个类似,但它测试的是被测方法找不到对应人名时的场景。

集成测试

所有常见的应用都会和一些外部环境做集成(数据库,文件系统,向其他应用发起网络请求)。为了使测试有更好的隔离、运行更快,我们通常不会在编写单元测试时涉及这些外部依赖。不过,这些交互始终是存在的,它们也需要被测试覆盖到。这正是集成测试的用处所在。它们测试的是应用与所有外部依赖的集成。

对于自动化测试来说,不仅需要运行自己的应用,也需要运行与之集成的组件。如果要测试和数据库的集成,那就需要在跑测试的时候运行数据库。如果要测试能否从硬盘里读取文件,就需要先保存一个文件到硬盘上,然后在集成测试中去读取它。

前面我提到过「单元测试」是一个模糊的术语,对于集成测试而言,更是如此。对于一些人来讲,集成测试意味着去测试和多方应用产生交互的整个应用。我理解的集成测试更加狭义:每次只测试一个集成点。测试时应使用测试替身来替代其他的外部服务、数据库等。同时,使用契约测试对测试替身和真实实现进行覆盖。这样出来的集成测试更快,更独立,更易理解和调试。

狭义的集成测试测的是服务的边界。从概念上来说,这样的测试总是在触发导致应用和外部依赖(文件系统,数据库,其他服务等)集成的行为。比如说,一个数据库集成测试可能会这么写:

(一个集成了你的代码和数据库的集成测试)

  1. 启动数据库
  2. 连接应用到数据库
  3. 调用被测函数,该函数会往数据库写数据
  4. 读取数据库,查看期望的数据是不是被写到了数据库里

另一个例子,一个通过 REST API 和外部服务集成的测试可能是会这么写:

(这种集成测试检查了应用是否能正确和外部服务通信)

  1. 启动应用
  2. 启动一个被测外部服务的实例(或者一个具有相同接口的测试替身)
  3. 调用被测函数,该函数会从外部服务的 API 读取数据
  4. 检查应用是否能正确解析返回结果

与单元测试一样,集成测试也可以写得很白盒。一些框架在应用启动后,仍然支持对应用的一些部分进行 mock。 这使得你可以去检查正确的交互是否发生。

代码中所有涉及数据序列化和反序列化的地方都要写集成测试。这些场景可能比你想象得更多,比如说:

  • 调用自身服务的 REST API
  • 读写数据库
  • 调用外部服务的 API
  • 读写队列
  • 写入文件系统

为这些边界编写集成测试,保证了对外部系统的数据读写操作是正常工作的。

编写狭义的集成测试时,你应该尽可能在本地运行外部依赖,如启动一个本地的 MySQL 数据库、针对本地的 ext4 文件系统进行测试等。如果是与外部服务集成,可以在本地运行该服务的实例,或构建一个模拟真实服务的假服务,并在本地运行。

如果有些三方服务,你没法在本地运行一个实例,那么可以考虑运行一个专用实例,并在集成测试中指向它。避免在自动化测试里集成真实的生产环境的服务。在生产环境上爆出上千个测试请求是个惹人生气的好办法,因为你会扰乱日志(这是最好的情况),最坏的情况是你会对该服务产生 DoS 攻击。透过网络和一个服务集成是广义集成测试的一大特征,这会让你的测试更慢,通常也更难编写。

在测试金字塔中,集成测试的层级比单元测试更高。集成缓慢的外部依赖(如文件系统或数据库等)通常比隔离了这些依赖的单元测试需要更长时间。他们可能比小型并且独立的单元测试难写,毕竟你需要让外部依赖在你的测试中运行起来。然而,它的优势在于建立了你对应用能正确访问外部依赖的自信,这是单纯的单元测试做不到的。

数据库集成

PersonRepository 是代码里唯一的数据库类。它依赖于Spring Data,我们并没有实际去实现它。只需要继承CrudRepository接口并声明一个方法名。剩下的就是 Spring 魔法了,Spring 会帮我们实现其他所有的东西。

public interface PersonRepository extends CrudRepository<Person, String> {
    Optional<Person> findByLastName(String lastName);
}

对于CrudRepository接口,Spring Boot 提供了完整的 CRUD 方法例如findOne, findAll, save, update和delete。我们自定义的方法(findByLastName())继承了这些基础功能并实现了根据 last name 获取 Persons 对象的功能。Spring Data 会解析方法的返回类型,并按照命名规范解析方法名,从而决定如何实现方法。

虽然 Spring Data 已经实现了和数据库交互的功能,我还是写了一个数据库集成测试。你可能会反对,认为这是在测试框架,而我们应该避免测试不属于我们开发的代码。然则,我坚信在这里写一个集成测试是至关重要的。首先它测试了我们自定义的 findByLastName 方法实际的行为如我们所愿。次之,它证明了我们的数据库类正确地使用了 Spring 的装配特性,它是能正确连接到数据库的。

为了让你能更容易在本地把测试运行起来(而不必真的装一个 PostgreSQL 数据库),我们的测试会连接到一个内存H2数据库。

我已经在build.gradle里定义 H2 作为测试依赖。而测试目录下的application.properties没有定义任何spring.datasource属性。这会告诉 Spring Data 使用内存数据库,它会在 classpath 里找到 H2 来跑我们的测试。

当你用 int profile 真正启动应用时(例如把SPRING_PROFILES_ACTIVE=int设置到环境变量里),它会连接到application-int.properties里定义的 PostgreSQL 数据库。

我知道这涉及到了很多 Spring 的知识。你必须仔细阅读许多文档才能理解这个例子。实现代码只有几行,非常直观,但是如果你不知道 Spring 的一些知识点是很难加以理解的。

除此以外,测试使用内存数据库其实是有风险的。毕竟我们集成测试针对的数据库和我们生产用的数据库不一样。你可以自己选择,是利用 Spring 的强大能力获得简洁的代码,亦或者是写显式但较为冗长的实现。

解释已经足够多了,这里有一个集成测试的例子。它先保存了一个 Person 对象到数据库里,然后根据 last name 去查找它。

@RunWith(SpringRunner.class)
@DataJpaTest
public class PersonRepositoryIntegrationTest {
    @Autowired
    private PersonRepository subject;

    @After
    public void tearDown() throws Exception {
        subject.deleteAll();
    }

    @Test
    public void shouldSaveAndFetchPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        subject.save(peter);

        Optional<Person> maybePeter = subject.findByLastName("Pan");

        assertThat(maybePeter, is(Optional.of(peter)));
    }
}

你可以看到我们的集成测试像单元测试那样遵循了arrange, act, assert的结构。我说过这是一个普适的概念吧。

和外部服务集成

我们的微服务会调用darksky.net——一个关于天气的 REST API。当然啦,我们希望保证服务调用时能发送正确的请求,并且能正确地解析响应。

跑自动化测试时,我们希望避免真实地调用darksky的服务。当然,我们使用的免费版有调用次数限制,这是个原因。但真正的原因是要去耦合。我们的测试应该能独立运行,而不需要管 darsky.net 可爱的开发者们在干些啥。即使我们的机器访问不到darksky服务器,或darksky服务器在进行宕机维护,都不应该使我们的测试挂掉。

我们可以在集成测试中用自己的假darksky服务器来代替真正的服务器。这听起来像是个巨大的任务。幸亏有像Wiremock这样的工具,事情变得很简单。看这里:

@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientIntegrationTest {

    @Autowired
    private WeatherClient subject;

    @Rule
    public WireMockRule wireMockRule = new WireMockRule(8089);

    @Test
    public void shouldCallWeatherService() throws Exception {
        wireMockRule.stubFor(get(urlPathEqualTo("/some-test-api-key/53.5511,9.9937"))
                .willReturn(aResponse()
                        .withBody(FileLoader.read("classpath:weatherApiResponse.json"))
                        .withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                        .withStatus(200)));

        Optional<WeatherResponse> weatherResponse = subject.fetchWeather();

        Optional<WeatherResponse> expectedResponse = Optional.of(new WeatherResponse("Rain"));
        assertThat(weatherResponse, is(expectedResponse));
    }
}

为了使用 Wiremock,我们在固定的端口(8089)实例化了一个WireMockRule。使用领域特定语言,我们可以配置一个 Wiremock 服务器,定义它需要监听的路径并设置相应的应答。

接着调用我们要测试的方法——它会调用第三方服务,然后检查结果是否能被正确解析。

理解测试怎样调用 Wiremock 服务器而不是真正的darksky很重要。秘密就在src/test/resources下的application.properties文件。这是 Spring 在运行测试时会加载的属性文件。出于测试目的——比如,调用一个假的 Wiremock 服务器而不是真实服务器——我们在这个文件里覆写了一些配置,如 API keys 和 URL 等: weather.url = http://localhost:8089

值得注意的一点是,这里声明的端口必须和我们在测试里实例化WireMockRule时的端口保持一致。我们之所以能为了测试注入一个假的 API url,是因为我们通过注入的方式将 url 传给了WeatherClient类的构造函数:

@Autowired
public WeatherClient(final RestTemplate restTemplate,
                     @Value("${weather.url}") final String weatherServiceUrl,
                     @Value("${weather.api_key}") final String weatherServiceApiKey) {
    this.restTemplate = restTemplate;
    this.weatherServiceUrl = weatherServiceUrl;
    this.weatherServiceApiKey = weatherServiceApiKey;
}

这样我们告诉WeatherClient要把我们定义在 application properties 的weather.url值赋给weatherUrl。

借助类似 Wiremock 这样的工具,为外部服务编写狭义的集成测试就变得很简单。不幸的是这种方式有个缺点:如何保证我们启动的假服务器与真的服务行为一致?按我们目前的实现,当外部服务改变了它的 API 时,我们的测试依然能跑过。现在我们仅仅测试了WeatherClient可以解析假服务器返回的应答信息。这是个好的开始,但是它非常脆弱。如果使用端到端测试,针对真实服务的实例运行测试,而不使用假的服务,固然能解决这个问题,但这又会让我们对被测服务的可用性产生依赖。幸运的是,针对这个难题还是有更好的方案:针对真实和假的服务运行契约测试。这能保证我们集成测试里用的假服务是个忠实的测试替身。下面来看看这种方案是怎么工作的。

契约测试

越来越多现代软件组织发现,对于增长的开发需求,可以让不同的团队来开发同一系统的不同部分。每个团队负责构建独立、松耦合的服务,团队间开发不互相影响。最终再将这些服务集成为一个大而全的系统。最近关于微服务的讨论日益热烈,关注的正是这一点。

将系统拆分成多个更小的服务,常常意味着这些服务之间需要通过确定的(最好是定义明确的,但有时候会有变动演进)接口通信。

不同应用间的接口可能形态各异,或基于不同的技术栈。常见的有:

  • 基于 HTTPS 使用 JSON 交互的 REST 接口
  • 基于类似gRPC的 RPC(Remote Procedure Call,远程进程调用)接口
  • 使用队列构建的事件驱动架构

对于任意一个接口,一定会涉及两个实体:提供方和消费方。提供方为消费方提供数据。消费方处理来自提供方的数据。在 REST 世界里,提供方为所有要暴露的 API 创建一个 REST API;消费方则调用这些 API 来获取数据,或进一步触发其他的服务。而在一个由异步、事件驱动的世界,提供方(通常被称为发布者)发布数据到一个队列中;消费方(通常被称为订阅者)订阅这些队列,读取并处理相关数据。

(每一个接口都有提供方(或者发布者)和消费方(或者订阅者)实体。接口之间的规范可以视为是一个契约。)

当你把服务消费方和服务提供方分散到不同的团队去时,你就需要清楚地了解这些服务之间的接口(也就是我们所讲的契约)。传统的公司一般是通过以下的方式解决这个问题:

  • 写一个钜细靡遗的接口文档(就是契约)
  • 根据定义好的契约实现提供方服务
  • 把接口文档扔给隔壁的消费团队
  • 等。等到消费方团队实现接口消费部分的工作
  • 运行一些大型的、手动的系统测试,保证软件能正常工作
  • 祈祷双方团队永远都维持接口定义不变,不要把事情搞砸

越来越多现代软件开发团队已经把第五步和第六步用更加自动化的方式来替代:自动化契约测试保证了消费方和提供方实现的时候依然遵循契约。这种测试提供了一个良好的回归测试组合,保证契约的变更能被及早发现。

在现代敏捷组织,你应该选择效率高浪费少的路子。你们是在同一个公司里构建应用。比起扔出去一个面面俱到的文档,与其他服务的开发者们直接交流本应容易得多。毕竟他们是你的同事,而不是一个只能通过客户支持或合同进行沟通的第三方供应商。

消费方驱动的契约测试(Consumer-Driven Contract tests,CDC 测试)是让消费方驱动契约实现。使用 CDC,接口消费方会写测试,检查接口是不是返回了他们想要的所有数据。消费方团队会发布这些测试从而让提供方可以轻松获取到这些测试并执行。提供方团队现在可以一边运行 CDC 测试一边开发他们的 API 了。一旦所有测试通过,他们就知道已经实现了所有消费方想要的东西。

(契约测试保证了提供方和所有的消费方基于同一个定义好的接口契约。用 CDC 测试,消费者就可以通过自动化测试发布他们的需求,提供方则可以持续不断获取这些测试并执行)

这种方式允许提供方团队只实现必要的东西(让设计保持简约,YAGNI等)。提供方团队需要持续地获取并运行这些 CDC 测试(从他们的构建 Pipeline 里),从而能立即发现任何打破契约的代码变更。如果有代码变更破坏了接口,CDC 测试应该会执行失败,这样可以防止破坏性改动上线。当这些测试保持通过,团队就可以做任何他们想做的改动而不需要担心其他团队。使用消费方驱动测试的话,一般过程会是这样的:

  • 消费方团队根据他们期待的结果编写自动化测试
  • 发布自动化测试给提供方团队
  • 提供方持续不断地运行这些测试,并保持他们都能通过
  • 如果 CDC 测试挂掉了,则需要双方进行沟通

如果你的组织正在践行微服务,那么拥有 CDC 测试将是迈向自治团队的一大步。CDC 测试是一种促进团队交流的自动化途径。它们保证了团队间的接口能一直如期工作。如果有 CDC 测试挂掉,则可能是个好的信号,意味着你应该走过去到那个被测试影响的团队,了解他们最近是否有 API 变更,弄清楚你们希望如何处理这些变更。

一个稚嫩的 CDC 测试实现非常简单,比如说你可以对一个 API 发送请求,并断言响应中包含了你需要的所有东西。然后你把这些测试打包成可执行文件(.gem, .jar, .sh),并将它们上传到一个其他团队可以获取到的地方(例如一些诸如Artifactory这样的仓库)。

在过去几年里,CDC 正变得越来越受欢迎。同时也涌现了一些工具,使得编写及上传 CDC 更加简单。

在这些工具中,Pact可能是最显眼的一个了。它为编写提供方或消费方的测试提供了详尽的支持,为外部服务隔离提供了开箱即用的(打)桩工具,它还支持你与其他团队交换 CDC 测试。Pact 已经被移植到很多平台上,并且可以和 JVM 语言一起使用,例如 Ruby,.NET,JavaScript 等等。

如果你想开始编写 CDC 测试但不知道怎么开始,不妨试试 Pact。文档一开始可能会让你应接不暇。保持耐心克服一下。它能帮助你深刻理解 CDC 测试,也会让你更容易在团队合作时推行 CDC。

消费方驱动契约测试真的可以说是建立自治团队的基石,它让这样的团队充满自信,快速前行。不要掉队,好好读读相关的文档,尝试一下。一套稳固的 CDC 测试集非常宝贵,它让你能快速开发,同时又不会挂掉已有的服务,引起其他团队的不满。

消费方测试(我方团队)

上面的例子中,我们的微服务消费了天气 API。所以我们有责任写一个消费方测试来定义我们期望从 API 契约中获得的结果。

首先,我们要在build.gradle里引入一个库来写基于协议的消费方测试:

testCompile('au.com.dius:pact-jvm-consumer-junit_2.11:3.5.5')

得益于这个库,我们可以用协议的仿造服务来实现一个消费方测试:

@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientConsumerTest {

    @Autowired
    private WeatherClient weatherClient;

    @Rule
    public PactProviderRuleMk2 weatherProvider =
            new PactProviderRuleMk2("weather_provider", "localhost", 8089, this);

    @Pact(consumer="test_consumer")
    public RequestResponsePact createPact(PactDslWithProvider builder) throws IOException {
        return builder
                .given("weather forecast data")
                .uponReceiving("a request for a weather request for Hamburg")
                    .path("/some-test-api-key/53.5511,9.9937")
                    .method("GET")
                .willRespondWith()
                    .status(200)
                    .body(FileLoader.read("classpath:weatherApiResponse.json"),
                            ContentType.APPLICATION_JSON)
                .toPact();
    }

    @Test
    @PactVerification("weather_provider")
    public void shouldFetchWeatherInformation() throws Exception {
        Optional<WeatherResponse> weatherResponse = weatherClient.fetchWeather();
        assertThat(weatherResponse.isPresent(), is(true));
        assertThat(weatherResponse.get().getSummary(), is("Rain"));
    }
}

如果观察得仔细,你会发现WeatherClientConsumerTest和WeatherClientIntegrationTest很相似。这次我们用 Pact 取代了 Wiremock 来对服务器打桩。事实上消费方测试工作方式与集成测试完全一致:我们用打桩的方式隔离第三方服务,定义我们期望的响应,然后检查我们的客户端可以正确处理响应。从这个意义上讲,WeatherClientConsumerTest本身就是一个狭义的集成测试。这种方式相比使用 Wiremock 好在,它每次运行都会创建一个协议文件(会生成到target/pacts/&pact-name>.json)。这个协议文件使用特殊的 JSON 格式描述了这个契约的期望结果,它可以被用来验证我们打桩的服务与真实服务行为确实是一致的。我们可以把这个协议文件交给提供 API 的团队,他们可以根据这个文件的期望输出来编写提供方测试。这样的话他们就能测试,他们的 API 是不是满足我们期望的所有结果。

消费方通过描述他们的期望结果来驱动接口实现,这就是 CDC 里消费方驱动所想要表达的意思。提供方必须保证他们满足了所有期望结果。没有过度设计,保持简洁。 把 Pact 文件交给提供方团队可以有几种方式。一种简单方式就是把它们加入到版本控制系统里,告诉提供方永远拉取最新的文件即可。更先进一点的方式则是用一个文件仓库,类似 Amazon S3 这样的服务或者 pact broker。起步迅速,按需拓展。

在真实的软件中,你并不需要为一个客户端类既写集成测试又写消费方测试。上面的示例代码同时包含了这两种测试,只是想告诉你这两种测试的写法。如果你想用协议来写 CDC 测试,我推荐你只写消费方测试。两种测试的编写成本是一样的。用协议的方式就有协议文件这个好处,这样把协议文件递交给其他团队,他们就能很容易实现他们的提供方测试。当然这取决于你能说服其他团队也使用协议。如果不行,那么用 Wiremock 来实现集成测试可以作为替代方案。

提供方测试(其他团队)

提供方测试必须由提供天气 API 的团队来实现。我们消费的是 darksky.net 提供的一个公共 API。理论上 darksky 团队会实现提供方测试,以保证不打破他们应用和我们的服务之间的契约。

很明显他们不会关注我们这个简单的示例代码库,也不会为我们实现 CDC 测试。这是公共 API 和组织内微服务的一大不同点。公共 API 不可能考虑到每一个消费方,否则他们就得整天忙于写测试了。而在我们自己组织内,你能够、也应该考虑每个消费方。你的应用一般只会服务于少量的,最多几十个消费方。为这些接口编写提供方测试应该不是太大的问题,这可以保证系统稳定。

提供方团队拿到协议文件后,会在他们的服务上运行一下。这需要实现一个提供方测试,在测试中读取协议文件,打桩隔离掉一些测试数据,运行他们的服务,并检查是否返回了协议文件中期望的结果。

Pact 团队写了一些库来实现提供方测试。他们在主GitHub 仓库写了一个很好的概览,告诉你有哪些消费方/提供方测试的库是可用的,你只需要从中选择适用于你技术栈的即可。

为了简单起见,我们假设 darksky 的 API 也是用 Spring Boot 来实现的。这样的话他们就可以用Spring pact provider来写,这个库和 Spring 的 MockMVC 机制做了很好的适配。我们假想 darksky.net 团队写了提供方测试,那么它大概长这样:

@RunWith(RestPactRunner.class)
@Provider("weather_provider") // same as the "provider_name" in our clientConsumerTest
@PactFolder("target/pacts") // tells pact where to load the pact files from
public class WeatherProviderTest {
    @InjectMocks
    private ForecastController forecastController = new ForecastController();

    @Mock
    private ForecastService forecastService;

    @TestTarget
    public final MockMvcTarget target = new MockMvcTarget();

    @Before
    public void before() {
        initMocks(this);
        target.setControllers(forecastController);
    }

    @State("weather forecast data") // same as the "given()" in our clientConsumerTest
    public void weatherForecastData() {
        when(forecastService.fetchForecastFor(any(String.class), any(String.class)))
                .thenReturn(weatherForecast("Rain"));
    }
}

你可以看到提供方测试必须做的就是两点:加载一个协议文件(例如,用@PactFolder注解来自动加载已下载好的协议文件)、提供需要准备的数据(例如使用 Mockito 来仿造)。此外,不需要再实现额外的测试,它会从协议文件自动派生出来。对于消费方测试里声明的提供方名称(provider name)和状态,提供方测试应该一一匹配。

提供方测试(我方团队)

我们已经看了如何测试我们服务和天气提供方之间的契约。对于这个接口,我们的服务扮演的是消费方,天气服务则扮演了提供方。考虑得更远一些,会发现我们的服务同时也是其他系统的提供方:我们为数个路径提供了 REST API 以供其他系统消费。

我们刚认识到了契约测试什么场景都能用,当然我们也会想给我们的契约写一写契约测试。幸运的是,我们使用了消费方驱动契约,所以我们手里有所有的消费方发过来的协议,可以用它们来实现我们的 REST API 提供方测试。

先把 pact-jvm-provider 库装上:

testCompile('au.com.dius:pact-jvm-provider-spring_2.12:3.5.5')

提供方测试的实现与前面所述的范式相同。为简单起见,我直接从simple consumer拿来一份协议文件放到了我们的仓库中,这会让我们操作起来简单一些。在真实的项目,你可能需要更完善的机制来分发协议文件。

@RunWith(RestPactRunner.class)
@Provider("person_provider")// same as in the "provider_name" part in our pact file
@PactFolder("target/pacts") // tells pact where to load the pact files from
public class ExampleProviderTest {

    @Mock
    private PersonRepository personRepository;

    @Mock
    private WeatherClient weatherClient;

    private ExampleController exampleController;

    @TestTarget
    public final MockMvcTarget target = new MockMvcTarget();

    @Before
    public void before() {
        initMocks(this);
        exampleController = new ExampleController(personRepository, weatherClient);
        target.setControllers(exampleController);
    }

    @State("person data") // same as the "given()" part in our consumer test
    public void personData() {
        Person peterPan = new Person("Peter", "Pan");
        when(personRepository.findByLastName("Pan")).thenReturn(Optional.of
                (peterPan));
    }
}

ExampleProviderTest需要做的事只有一件,那就是根据协议文件里的内容提供State信息。当我们运行提供方测试时,Pact 就会用到指定的协议文件,并发送 HTTP 请求到我们的服务,然后根据我们配置的 State 来决定响应。

UI 测试

大部分的应用都会有些用户界面。在 web 应用的上下文中,我们所谈的界面就是指网页界面。但人们经常会忘记,除了多彩的网页页面,还有许多的 REST API 界面或命令行界面等。

UI 测试测的是应用中的用户界面是否如预期工作。比如,用户的输入需要触发正确的动作,数据需要能展示给用户看,UI 的状态需要发生正确变化等。

有时候,人们提到 UI 测试和端到端测试时(比如 Mike Cohn)说的是一个东西。对我而言,这种观点混淆了这两个有交集的不同概念。

诚然,端到端的测试通常意味着会测到许多用户界面。但是反过来讲却并不能成立。

测试用户界面不必非得通过端到端的方式完成。根据技术栈不同,有时测试用户界面也可以很简单,只需要为前端的 JavaScript 代码写一些单元测试,同时用桩将后端隔离开即可。

对于传统的网页应用,UI 测试可以用Selenium这一类工具完成。如果你把 REST API 也当成一个用户界面,对你的 API 写一些恰当的集成测试可以达到完全相同的目的。

对于网页界面而言,你的 UI 大概可以围绕这几个部分进行测试:行为,布局,可用性,以及少数人认为需要测试的设计一致性。

幸运的是,测试用户界面的行为非常简单。点击一下,输入数据,然后看到用户界面状态如实变更。现代的单页应用框架(以react, vue.js, Angular等为代表)通常都会提供一些工具或组件,帮你从很低的测试层级(单元测试)对界面交互进行测试。即便你没有使用任何框架,只使用纯 JavaScript,也有常规的测试工具(如JasmineMocha等)可供选择。对于更传统一些的服务端渲染应用,使用 Selenium 会是最佳的选择。

测试应用的布局是否前后一致则有些困难。根据应用类型和用户需求的不同,也许你可能需要确保代码的更改不会意外破坏页面的布局。

问题是众所周知…计算机在检查某物「看起来是否不错」方面一直表现不佳(也许未来一些好的机器学习算法可以改善这一现状)。

如果你依然希望在构建流水线中集成自动化的测试来检查应用的设计,还是有些工具可以试一下。大部分的这些工具都是使用 Selenium 帮你在不同浏览器中打开应用、截图、跟之前的截图做对比。如果新旧截图的差异超过了预设阈值,工具就会告诉你。

Galen就是其中一种工具。即便你有特殊的需求,自己实现一套工具也不是很难。我之前工作过的一些团队就构建了lineup,以及基于 Java 的jlineup,用以实现类似的测试工具。如我前面所说,这两种工具都使用了 Selenium。

当你想测试可用性或一些「看起来对不对」的东西时,你已经超越了自动化测试的范畴。这是探索性测试,可用性测试(这甚至可以像走廊测试那般简单)的领域。你需要给你的用户展示产品,看看他们是否喜欢使用它,有没有什么功能会让他们在使用时感到困惑或恼火。

端到端测试

通过用户界面测试一个已部署好的应用,可以说是最端到端的方式了。前面说的以 webdriver 驱动的 UI 测试就是一个很好的端到端测试案例。

(端到端测试测试了整个的、完全集成了的系统)

端到端测试(也被称为广域栈测试)会赋予你极大的信心,让你了解软件是否正常工作。SeleniumWebDriver 协议使你能够针对部署好的服务进行自动化测试,它能启动一个无头(headless)浏览器来对用户界面执行点击、输入、检查状态的操作。当然你也可以直接使用 Selenium,或者用类似Nightwatch这种基于 Selenium 的工具。

端到端测试也有它特有的一些问题。众所周知,它们通常比较脆弱,经常因为一些意料之外的问题挂掉。并且这些错误信息通常不是真正的原因所在。用户界面越复杂,测试常常越是脆弱。浏览器差异、时间(时序)问题、元素渲染、意外的弹出框…这还仅是列表一角,已经让我经常花大量时间进行调试,这实在令人沮丧。

在微服务的世界中,谁负责写这些测试也是一个大问题。因为端到端测试覆盖到数个服务(整个系统),导致编写端到端测试不是任何一个团队的责任。

如果你们有一个集中的质量保障团队,由他们来编写端到端测试看起来就不错。但是呢,拥有一个集中式的 QA 团队同时也是一种明显的反模式,这根本不应该出现在 DevOps 的世界中。你的团队应该是真正的跨职能团队。回答谁该对端到端测试负责这个问题并不容易。也许你的组织里会有一些社区实践,或有个质量协会之类的机构能为此负责。一个合适的答案,与你的组织本身高度相关。

此外,端到端测试还需要大量的维护成本,运行起来也相当慢。试想一下这样的场景,除非只有几个微服务,否则你根本没办法在本地运行端到端测试,因为你需要启动所有的服务。祝你的机器能同时跑起几百个应用,并且内存没被撑爆。

因其高昂的维护成本,你应该尽量将端到端测试的数量减少到最低限度。

考虑一下应用中对用户而言具有高价值的交互。定义好产品产生核心价值的用户旅程,然后将这些旅程中最重要的步骤变成自动化的端到端测试。

举例来说,假设你正在构建一个电子商务网站,最具价值的顾客旅程可能是这样的:用户搜索一件商品、将其加入购物车,然后付款。就这么简单。只要这个旅程正常工作,你应该无需过多担心。也许你可以找出一两个重要的用户旅程,并将其用端到端测试来覆盖。但到此为止,再多的测试就会开始带来痛苦了。

谨记:在测试金字塔里,有很多更低层级的测试,这些测试已经全面测试了各种边缘情况及与其他系统的集成。不需要再在高层级测试里重复测一遍。否则,高维护成本和一堆谎报错误将会降低开发速度,迟早会让你对测试失去信心。

用户界面端到端测试

对于端到端测试来说,SeleniumWebDriver协议是大部分开发者的选择。用 Selenium 你可以选一个喜欢的浏览器,它会自动帮你访问网页、触发点击事件、输入一些数据,并检查用户界面的变更。

Selenium 需要一个浏览器用来运行测试。有几种所谓的驱动(drivers) 可以用来启动不同的浏览器。选一种(或几种)加到build.gradle里。不管选择什么浏览器,都需要保证团队里所有开发者以及 CI 服务器上都安装对应版本的浏览器。保持同步有时相当困难。如果你用的是 Java,有一个轻量级的库叫webdrivermanager,它会自动帮你下载并设置好正确版本的浏览器。把这些依赖加到build.gradle文件里即可:

testCompile('org.seleniumhq.selenium:selenium-chrome-driver:2.53.1')
testCompile('io.github.bonigarcia:webdrivermanager:1.7.2')

为测试运行一个完整的浏览器有时候会是一种麻烦事。尤其对于持续交付跑 Pipeline 的服务器,也许没有时间打开一个包含用户界面的浏览器(例如因为当前没有可用的 X-Server)。变通办法就是使用类似xvfb那样的虚拟 X-Server。(看来得好好了解一下 X-Server 是啥,否则不翻译可能会有问题)

更先进一点的方案是使用无头浏览器(也就是没有用户界面的浏览器)来运行 webdriver 测试。截至目前PhantomJS依然是做浏览器自动化最好的无头浏览器(译者注:其实现在 PhantomJS 作者已经宣布停止维护了,建议转向使用Puppeteer)。但这之后ChromiumFirefox双双宣布他们在各自的浏览器里实现了无头模式,这使得 PhantomJS 突然显得有些过时了。比起仅为了你自己作为开发者的方便而使用人工浏览器,使用用户真正使用的浏览器(如 Firefox 和 Chrome)来做测试应该是更好的选择。

不管是无头 Firefox 还是 Chrome,都是新出的东西,还没有广泛应用到实现 webdriver 测试的场景。这里我们希望简化一些,不去折腾走在时代前沿的浏览器无头模式,而是直接用传统的 Selenium 和一个普通的浏览器。以下是一个简单的端到端测试的例子,它将启动 Chrome、访问我们的服务,并检查页面的内容是否正确:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ESeleniumTest {

    private WebDriver driver;

    @LocalServerPort
    private int port;

    @BeforeClass
    public static void setUpClass() throws Exception {
        ChromeDriverManager.getInstance().setup();
    }

    @Before
    public void setUp() throws Exception {
        driver = new ChromeDriver();
    }

    @After
    public void tearDown() {
        driver.close();
    }

    @Test
    public void helloPageHasTextHelloWorld() {
        driver.get(String.format("http://127.0.0.1:%s/hello", port));

        assertThat(driver.findElement(By.tagName("body")).getText(), containsString("Hello World!"));
    }
}

这个测试只能在你装好 Chrome 的系统里运行起来(本地机器,CI 服务器)。

这个测试很直观。它用@SpringBootTest在一个随机端口启动了整个 Spring 应用。然后我们实例化了一个 Chrome 的 webdriver,告诉它去访问我们微服务的/hello 路径,然后检查浏览器里是不是打印出了”Hello World!”的字样。看起来很酷哟!

REST API 端到端测试

在测试应用时,如果能避免涉及图形化的用户界面,将有望写出比完整的端到端测试更健壮的测试,同时依然能覆盖到大部分的应用。这在测试 web 界面异常困难时大有用处。也许你的应用根本没有界面,仅仅是提供了 REST API(比方说你有个单页应用会调用到这个 API,或单纯因为你鄙视一切好用而漂亮的界面)。不管怎么说,有一类皮下测试仅仅测试图形化用户界面背后的东西,但依然能给你带来足够的信心。如果你也像咱的示例代码一样,只是暴露出一个 REST API,那么这种测试方法就非常合适。

@RestController
public class ExampleController {
    private final PersonRepository personRepository;

    // shortened for clarity

    @GetMapping("/hello/{lastName}")
    public String hello(@PathVariable final String lastName) {
        Optional<Person> foundPerson = personRepository.findByLastName(lastName);

        return foundPerson
             .map(person -> String.format("Hello %s %s!",
                     person.getFirstName(),
                     person.getLastName()))
             .orElse(String.format("Who is this '%s' you're talking about?",
                     lastName));
    }
}

有一个库,在测试提供 REST API 的服务时很好用:REST-assured 。它提供了优雅的 DSL,让你可以优雅地向待测 API 发出真实的 HTTP 请求,并检验收到的响应。 第一件事:把这个库加到build.gradle里。

testCompile('io.rest-assured:rest-assured:3.0.3')

用这个库,我们可以这样实现我们针对 REST API 的端到端测试:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ERestTest {

    @Autowired
    private PersonRepository personRepository;

    @LocalServerPort
    private int port;

    @After
    public void tearDown() throws Exception {
        personRepository.deleteAll();
    }

    @Test
    public void shouldReturnGreeting() throws Exception {
        Person peter = new Person("Peter", "Pan");
        personRepository.save(peter);

        when()
                .get(String.format("http://localhost:%s/hello/Pan", port))
        .then()
                .statusCode(is(200))
                .body(containsString("Hello Peter Pan!"));
    }
}

这里我们还是用@SpringBootTest启动了一个完整的 Spring 应用。我们@Autowire了一个PersonRepository,以便能很容易将测试数据写到数据库里。现在当我们发送了 API 请求,对 Pan 先生打招呼说 hello 时,我们将会收到一个友好的打招呼响应(Hello Peter Pan!)。神奇吧!如果你的应用没有用户界面,那么这个端到端测试就已绰绰有余了。

验收测试——你的功能工作正常吗?

测试金字塔越往上,越有可能需要从用户的角度来测试你所构建的功能是否能正常工作。你可以将应用视为黑盒,然后把测试关注点从这样的方式:

当我输入的值是 x 和 y 时,返回值应该是 z

转变为:

Given:在用户已经登陆

并且有一篇名为“bicycle”的文章的情况下

When:当用户进入了“bicycle”这篇文章的详情页面

并点击“添加到购物篮”按钮

Then:那么“bicycle”这篇文章应该出现在用户的购物车里

对于这样的测试,有时候你会听到像功能性测试或者验收测试这样的说法。偶尔会有人跟你说功能性和验收测试不是一回事。有时这些说法却又是一回事。人们可能对这些说法和定义陷入无尽的争论。这样的讨论经常会导致更多的混乱。

我认为是这样的:无论如何,你总会需要从用户的角度而非仅仅从技术角度来测试软件是否正常工作。你怎么称呼这种测试并不是太重要,写测试本身才重要。称呼随便挑一个,保持后续术语一致,然后就开始编写这些测试吧。

人们时常提及 BDD 及一些相关的工具,它们可以用 BDD 风格来编写这类测试。BDD 或者 BDD 风格写出来的测试较易将你的思维从实现细节转向关注用户需求。你完全可以试一试。

你甚至不必要采用已经十分成熟的 BDD 工具,例如Cucumber(虽然你也可以用)。有些断言库(如chai.js)也支持你使用should-风格的断言,这可以使你的测试读起来更 BDD 一些。即便你不用这样的库,精心组织一下代码也可以使测试专注在用户行为上。一些小巧的 helper 方法/函数就能让你做到这一点:

一个用Python写的验收测试示例

def test_add_to_basket():
    # given
    user = a_user_with_empty_basket()
    user.login()
    bicycle = article(name="bicycle", price=100)

    # when
    article_page.add_to_.basket(bicycle)

    # then
    assert user.basket.contains(bicycle)

验收测试可以有不同粒度的层次。大部分时候它们所处的测试级别都比较高,一般是直接从用户界面上测试服务。不过从技术上讲,验收测试不必总是写在测试金字塔的最高层。如果你的应用设计和场景允许你在低层级写验收测试,那就这样做。把它写成低层级测试要比写成高层级测试好。验收测试的概念——证明在用户视角看来,应用是正常工作的——是和测试金字塔完全契合的。

探索测试

即便是最详尽的自动化测试,它也不是十全十美的。有时你总会在自动化测试里漏掉一些边缘情况。偶尔会出现一些难以单靠单元测试检测出来的 bug。某些质量问题甚至很难从自动化测试的视角被发现(诸如设计和可用性等)。即使对自动化测试抱着崇高的期望,一定程度的手工测试也是不可避免的。

(使用探索性测试揪出构建流水线上没能发现的问题)

探索性测试纳入测试组合里。它是这样一种手工测试法:给予测试者自由,依赖他们的创造性来发现系统中的质量问题。定期花点时间,撸起袖子,试着对你的应用做些破坏性的操作,使其不能正常工作。开动你的破坏性思维,想方设法制造一些问题和错误。然后记录下你发现的所有东西,以供分析。其中要特别留意那些 bug、设计问题、很长的响应时间、缺失或有误导性的错误信息等一切会让作为用户的你感到恼怒的东西。

好消息是,一般你发现的大部分问题都能写自动化测试来覆盖。写自动化测试来覆盖发现的这些 bug,有助于日后的回归测试中不会再重现同样的错误。并且,它能帮你在修复 bug 时,缩小 bug 产生根因的排查范围。

做探索性测试时,你可能会发现一些问题,但它们被构建流水线放过了。不用沮丧,这是关于流水线成熟度的绝佳反馈。收到反馈后,请采取必要的行动:思考一下,做点什么才能避免此类问题以后再次发生。也许是缺失了某一类自动化测试。也许是这个迭代的自动化测试做得马马虎虎,需要测试得更为透彻。也许有好用的新工具或方案可以让你的流水线日后避开这类问题。总之,采取行动是必要的,这样你的流水线,你整个的软件交付将变得越来越成熟。

测试术语的困惑

讨论测试的不同分类总是十分困难。当我在讲单元测试的时候,可能与你理解的那个单元测试有些许差异;对于集成测试而言,可能差异会更大。一些人觉得,集成测试的覆盖面非常广,能测试到整个系统中的很多方面。对我而言它的范围则小得多,每个集成测试应仅仅测试一个与之集成的外部系统。有些人称这为集成测试,有些人则更喜欢称它们为组件测试,还有些人喜欢称为服务测试。很多人会争辩说,这三个术语是完全不同的东西。这里并无绝对的对与错。软件开发社区至今也没法给出关于测试术语的明确定义。

术语含义本身有模糊性,不必孜孜不倦于其中。叫端到端测试也好,广域栈测试也罢,功能性测试也行,都没问题。你认为的集成测试,可能和其他公司的人的认知也不同,这也没问题。当然,如果业界能有一些定义明确的术语并统一语言,那是再好不过了。可惜的是这件事尚未发生。而且在编写测试时会有很多细微差别,它们的范围更像是互相重叠而不是互相离散的,这使得保持术语的一致性更为艰难。

找到适合你和你团队的术语,这就足够了。清晰理解不同类别测试的区别。团队要在测试命名上保持统一,要为每一类测试划分清晰的范围。只要能在团队内部达成一致(或甚至能上升到组织内部一致),你真的不需要关心别的事情了。Simon Stewart在谈到他们在 Google 里的做法时有很好的总结。这篇文章完美展示了,为什么过于纠结名称和命名习惯本身不太值得。

把测试放到你的部署流水线上

如果你正在践行持续集成或者持续交付的实践,那么你会有一条部署流水线来在每一次提交改动时运行自动化测试。通常这个流水线会被分成几个阶段,它们会逐步建立起让你把软件部署到生产环境的自信。听了这么多不同类型的测试,你可能想进一步了解它们在部署流水线中应如何放置。要回答这个答案,你需要思考一下持续交付(实际上是极限编程和敏捷软件开发的核心价值观之一)的其中一项核心价值观:快速反馈。

避免测试重复

现在你已经理解了为何你需要为软件编写不同类型的测试,但是这还有一个陷阱你需要避开:金字塔不同层级进行了重复测试。虽然你本能会说测试太多没啥问题,但我向你保证,会有问题。测试组合中每一个测试都有一定的成本,它们不是免费的。编写和维护测试都要花费时间。阅读和理解其他人写的测试也要花时间。当然,运行这些测试也要费时间。

对于产品代码,你应该力争简洁,杜绝重复。在实现测试金字塔时,你也应该牢记这两条基本法则:

  1. 如果一个更高层级的测试发现了一个错误,并且底层测试全都通过了,那么你应该写一个低层级测试去覆盖这个错误
  2. 竭尽所能把测试往金字塔下层赶

第一条法则很重要,这是因为低层级测试让你更容易缩小错误的范围,并且隔离掉大部分上下文把错误重现。在调试手头上的问题时,低层级测试运行起来更快,没有太多冗余的东西。同时它们也是很好的回归测试。第二条法则很重要,它能保持测试组合快速运行。如果你已经在低层级测试里覆盖了所有情况,那么再维护一个高层级的测试就没有必要了。因为这并不能给你就软件的正常工作带来更多的信心。如果有许多无效的测试,它也会让你的日常工作很恼火。测试组合会因此拖慢节奏,当你改变代码行为时就需要改更多的测试。

或者让我们这样总结一下:如果写一个更高层级测试能给你带来更多的信心,那就写高层的测试。给一个 Controller 类写单元测试可以测试它内部的逻辑。不过它还是没法告诉你这个 Controller 提供的 REST 路径是否能真正响应 HTTP 请求。那你可以上移一下测试层级,多写一个测试来测试这个点——就测这个点,不能更多了。你不需要再测试所有的条件分支和边缘场景,因为低层级测试已经覆盖到了。保证高层级测试仅仅关注低层级测试覆盖不到的地方。

我对于失去了价值的测试很严格,必须消灭掉它们。我会删掉已经被低层级测试覆盖完全的高层级测试(考虑到它们不会再提供额外的价值了)。我尽可能用低层级测试取代高层级测试。有时候,这会有些困难,尤其是你知道设计测试本身就很艰难。警惕沉没成本的思维陷阱,果断摁下删除键。没有理由在不再提供价值的测试上浪费宝贵时间。

译者注:沉没成本是一个经济学概念,沉没成本谬误在这里指的是不忍删除花了时间精力撰写的测试

整洁测试代码

和写代码一样,良好整洁的测试代码同样需要悉心照料。在你于自动化测试之路上继续前进之前,我有些写好可维护测试代码的窍门想告诉你:

  1. 测试代码跟生产代码一样重要。要对它们赋予同等的关注和照顾。“这只是测试代码”不能成为你写出邋遢代码的借口
  2. 一个测试只测试一个分支。这能帮你的测试保持短小,容易理解
  3. “arrange, act, assert”或者“given, when, then”等口诀有助于你写结构良好的测试
  4. 可读性很重要。不要过于追求 DRY。如果能提高可读性,重复有时候也是可以接受的。尝试在DRY 和 DAMP之间寻找好平衡
  5. 如果对于重复有疑惑,试试用Rule of Three法则来决定是不是要重构。重构之前先试用一下

结论

好了!我知道这是一篇非常漫长并且艰深的文章,它解释了为什么我们需要测试,以及如何对软件进行测试的问题。好消息是,这篇文章提供的信息经得起时间推敲,无论你在构建什么样的软件都能适用。不管你是工作在一个微服务项目上,还是 IoT 设备上,抑或是手机应用或者网页应用,这篇文章提供的观点应该都有章可寻。

我希望这篇文章能对你有些帮助。有兴趣你可以去示例代码看看,把这里介绍的一些概念纳入到你的测试组合中。想拥有一套稳固的测试组合确实需要付出努力。但长远来看,它们是会给你回报的,它们会给作为开发者的你带来更多清净。相信我。

致谢

感谢 Clare Sudbery, Chris Ford, Martha Rohte, Andrew Jones-Weiss David Swallow, Aiko Klostermann, Bastian Stein, Sebastian Roidl 及 Birgitta Böckeler 为本文的早期手稿提供反馈和建议。感谢 Martin Fowler 的建议,洞见和支持。


更多精彩洞见,请关注微信公众号:思特沃克

Share

Defects的启示

在过去的几个月,我做了一些实践,通过整理、讨论和分析项目上的Defects情况,来探索质量管理中的待改进点。最终发现,Defects实际上给质量管理带来了很多的启示。

当然,要讨论Defects,首先要使团队对Defects有一致的理解。我查了很多资料,也没有找到对”Defects”一词的明确定义,大部分人将”Defects”等同于“Bug”。

1947年9月9日,Grace Hopper发现了第一个电脑上的bug。当团队在Mark II计算机上工作时,搞不清楚为什么电脑不能正常工作了。经过深度挖掘,才发现,原来是一只飞蛾误打误撞地飞到了计算机内部,从而引发了故障。从此,人们开始用“Bug”(原意是“虫子”)来称呼计算机中的隐含的错误。

然而,一个好的软件产品,不仅要关注功能本身,还要关注其是否好用、是否安全、是否给用户带来良好的体验、是否帮助用户实现真正的业务价值。因此,从狭义上讲,Defects是指软件程序中存在的某种破坏其正常运行的问题或错误。从广义上讲,Defects还包含那些没有达到客户或用户期望的质量问题。具体来说,Defects可以分为以下几类:

  • 程序错误: 指程序中存在某种错误,比如边界、时区等问题,使得系统无法正常工作。
  • 性能问题:指由于性能瓶颈所导致的系统缺陷。试想,作为用户,如果你想要查看一个报表,却需要花10分钟来等待加载,你是否会放弃?
  • 安全问题:指软件安全漏洞,造成信息泄露、或使得系统数据或功能易受攻击。
  • 兼容性问题:指程序无法在不同的硬件平台、操作系统、网络环境等中正常运行。
  • 功能与用户需求不否:指软件功能与用户期望不匹配。比如,用户期望造一个沙发,却交付了个马扎。
  • 交互体验不佳:指用户使用起来不方便。譬如,电梯控制面板上的“报警”按钮和“关门”按钮紧挨在一起,你是否经常由于”关门”而误触了“报警”按钮?再比如,你在网页中填写了一个长长的表单,点击“提交”按钮后,系统提示输入信息有误,却并没有告诉你错误的哪里,你是会不耐烦地从头查阅,还是干脆放弃?

Defects的产生与应对策略

产品质量是团队共同的责任,软件开发是一个过程,任何环节都有可能产生质量问题,但每个环节的问题都应该选择比较恰当的处理方式。

在敏捷开发中,我们以迭代的形式逐步完成产品的开发,每个迭代都能以一个可交付的软件呈现给用户,从而尽早地获得用户反馈,以保证我们交付的软件是用户真正期望的。在每个迭代中,我们所有的开发都基于用户故事卡(Story),每一张用户故事卡都将经历Analyse、Design、Code、Test、Deploy的过程。

那么,在敏捷软件开发过程中,哪些环节都可能产生Defect呢?

正如上图所示,Defect分别来自于Sprint阶段、UAT用户验收阶段以及真正的生产环境。其中,Sprint阶段又细分为:不合理的需求、不恰当的设计、代码及逻辑错误、Story卡测试过程中发现的问题、回归测试中发现的问题、以及非功能性测试发现的问题。

开发过程中不同阶段的Defects,我们分别采用什么样的敏捷实践来应对呢?

上图以看板的形式展示了Sprint开发中Story卡片流动的过程,以及每个环节的敏捷实践,这些实践有助我们发现和改善质量问题:

  • 不合理的需求: 由于QA往往有不同于BA的视角,提早与BA Pair完善Story AC (Acceptance Criteria)。此时发现的问题要及时补充到Story卡上。这样,不仅能够尽早地发现需求上的不合理或遗漏,还有助于QA深入理解需求、设计测试用例。
  • 不恰当的设计:UX制作出酷炫的设计图,却并不一定是用户真正期望的,或者技术实现的成本过高。因此,一方面,要在开发之前与用户Review设计图,并按照用户的反馈及时更新;另一方面,在每一张Story卡开始开发之前,由BA、UX、QA及Dev一起Kick Off Story,通过讨论和澄清,使得团队成员对需求和设计达成一致。一旦发现问题,要及时更新Story卡和设计图。
  • 代码及逻辑错误:单元测试、Code Review、Desk Check都是用来发现代码及逻辑错误的有效手段。因此,开发提交代码后,要先执行单元测试、只有当单元测试通过之后,才可以将代码部署到QA测试环境;然后按照Story的AC逐条与QA和BA进行Desk check。除此之外,开发团队要每天坚持Code Review,以便发现代码逻辑及编码规范方面的问题。这些过程中发现的Defects都应该尽快修复。
  • Story卡测试中发现的问题:Story卡测试时发现的问题,无论其严重程度如何,基本上都要在当前迭代修复。QA可以与Dev面对面沟通,也可以将Defect添加到Story的Comment里面,再将Story重新拖回In Dev状态,或者在物理看板上添加一张物理卡片。但无论哪种形式,都需要在早会时提及,以便有效地跟踪Defect进度。
  • 回归测试中发现的问题:普遍来讲,回归测试发现的问题,优先级要低于Story的开发。因此,QA需要在电子看板或者Defects管理系统中提交一条Defect记录,然后与BA沟通,在最合适的时间Assign给Dev。但如果该Defect造成系统崩溃或者Block了某些功能的使用,就应该立即修复它。
  • 非功性测试发现的问题:非功能性测试一般是在每个Release上线之前做,发现的问题也要在Release之前修复。同样需要在电子看板或者Defects管理系统中提交Defect记录,但要注意其优先级。
  • UAT用户验收阶段的反馈:在UAT阶段,开发团队向用户Showcase,或者由用户来做用户验收测试。此时,用户会提出一些反馈。由QA和BA对这些反馈进行分析,如果是功能层面的问题,在看板上建成卡片,并在上线前修复。如果是需求层面的问题,就将其添加到需求列表中,以便安排在之后的迭代计划中。
  • 生产上的问题:生产上的问题优先级是最高的。但是与用户反馈一样,功能层面的问题要立即修复,用户体验上的问题要添加在需求列表中。

Defects对质量管理的启示

Defects并不是独立存在的,它或多或少反映出了项目管理和开发过程中存在的问题,这些问题都可能对质量产生影响。比如:线上问题的走势,是否能够反映出产品质量的变化;分析每个迭代Defects的数据及产生的原因,有助于发现开发过程中出现的问题,及时地进行风险把控。

我以自己所在项目为例,说一说Defects给质量管理和团队管理带来的启示。

1. 通过线上问题走势,分析产品质量的变化

2017年8月,我们接到A遗留系统,到10月份累计在生产环境发现历史遗留问题21个。按照优先级,每个月修复一定的数量。截止2018年7月,发现的历史遗留问题高达46个,只剩余2个还未修复。Defects数量在减少,产品质量在逐步提升。

除此之外,我们对历史遗留问题和新引入问题做了对比,这10个月的线上问题中,历史遗留问题占85%,新引入问题占15%,可见仍有部分没能在开发过程中发现,使其流到线上。要对这些问题具体分析:其严重程度如何、产生的原因是什么、为什么在开发过程中没有发现、后续有怎样的改进措施。 当然,最好能对生产上的“运维类问题”和“功能类问题”加以区分,以便采取更恰当的改进措施。

2. 分析迭代Defects情况,讨论改进措施

除了分析线上问题,我还对从2017年10月-2018年7月QA提交的Defects情况做了一个统计,观察每个月提交的Defects和修复的Defects情况。

从统计结果来看,2018年7月发现和修复的Defects数量均呈明显的上升趋势,达到历史最高点。因此,有必要对7月份的Defects情况做一个详细的分析,看看究竟是什么原因导致了这些Defects。

我对这些Defects做了一个初步的分类,并利用Retrospective Meeting的机会,与团队成员一起分析讨论。发现产生问题的原因有以下几个方面:

  • 本次Release的Story Kick Off和Desk Check做的不够好。有时候开发没有Kick Off就直接按照自己的理解开始编码,导致团队成员没有对需求达成一致的理解,做出来的功能出现偏差。有时候Dev将一堆卡垒在一起做Desk Check,这样很难逐条覆盖AC,从而将问题流入QA测试阶段。
  • 本次的需求比较偏技术,BA只能从业务的角度去编写Story卡。开发同学为了追赶工期,没能够添加充分的Tech Task, 也没能够坚持Code Review,导致出现一些逻辑错误。
  • 单元测试覆盖率比较低。作为一个遗留的微服务系统,某些服务在之前从未重构过,代码逻辑比较混乱,添加单元测试的难度大、成本高。因此一些本该单元测试阶段就能发现的问题一直流到QA测试阶段。
  • 本次Release一共一个月时间,UI一直到最后一个礼拜才确定下来,期间反反复复的修改不仅花费了太多成本,还消磨了Dev的意志,导致出现一些本不该出现的Defects。
  • 新人加入,项目工期紧,对上下文信息同步不够,导致新开发的内容破坏了一些已经验证过的功能。

这些原因充分说明了这段时间项目中存在的问题,我们对此逐条提出了具体的改进措施:

  • 坚决执行Story Kick Off和Desk Check敏捷实践,在每日站会时严格跟踪每一张Story卡的进度。
  • 预定一个定期会议,每天下午17:00 – 18:00进行Code Review,并每周一人轮班担任Owner。
  • 将单元测试覆盖率可视化。同时,制定项目标准:对于新开发的内容,必须编写并通过单元测试才能Desk Check;对于历史遗留模块,在技术债墙上添加技术债卡片,并于每周消化一个技术债务。
  • 项目开发前期要加强与客户和用户的沟通,在Story开始开发之前,确定好UI设计,开发过程中尽量避免大的改动。
  • 新人加入项目时,采用结对编程的方式完成开发。除此之外,每周在项目内进行一次技术分享Session。

当然,以上两点只是我基于A项目举的一个例子。实际上,Defects还给了我们很多启示,比如,为什么项目老是加班?为什么有些模块的Defects数量比较多?如何根据团队成员花在Defects上的efforts,制定提升计划?然而,每个项目的情况不一样,我们应该基于自己的项目背景,由团队成员一起分析深层次的原因,共同制定切实可行的改进措施,从而不断地提高产品质量。


更多精彩洞见,请关注微信公众号:思特沃克

Share

数字化时代的软件测试

数字经济高速推动着一个无情的市场,所有利益相关者通过设备和应用网络进行交互,一个微观时刻足以让市场领导者摆脱优雅。 这种对速度的痴迷能否淡化质量定性方法?《World Quality Report 2017-1028》带你来一探究竟。

Quality Report

现代QA和测试部门重点关注的领域

敏捷和DevOps已经成为数字化转型的重要工具,同时,质量保障和测试工作也随之发生变化:

  • 中央治理和控制减少,团队选择方法和技术的自由度增大;
  • 部署速度提高和应用程序日益复杂化,软件错误和故障的风险增加;
  • 软件质量对品牌的影响巨大,但这已经不是最高优先级的目标,日趋成熟的尽早质量保障实践可以帮助纠正品牌和形象方面的缺陷;
  • 最终用户的满意度和安全性是最重要的两个方面,要确保应用程序的功能和非功能质量,同时需要找到成本和风险的平衡点。

调查结果表明,现代QA和测试部门需要重点关注的领域是以下三个方面:

1. 智能测试自动化和智能分析

智能测试自动化和智能分析将成为支持测试的关键,因为它们可以实现智能决策,快速验证和自动调整测试套件。测试自动化的范围从简单地将测试活动(计划、设计和执行)自动化发展到自动化测试环境和测试数据配置。

然而,调查结果显示目前自动化还处于不足的状态,尽管从自动化中获益的组织数量在增加,但产生的价值没有根本变化,测试自动化水平仍然很低(低于20%)。

速度将推动更智能的自动化需求,需要找到提高自动化水平的方法。

2. 智能测试平台

智能测试平台需要应对测试环境、数据和虚拟化日益增长的挑战。真正的智能测试平台的远景超越了生命周期自动化,需要实现自动配置的完全自我感知和自适应环境,以及支持自动化测试数据生成和测试数据管理。

测试环境、测试数据和虚拟化是三大挑战,同时也为自动化提供了巨大的机会。结合智能生命周期的自动化,将使QA和测试进入下一个演进阶段,称之为智能QA,这已经成为行业重要的关键成功因素。

3. 适应敏捷开发流程的QA和测试部门

组织需要关注的第三个领域是适应敏捷开发流程的QA和测试部门。在敏捷和DevOps模型中,测试从中心部门转移到分散的团队。未来的测试组织需要将灵活性与效率和重用性相结合,提供测试环境、测试数据、测试专业知识和技能的测试中心将分散到各种业务线的IT团队。

FIGURE 1

QA和测试的现状与挑战

从调查结果,总结出以下关于质量和测试现状的发现:

1. 回归对应用程序质量的关注,表明在敏捷环境的新上下文里,测试已经成熟

面对开发和测试环境的复杂性以及数字化转型的速度,关注点正在回归到整体产品质量上来,这是一个进步的迹象:

  • 参与这次调查的受访者中QA和测试人员明显多于其他角色,由2016年的37%上升到2017年的41%;
  • 2016年被引用最多的目标是在上线前发现缺陷,这个数字从40%下降到2017年的28%;
  • 最终用户满意度从39%下降到34%。

客户体验和增强的安全性处于IT战略的前两位。从2016年到2017年,增强安全性需求从65%大幅下降到35%。 IT成本优化进入今年IT战略的前三位,证明QA和测试能够应对过去几年的快速变化。

其他一些对IT战略意义重大的领域包括对业务需求的响应、实施软件即服务以及实施敏捷和DevOps。敏捷和DevOps实施需求的减少幅度超过一半,从38%的受访者减少到17%,这表明这些开发方法正变得越来越主流。

2. 测试自动化正在通向智慧、智能和认知QA之路

自动化尚处于待开发阶段,测试活动的平均自动化水平约为16%。自动化产生的价值在很大程度上没有变化。测试自动化不仅应该复制现有的手动测试过程,38%至42%的组织将认知自动化、机器学习、自我修复和预测分析视为测试自动化未来的有前途的新兴技术。

智能解决方案是DevOps、移动和物联网中的新趋势。通过增加智能自动化,企业适应快速变化的业务环境能力将得到增强。

Test Automation

3. 敏捷开发中测试的挑战不断增加

  • 99%的受访者在敏捷开发测试中面临某种挑战
  • 46%的受访者认为缺乏数据和环境是最严峻的挑战,这比2016年的43%有所提高
  • 在敏捷迭代中重复使用或重复测试的难度排在第二位,由2016年的40%增加到了45%
  • 挑战数量下降的唯一领域是:难以确定测试的重点以及测试团队在计划或初始阶段的早期参与。

测试和测试环境的自动化将帮助组织解决敏捷和DevOps开发模式给测试所带来的大部分挑战。 这些智能测试解决方案使得质量保障的速度能够适应日益复杂的集成IT环境。

4. QA组织不断演进以满足双峰要求

2017年,集中式的测试组织和分散式模型之间的分配更加均衡。在许多组织中,以前的卓越测试中心(TCoE,Test Center of Excellence)已经过渡到更加灵活的测试卓越中心(TEC,Test Excellence Center),其重点在于支持和赋能,而不是实际执行测试活动。

瀑布式开发仍将在未来很长时间内实施,形成与敏捷和DevOps混合的局面。例如,组织选择定位软件开发测试工程师(SDET)的位置时,其中敏捷Scrum和TCoE分别是36%和47%。

5. 环境和数据仍然是QA和测试的难点

调查结果显示有73%的组织采用云环境、15%的组织采用容器化来执行测试,使得测试的生命周期缩短。然而,仍有50%上下的受访者分别表示在测试环境管理、测试环境利用率、适用于敏捷开发的开发和测试环境,以及早期进行集成的环境方面存在挑战。

在测试数据管理方面,分别有超过50%的受访者存在以下挑战:管理测试数据集的规模、创建和维护合成测试数据、遵守与测试数据相关规定。

Test Environment Management

6. 测试预算下降,但预计会再次上升

专门用于质量保证和测试的IT总支出的比例为26%,它已经从2016年的31%和2015年的35%下降。

但是,随着组织采用敏捷和DevOps来支持数字化转型,未来两年质量保证和测试预算将会增加,企业必须确保IT应用程序的数量和复杂性,以及随之而来的QA平台解决方案的质量。

推荐的应对策略

1. 提高智能测试自动化水平

自动化是满足日益增长的数字化转型测试需求的关键,建议组织制定一个中心战略,确定企业首选的测试工具,确定自动化计划的战略业务目标,并确定衡量结果的指标。

同时,引入基于分析的自动化解决方案,向智能化QA和智能化测试自动化转变,以确保能跟上数字化转型的速度,做到持续的发展。

2. QA和测试部门转型以支持敏捷开发和DevOps团队

首先是组织结构方面的转变,QA需要与Dev和Ops团队一起,构建集成的DevTest平台,以实现持续的测试自动化。

测试人员专业技能也需要有所改变,要加强开发、分析和业务流程方面的技术专长,以适应敏捷和DevOps模式。

3. 投资智能测试和质量保障平台

在日益复杂的IT环境下,智能测试平台有助于企业做好质量保障工作。

  • 将智能分析和机器人解决方案引入测试流程和平台;
  • 提高容器化和虚拟化解决方案的水平和使用;
  • 投资于测试数据生成解决方案,以提供更多更好的符合所有法规的合成测试数据;
  • 将容器化环境,虚拟化服务和自动化测试数据集成到一个共同的可访问流程和平台中,组织可以围绕所有测试活动制定一致的方法;
  • 采用持续监测,预测分析和机器学习工具,利用生产环境数据,提供基于业务风险和实际问题定义测试策略。

平台战略

4. 定义企业级测试平台战略

开源和服务化解决方案给质量保障和测试工具的选择带来了灵活性,但是,跨多个存储库数据连接和交换导致企业级质量状态缺乏透明度。

企业可以实施单一平台战略,指定一些技术为主要选择工具,或者创建最佳工具策略,可以涉及来自不同供应商的多种工具解决方案。

5. 定义企业级QA分析战略

前面提到过智能分析是重点关注的领域之一。为了从智能QA(智能测试自动化和智能测试平台)的投资中获得最佳回报,建议组织确定企业范围的QA分析策略。

这种质量保证分析策略决定了应该部署分析和认知解决方案的目标和领域,定义了跨QA操作的智能技术路线图。质量保证分析战略应与整体组织战略相联系,并应描述其如何实现整个组织目标。

:以上内容和图片均摘自《World Quality Report 2017-1028》,更多详细内容请参考原文。


更多精彩洞见,请关注微信公众号思特沃克

Share