银行组织的敏捷落地

年初在一篇文章《银行IT的敏捷转身》中谈了银行IT普遍面临的敏捷转型问题,主要聚焦于这个数字化时代,银行IT从过去的成本中心,走向科技能力中心的困惑和挑战,文中我指出了敏捷转型绝对不是IT一个部门的事情。可喜的是在这半年的时间里,我们看到越来越多的银行从数字化战略的角度开始整体规划敏捷转型,把敏捷作为迈向数字化的一个坚实基础来抓。

在经历了几家银行大刀阔斧的改革后,我希望能够把敏捷落地这个话题放到银行整个组织下来跟大家分享几点心得,借机提醒正在高歌猛进的组织不要忘了初心,也为正在规划转型的组织提供一些前车之鉴。 利用敏捷宣言的模式,我总结了四个方面:

  • 敏捷文化 over 敏捷开发
  • 实验探索 over 创新项目
  • 平台思维 over 微服务架构
  • 员工体验 over 开放办公

我们应该都意识到了排比句的右手边是这个数字化时代敏捷落地的一些核心领域,但我希望通过这样的对比,强调组织级敏捷落地中左手边领域的重要性。在下文中我将逐一展开这四个对比,帮助大家理解转型过程中的一些核心关注点。(点击此处或扫描二维码观看视频回放)

敏捷文化 over 敏捷开发

金融是一个强监管和强合规的行业,在中美贸易战和P2P暴雷遗患未除的当下,监管肯定是不会松绑的。某种意义上这是合理的,有多少客户能够接受自己在四大行买的现金理财产品出现了本金亏损(即使购买时风险已经告知)?如果这种情况出现在我父母身上,他们有可能就报警了。

这样的行业管理模式下必然驱动出统一、标准化的服务(产品)开发过程及方法,以及随之配套的企业文化。在面对不确定性市场时,这样的方法和文化的弊端被放大了,没有办法快速响应变化,更无法激发创新。这是大多数银行开始走向敏捷的原罪。

经过最近20年的演进,敏捷(软件)开发实际上已经有了一套比较体系化的方法。Scrum、Kanban及XP的实践都得到了广泛应用,在《ThoughtWorks的敏捷开发》一文中我也总结了这10多年来ThoughtWorks全球形成的敏捷开发方法的体系构建及关键实践。

那么银行作为一个组织的敏捷转型,是否就是要把过去的开发过程和方法,转变成上面提到的敏捷方法,并形成自己组织内部的统一实践呢?答案自然不是。我们要解决的原始问题是如何建立对市场变化的快速响应,并能够激发组织内部的创新。我们的目标并不是要用一套大一统的“敏捷方法”来取代过去的传统方法,我们需要的是组织文化上的敏捷性,能够持续学习和改善。

就中国的大多数银行来讲,这意味着可能有多种软件开发过程和方法,甚至于在一些传统核心应用里仍然使用瀑布过程作为流程主干。当然这里并不是预定解决方案就是“双模”,从银行自身业务发展出发,谁说不可以“三模”、“四模”呢?

经过四年多的实践,某大型国有银行软件开发中心就形成了三种敏捷开发模式(开发敏捷、全流程敏捷和端到端敏捷),为中后台团队、网络金融和互联网创新分别提供了实践的牵引。如果从教条主义出发这是值得批判的,为啥不全都是端到端敏捷?但从现实的行业和企业生存环境出发,这样的敏捷落地是务实的。四年时间里我见证了该组织员工敏捷认知上的持续进步,在强监管的约束下,通过多种模式创造了时代需要的组织灵活性。从这一点出发,这样的做法和大刀阔斧的组织变革同样值得尊敬。

我们需要拥抱变化和持续改进的敏捷文化,而不是所有产品整齐划一的“敏捷”开发模式。

实验探索 over 创新项目

由于FinTech的冲击,各大银行纷纷启动了创新机制,有的甚至成立了单独的创新中心。科技创新在银行业成了最为重要的企业战略话题,各家银行的网点里目前都已经摆满了全自助的柜员服务机,有的大堂里已经开始有服务机器人在主动迎宾。

创新同样是敏捷落地过程中一个不可避免的问题,甚至在不少银行成为发起敏捷转型的原动力。在一家致力于金融科技引领的大型股份制银行的转型过程中,敏捷开发模式成为了FinTech创新项目的必选项。但除了更“快”,大家似乎都没有找到创新和敏捷的必然联系,只是因为希望创新产品快速上市,所以认为必然是敏捷的。

在这家股份制银行的一次FinTech创新项目提案评审会议上,CIO的一个问题触动了我的思考。在各个创新团队争相汇报自己的创新产品取得的成果后,CIO停顿了几秒钟,说到:“我希望大家以后不要每次都出来讲自己的创新如何成功,取得了如何的成绩。我希望大家都讲讲自己在创新的过程中遇到了什么问题,通过用户实验验证了哪些错误的假设,并谈谈怎么改进的。”

是啊,既然是创新,那就是在实验,而实验失败应该是十之八九的事情吧。如果永远都是成功,可能如这位CIO接下来点评的:“大家都没有创新,只是在延续已有业务而已。”而接受实验失败,并把失败作为一次重要的学习机会,在目前银行业里仍然十分少见。没有这样的试错文化,可能下一个“支付宝”仍然不会出现在现有的银行体系里。

我们需要通过科学实验来验证业务想法,而不是制造一堆只能成功的“创新”项目。

平台思维 over 微服务架构

金融服务已经完全依赖于数字化渠道了,各家银行都意识到了IT系统的重要性,拼命加大科技方面的投入。由于很多互联网企业的示范作用,微服务化架构也进入了银行科技的愿景里,期待着云时代能够通过微服务构建灵活的系统架构,从而能够支撑新服务和产品的高效敏捷开发。

于是很多银行都开始拿出不同的应用进行微服务改造,希望通过试点建立自身微服务架构的能力,逐步让更多的应用“微服务化”。国内银行显然没有时间等着一个一个应用的试点,于是往往会挑选不同业务领域(如零售和对公)的应用同时进行改造。然而,完成微服务拆分后,根本没有人会跨业务的审视大家在服务层面是否有共性需求,我们希望的复用性自然也就不会发生。这些服务未来可能也仅仅是一个应用改造后的“模块”,而不是真正为多项业务持续使用的“活着”的服务。

在此基础上,不可避免的需要构建一套微服务开发框架,直接采用开源框架对于银行来说还是很难满足其监管要求的。在设计和开发这个框架的过程中,我们最常听到的就是如何能够把各种服务管理述求(从注册到安全)都植入到框架里。这是一个似曾相识的故事,结局可能是一个复杂难懂的框架,看似开发工作量(代码行数)少了,但却给开发人员带来了痛苦的体验,以至于一有机会大家都会想办法绕过框架。

这些问题的解决必须依赖于我们思想观念的转变,新的平台思维是我们需要去拥抱的。我们这谈的并非是阿里提出的中台,而是从过去软件应用框架平台到数字化能力平台的转变,这个转变带来了三个方面的显著变化:

平台的“客户”是我们的开发人员。这里的开发人员是广义的,比如在数据分析领域,未来的银行业务人员也是开发人员。这个能力平台必须要关注开发人员,即客户的体验!

平台是持续演进的“活体”。平台上每种能力都为不同的业务应用提供着支撑,并且是持续完善的。我们不会像过去应用框架开发一样集各种述求于一身,设计就需要大半年。

平台是自服务的。开发人员不需要读上百页的技术文档,或demo项目来理解怎么使用平台能力。感谢互联网,已经为我们做出了这样的表率。

我们需要一个能够持续积淀的数字化能力平台,而不是一堆各自为战的微服务。

员工体验 over 开放办公

我经常玩笑说组织转型有两个非常好的破旧立新的契机:一是组织结构的调整,二是办公室重新装修。后者毫无意外已经成为了银行组织敏捷转型过程中的常规武器,通过打造不一样的工作环境,来促进员工之间更多的沟通和交流。

在敏捷倡导的协作和信任模式下,大部分重新装修都会选择开放式办公环境,即每个员工不再有自己的小格子间,甚至不会有自己的固定工位。这样的好处自然是我们可以更方便地让一个团队的员工们坐到一起,形成更紧密的团队协作氛围。

多年的顾问工作让我习惯了“居无定所”,每次走到客户的开放办公环境自然感觉非常适应。但也有那么几次走入新装修的彩色环境时感觉莫名的不快,所谓的开放办公桌比之前的格子间更为拥挤了,桌上一个显示器挨着一个显示器。整个场地没有几个会议室,都摆满了长条桌,团队站会都显得非常局促。这让我想起了多年前一家创业企业负责人在参观了ThoughtWorks北京办公室后跟我说的一句玩笑话:“这样的开放布局不错,单位面积里能多坐不少人,还能时刻监视每个人!”搞得我忙解释,其实我们的人均员工空间是行业普遍水平的一倍多,并且也没有人会去监视别人。

(你听说的开放办公 vs 你经历的开放办公)

这样假借开放之名来“提高”场地利用率的情况现在也正在发生着。值得提醒有类似考虑的管理者,别忘了选择开放环境的初心。我们在给团队提供更紧密协作空间的同时,也需要考虑团队的私密空间,这要求不同团队之间有一定的空间隔离,也要求足够的会议室来支撑时常需要进行的小范围协作会议。开放空间的设计不是在大平桌上整齐地排列一台挨着一台的电脑和显示器,而是更加全面的思考团队沟通协作的需求,更多的可视化空间及移动办公设施。

而这一切都是为了更好的团队和员工体验,让大家能够在安全放松的环境下去思考和碰撞,从而能够激活整个组织,创建生机型文化。借用西方管理哲学里常用的一句话:只有愉快的员工,才会有愉快的客户!

我们需要一个能够让员工感到安全和放松的团队工作环境,而不是一个为了提升利用率而拥挤不堪的“开放”场地。

银行组织的敏捷落地正在发生着,文中四点显然无法涵盖转型工作的方方面面。如开篇讲到的,我希望在帮助银行数字化转型的过程中,持续把自己的经历和观察总结分享出来,促进我们的银行业在数字化的进程中变得更加开放,从而能够碰撞出真正的创新。

Share

Lightweight Architecture Decision Records | 雷达哔哔哔

写在前面

ThoughtWorks每年都会出品两期技术雷达,这是一份关于科技行业的技术趋势报告,在四个象限:技术、平台、工具以及语言和框架对每一个条目(Blip)做采用、试验、评估、暂缓的建议。(参考阅读:解读技术雷达的正确姿势

一直以来,我们都未对每一个Blip做进一步的解读,而这次决定尝试一个新的专栏——《雷达哔哔哔》,由作者根据自己实践与理解,对雷达中部分条目作出解析,致力于用一篇篇短小精悍的文字,帮助读者加深对雷达的理解。

今天是《雷达哔哔哔》的第一篇,Blip是Lightweight Architecture Decision Records

位置

2018年5月第18期技术雷达,技术象限,建议试验

目标受众:

  • 系统架构师,技术管理者,开发设计人员

关注问题:

  • 传统的重文档编写维护量大,随着业务发展,很难保持同步
  • 在一些敏捷项目中,随着关键文档的缺失、项目Knowledge及决策丢失导致长生命周期的项目知识传递问题

解决方案:

  • 使用“ Lightweight Architecture Decision Records”(轻量级架构决策记录)来记录项目的重要决策,并将其与代码等其他项目资产一样,纳入到版本控制系统之中。

解读:

“项目要不要写文档”一直是一个很有争议的问题。在过去,项目一般都要写众多的文档,类似于需求说明书、概要设计、详细设计、数据库设计、等balabala设计……而这些文档的作用往往只是为了【过评审】或是【招投标】,写文档的形式也更多是简单的复制粘贴。

项目拿下了,过审了,一旦开动起来,文档反而就被束之高阁,再也无人过问了。

很多人没日没夜地写着千篇一律的文档、文档、文档……终于有一天,盼来了敏捷,并看到了敏捷宣言中硕大的一句:

(敏捷宣言)

唉呀妈呀,终于见到了亲人,从此高举着敏捷的大旗,与文档势不两立。

再有人敢提起写文档,就把早已准备好的“敏捷大棒”从身后掏出来,劈头就是一棒槌……

不得不说,敏捷又一次背了口黑锅。敏捷宣言所推崇的并不是简单的不写文档,而是认为之前那种写文档的方式根本没有体现出其应有的价值。还 不如代码写的漂亮些,测试写的完备些,让代码和测试成为真正有价值的活文档。

而这,相对于简单的复制粘贴攒文档,对于团队的要求反而更高了。

世间万物,物极必反。

随着时间的推移,再好的敏捷团队也会出现知识流失的问题,尽管有着完备的测试和易读的代码,但这些毕竟过于细节,无法快速还原当时设计或重构时的所有上下文。

所以技术雷达推荐使用“ Lightweight Architecture Decision Records”来记录项目的重要决策,相比于传统文档,它最大的特点就是轻量(Lightweight),关注于创造价值而不是遵循流程。 让我们看个ADR的模板:

(ADR Template)

同时技术雷达也建议我们不要将ADR束之高阁、放到Wiki或是文档库中。而是随着代码放到Git或其他版本控制工具里,这样既可以保持最大程度与代码同步,又能跟踪Decision的变更历史。

推荐的Adr-Tools工具,可以帮助我们更容易的做到这些。

相关Blip及延展阅读:

工具:

GitHub – npryce/adr-tools: Command-line tools for working with Architecture Decision Records

Share

在 Windows 上可以用 Docker 吗?

Docker,或者准确一点说,容器技术,在近几年里几乎成为了应用分发和集群部署的默认技术了。背景部分,如果感兴趣,请参考闲谈集群管理模式一文。Docker 生态的成熟还有赖于其周边工具和实践模式的兴起。比如,曾经雨后春笋般出现的编排技术,以及基于容器技术的 DevOps 实践大规模地开展。

那么这么好的技术,在 Windows 上能用吗?在各种场合,都有人与我讨论这个的话题。每次听到这样的疑问,我也是很无奈的。毕竟,只要稍微搜索一下,就不难回答:是可以的。不过,深入想一下,人们有这样的疑问也是有道理的:毕竟 Docker 是起源于 Linux 上的技术。

Docker 是基于 Linux 内置的 Namespace 和 CGroup 等系统内隔离机制而抽象出来的一种轻虚拟化技术。与虚拟机相比,它以一种轻量级的方式实现了运行空间的隔离。如果物理机是一幢住宅楼,虚拟机就是大楼中的一个个套间,而容器技术就是套间里的一个个隔断。不难理解,Docker 作为一种隔断,它并不能基于一种内核(Linux)提供另一种内核(Windows)的虚拟化运行环境。所以,基于 Linux 的 Docker 是不支持运行 Windows 应用的。

早在 Docker 之前,Linux 就已经提供了今天的 Docker 所使用的那些基础技术。当年 Docker 仿佛一夜之间突然火爆全球的背后,技术上的积累并不是瞬间完成的。这一切在 Windows 上显得有些滞后。在 Docker 已经众所周知的时候,Windows 系统却根本没有类似的机制,更别提 Windows 独有的工具链和实践方法了。所以,我们看到,早期 Windows 与 Docker 的交集只是为其提供应用开发环境。

boot2docker 与 Docker for Windows

可以在 Windows 开发面向 Docker 部署的应用程序——Windows 的桌面体验比 Linux 好太多,所以很早就出现了在异构操作系统上以虚拟机的形式运行 Docker 的项目出现,也就是 boot2docker。它既支持 Windows,也支持 macOS。

后来,Docker 公司开始推出自己的 Docker for Windows 工具包,它旨在为开发人员在 Windows 上开发面向 Docker 的应用程序提供完整的工具链,其中包括运行环境、客户端,Docker Swarm 编排工具和其他工具。Docker for Windows 中负责运行环境配置的工具是 Docker Machine。与 boot2docker 类似,Docker Machine 也会在 Windows 上创建一个 Linux 虚拟机,用于运行 Docker 引擎。也就是说,这个环境也只支持 Linux 的应用程序格式的,并不支持 Windows 应用程序的运行。

(在 Windows 上运行的 Docker for Windows(图片来自 Docker 文档))

Windows 容器技术

正当 Linux 世界的容器技术借着 Docker 的东风刮遍世界的时候,Windows 系统也发现了容器粒度的重要性。

微软与 Docker 在 2014 年宣布了合作,以期将容器技术带到 Windows Server 操作系统,并为传统的 Windows 应用程序的容器化改造提供更直接的支持。不久之后,微软在 Ignite 2015 上宣布将推出为容器优化的 Windows Nano Server;第一次 Windows 容器真正与与开发者见面是在 Windows 10 的年度更新(2016.8)上,它正式提供 Windows 容器的开发环境。在 2017 年 10 月发布的 Windows Server 1709 版本包含了 Windows 容器,意味着这项技术可以用于生产环境了。Windows 容器是真正能够运行 Windows 应用程序的容器技术,包括依赖 IIS、注册表等大量 Windows 特性的应用程序都可以在 Windows 容器中运行。

虽然 Windows 对容器的支持有些姗姗来迟,但社区对 Windows 容器的关注和运用却是异常活跃。这主要得益于容器技术本身生态的成熟,一来人们对这项技术已经有了充分的认知,同时周边工具和实践都已经日趋完善。另一方面,在与 Docker 公司一同打造这项技术的过程中,也注意了与已有技术的兼容性。人们发现,在电脑上启用 Windows Container 功能之后,接下来的操作步骤仍然是基于 Docker 客户端完成的,命令行参数与 Linux 上的 Docker 也没有区别。

几乎与 Windows 容器技术本身日趋成熟过程的同时,周边工具对 Windows 容器的支持也在同步完善。Docker for Windows 在新的版本中添加了一个贴心的菜单,可以一键切换 Linux 容器和 Windows 容器;Kubernetes 从 1.5 版本开始增加对 Windows 容器的支持;云环境方面,包括 Azure 和 AWS 在内的众多云环境都第一时间提供了 Windows 容器的支持……

Windows 容器架构

Windows 是如何既提供自有容器技术,又提供与 Docker 兼容的操作接口的呢? 下面的上图是 Linux 容器的架构,下图则是 Windows 容器的。可以发现两者结构很类似。与 Linux 类似,Windows 也新新抽象出来了 CGroup 和 Namespace 的概念,并提供出一个新的抽象层次 Compute Service,即宿主机运算服务(Host Compute Service,hcs)。相较于底层可能经常重构的实现细节,hcs 旨在为外部(比如 Docker 引擎)提供较稳定的操作接口。hcs 的操作接口目前有 Go 语言版本,以及 C# 语言版本,前者目前在 Docker 客户端中用来操作 Windows 容器。

(图片来自 Black Belt 在 DockerCon 的演讲:Docker 与 Windows 容器揭秘

容器镜像方面,微软自己提供了 Server Core 和 Nano Server 两种服务器版本。Server Core 可以理解为 Windows Server 去掉了 GUI 的部分,因此功能更完整(比如包括文件服务器、DNS 服务器等功能),同时镜像大小也更大(2GB~5GB);而 Nono Server 则是专为容器优化的迷你型系统,只包含有核心的 Windows 服务器功能,镜像大小为(130MB~400MB)。基于基础镜像来构建自己镜像的方法与 Linux 镜像是一样的,所以 DockerFile 文件的格式和语法并没有不同。

授权方面,只要用户已经取得宿主机的授权,微软并不会单独向用户收取容器镜像的授权费

小结

容器技术本身以及围绕它的一系列工具和实践让应用程序的打包和发布变得标准化,很大程度上可以消除应用程序对特定环境的依赖,进而为高效的集群化部署和运维提供有力保障。作为容器技术的代表,Docker 可以以两种形式运行在 Windows 上:以 Hyper-V 虚拟机的形式运行 Linux 格式的容器,或者运行原生的 Windows 容器。其中前者运行 Linux 格式的应用程序,后者能运行 Windows 应用程序。如果稍微用一点技巧,还可以让这两者同时运行在 Windows 电脑上

Windows 10 和 Windows Server 都提供了对 Windows 容器的支持,各种容器化工具对 Windows 容器的支持也在日趋完善当中。基于 Windows 开发新的应用时一方面可以优先考虑跨平台容器化部署的能力,另一方面也可以与存量应用程序一样考虑借助 Windows 容器技术实现容器化、云原生的特性。


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

Share

ThoughtWorks的Professional Service职业发展|MD脑洞

一年一度的Review又来了,咱们趁这个机会聊聊PS(研发人员)的职业发展。都说ThoughtWorks是一个相对扁平的组织,不过扁平组织给大家带来的一个问题就是职业成长路径很模糊,而且似乎升职的空间有限。

升值vs升职。这两者都很有价值,不过我们更看重前者。这里的升值指的是市场价值。我们希望ThoughtWorker在公司的经历不仅仅是一个为社会、客户和公司创造价值、产生影响的过程,同时是一个不断提升自身市场价值的旅程。升职有时候是对某种升值的验证,虽然并不总是如此。

不可否认,市场上仍然有不少规模巨大的组织,依靠数十级的层级结构来维系运营的执行效率,并为个人提供职业发展的方向和阶段性的认可。但是,越来越多的组织开始将决策点不断推向业务前沿,以面对正变化日趋快速的市场、更新愈加急速的知识。跟过去不同,一个合格的一线团队领导人,除了专业能力外,还被要求具备更加全面的业务能力。

在过去几年里,我们看到越来越多的一线Lead承担独立面对客户的角色,他们需要引导专业背景和角色立场各异的客户高管在一起完成项目工作坊;需要在复杂的客户组织中捭阖纵横,达成推动变革的目的;需要带领团队完成对业务战略至关重要的项目,同时帮助团队成员提升能力,拓宽他们的发展空间。

这些经历都将助力他们形成独当一面的能力。当我们的同事从公司离开,公司的经历也让他们迈上新的台阶,他们的新角色有技术VP、技术总监、CTO,还有总经理。在海外,这样的例子更多。

如何升值?这就说到了ThoughtWorker的成长。一个人的成长主要在于两个方面:

前者容易理解,基本就是在专业领域提出问题、搞定问题的能力。后者大家可能比较陌生,社交能力比较复杂也难以评估,人们平时常用“情商”一词笼统概括,包括了认知、行为、情绪等各方面能力,甚至还有动机、价值观等因素。社交胜任力跟人的家庭、社会成长背景,以及经验和成熟度密切相关,通常不是一朝一夕能有所改变的。

除了自身持续强烈的学习和成长欲望外,这两个胜任力的成长都需要经验的滋养。经验来自于时间和机会。就我个人而言,ThoughtWorks对我个人成长最大的帮助来自两个方面。前四五年是不同类型的工作机会,让我在专业和社会胜任力上打下了一个还不错的基础,而后面几年则给我提供了一个平台,让我能够发挥自己的能力和伙伴们一起去推动和成就一些事情。加入ThoughtWorks之前我换工作还挺频繁的(可能跟现在的年轻人比还有差距),目的就是希望让自己能够尝试些更多不同的经历。加入公司之后,先是在办公室里做了两年的海外市场交付。然后在外面飘了两年,出入各地汉庭,在不同客户那里做咨询。后来因为原来的运营负责人转去英国任职,被公司从客户现场召回公司做人员项目安排和运营相关的工作。跟冲在一线时相比,这一年多蹲在办公室的经历,让我从另外的视角对公司业务产生了很多不同的了解。随着国内市场优先级的提高,我又领命去推动国内市场开拓的战略,直到郭晓去总部当全球CEO。

我的那本《精益软件度量》就是在那个时候写的,当时还指望成为中国的Jim Highsmith呢,不过现在看来好像没戏了。最近两年全力投入在ThoughtWorks中国特别是国内市场的发展当中。坦白来讲,跟其它跨国公司的中国区负责人相比,ThoughtWorks的区域领导者得到的授权之大是非常少见的。我们能够发挥自己的能力推动我们认为正确的事情,也因此获得了更多的锻炼和成长。不同的路径,却有些类似的经历也可以从夏洁的《十年》一文中看到。

由此可见,ThoughtWorks在职业发展上关注的是平台和机会,而不像很多公司说的通道。当评估是否适合自己的职业发展时,平台是可以根据公司的业务和组织看到的,机会则有着很大的随机性。不过虽说有不确定,东方不亮西方亮,老话说得好,机会总是为有准备的人留着的。在我们这样一个快速变化和发展的组织里,组织创新和业务创新将持续发生,总会有些机会浮现出来,落到准备好离开舒适区,愿意付出额外努力的人身上。为了提供一些组织的上下文,我在下图大致整理了一些已经可以看到的发展机会。

图中的BUx是承担业务目标的团队,现在我们的成熟业务单元包括海外和国内交付,以及咨询团队。我们还在孵化和培育正在成长的业务,比如应用和基础设施管理业务团队,数据和智能业务团队, 还有那些未来浮现出来的业务线。

除了业务单元以外,社区(Community)是一些专业能力团队,比如UX。这些团队是以Community of practice的方式组织,在特定领域共同提升技能,创造知识,发挥影响力。

我们可以看到。由于业务类型和复杂度的增加,公司内部出现了不同类型的职业发展机会,而不同的服务模式对胜任力也提出了各种不同的要求。跟以往相比,这种要求主要体现在两个方面。

其一是专业化能力向纵深发展。扁平组织倾向于产生通才而不是专才,因此在ThoughtWorks我们特别强调个人和团队应该发展多面手的能力。但是我们如果希望能在专业上征服客户,赢得社区和行业的尊重,就需要在多个领域拥有定义问题,解决方案,实施执行三个层面的能力。这需要精深的知识,长期的积累和刻苦的专研。

于是我们根据业务模式和所需能力模型的差异,建立了不同的业务线,建立相应的专注、专业的团队,同时也是希望给同事们在成长方向上有不同的选择。这样ThoughtWorker们不仅可以在自己的优势领域和团队持续发展,还可以在BU(业务部门)之间轮转,获取不同类型的能力和阅历,拓展视野和发展空间。

其二是带领一个团队开拓新局面的能力。工程师文化似乎给人的一个暗示——不鼓励大家当领导。然而从公司角度却面临一个两难的境地:一方面缺乏团队和运营管理方面的资深人才,另一方面似乎不少人却又看不到自身发展的机会。

事实上,工程师文化并不与鼓励leadership相矛盾。如果看过《Show Stopper》里的 David Cutler,这位缔造了DEC VMS和Windows NT的传奇程序员,推动Windows NT成为那个时代最具竞争力的操作系统,这个过程中展现了惊人的领导力。他在2012年以70岁高龄仍在Azure云和Xbox一线贡献代码。

我们很多同事没有意识到,其实自己正在从事的工作已经有了领导力的要求,因而缺乏意愿和能力通过发挥他人的能力和努力创造更大的价值,也还没有意识到培养别人,特别是培养自己的第二梯队。发挥团队的力量,丰富对公司发展至关重要的人才池,其实对于自己在发展路径上走到下一步至关重要,不管你是打算进入下一个关键角色,还是摆脱当前其实不是自己兴趣所在的角色。我们希望有更多的Leader出现,为自己、业务和公司打开一个新局面。https://insights.thoughtworks.cn/wp-content/uploads/2018/10/ps职业发展-2.jpg 最后,我们也都知道,当我们的组织成长,规模扩大的时候,我们的结构也在扩大。那么我们的组织形式将向何方演进呢?我们可以往更多层级的树状方向发展,也可以建立网状的内部市场,朝着小单元、小部落的方向发展。什么是合理优化的组织结构不在本文的讨论范围之内,我个人的判断是,我们最后采取的发展方向将很可能是一个混合的结构。这意味我们将会产生一系列有一定自主能力的小型组织,而且这些组织也会随着业务的发展可能会合并或拆分。但从运作机制来讲,全局优化胜过局部优化,公司使命胜过经济驱动,这两条基本的考虑使得这些组织之间的关系跟完全靠经济和市场机制协调的组织不同,比如现在沸沸扬扬的海尔新组织策略(参见:海尔式大跃进:组织创新的陷阱)。

公司会在能力建设和发展方面进一步探索有效的方式,为大家提供支持,也会继续发展多个差异化的业务模式和业务线团队,让大家能够有机会选择适合自己发展的方向。希望大家在这个演进过程中抓住机会,找到自己的空间。同时,也想问问大家,在追求个人发展和公司平台/机会对接时,大家遇到的最大的困难和挑战是什么?

最后,我在不少问题上也希望听听大家的意见。

MD心声

  • 在我们的组织里有时候缺乏直接、稳定的boss,谁对我负责,谁来支持每个TWer的成长?
  • 缺乏明确的管理和责任链条,服务质量经常因人而异缺乏一致性,怎么破?
  • 我们对每个人角色定义很模糊,这带来对个人期望的模糊性。那么做到什么是好的,什么情况是不够好的,这之间的差别有很多争论的空间?怎么办?
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