持续交付:发布可靠软件的系统方法(七)
- 第 7 章 提交阶段
- 7.1 引言
- 7.2 提交阶段的原则和实践
- 7.2.1 提供快速有用的反馈
- 7.2.2 何时令提交阶段失败
- 7.2.3 精心对待提交阶段
- 7.2.4 让开发人员也拥有所有权
- 7.2.5 在超大项目团队中指定一个构建负责人
- 7.3 提交阶段的结果
- 7.4 提交测试套件的原则与实践
- 7.4.1 避免用户界面
- 7.4.2 使用依赖注入
- 7.4.3 避免使用数据库
- 7.4.4 在单元测试中避免异步
- 7.4.5 使用测试替身
- 7.4.6 最少化测试中的状态
- 7.4.7 时间的伪装
- 7.4.8 蛮力
- 7.5 小结
第 7 章 提交阶段
7.1 引言
当更改项目状态(向版本控制库的一次提交)时,提交阶段就开始了。当它结束时,你要么得到失败报告,要么得到后续测试和发布阶段可用的二进制产物和可部署程序集,以及关于当前应用程序状态的报告。理想情况下,提交阶段的运行应该少于五分钟,一定不会超过十分钟。
从许多方面来看,提交阶段都是部署流水线的入口。它不但是候选发布版本的诞生地,也是很多团队实现部署流水线的起点。当团队使用持续集成时,就会创建这个流程中的提交阶段。
这是极其重要的第一步。提交阶段的使用能确保项目花费最少的时间做代码级别的集成。它能驱动一些好的设计实践,并且对代码质量和交付速度产生很大影响。
提交阶段也是应该开始构建部署流水线的起点。
在第3章和第5章中,我们已经简单地介绍了提交阶段,而本章将更详细地讨论如何创建有效的提交阶段和高效的提交测试。可能主要感兴趣的是开发人员,因为他们是从提交阶段得到反馈的主体。提交阶段如图7-1所示。
在这儿提示一下提交阶段是怎样工作的。当某人向版本控制库的主干上提交了一次变更后,持续集成服务器会发现这次变更,并将代码签出,执行一系列的任务,包括:
- 编译(如果需要的话),并在集成后的源代码上运行提交测试;
- 创建能部署在所有环境中的二进制包(如果使用需要编译的语言,则包括编译和组装);
- 执行必要的分析,检查代码库的健康状况;
- 创建部署流水线的后续阶段需要使用的其他产物(比如数据库迁移或测试数据)。
这些任务由持续集成服务器通过调用相应的构建脚本组织在一起。关于构建脚本化详见第6章。如果该阶段成功了,二进制包和结果报告就被保存在你的中央仓库中,以供交付团队和部署流水线的后续阶段使用。
对于开发人员来说,提交阶段是开发环节中最重要的一个反馈循环。它会为开发人员引入的最常见错误提供迅速反馈。提交阶段的结果是每个候选发布版本的生命周期中一个重大的事件。这一阶段的成功是唯一进入部署流水线,启动该软件交付流程的途径。
7.2 提交阶段的原则和实践
如果说部署流水线的目标之一是消除无法在生产环境运行的构建的话,那么提交阶段就是“门卫”。提交阶段的目标是在那些有问题的构建引起麻烦之前,就把它们拒之门外。提交阶段的首要目标是要么创建可部署的产物,要么快速失败并将失败原因通知给团队。
接下来,我们讨论建立高效提交阶段的原则和实践。
7.2.1 提供快速有用的反馈
提交测试的失败通常是由以下三个原因引起的:
- (1) 由于语法错误导致编译失败;
- (2) 由于语义错误导致一个或多个测试失败;
- (3) 由于应用程序的配置或环境方面(包括操作系统本身)的问题引起。
无论是什么原因导致了失败,提交测试一结束,就要通知开发人员,并提供简明的失败原因报告,比如失败测试的列表、编译错误或其他错误清单。开发人员还应该可以很容易地拿到提交阶段运行时的控制台输出,即使提交阶段在多台机器上运行。
引入错误后,越早发现它,就越容易修复它。因为引入错误的人对其上下文的印象还比较深,而且找到错误原因的方法也比较简单。如果开发人员修改了一些内容并因此导致某个测试失败,而失败原因不是非常明显,最自然的做法就是查看从最后一次成功提交后到目前为止所有修改过的内容,来缩小搜查范围。
如果开发人员按照我们的建议,频繁提交修改的话,每次变更都会比较小。如果部署流水线能快速发现失败(最好是在提交阶段)的话,那变更的范围就仅限于该开发人员自己修改的代码。也就是说,修复那些在提交阶段发现的问题,要比修复那些由后续运行大量测试的阶段发现的问题简单得多。
提交阶段是第一个将质量视角从个体开发人员扩大到更多人的正式步骤。提交阶段的第一件事儿就是把提交者的修改与主线合并,然后对集成后的应用程序执行某种自动化的“验证”。既然“尽早识别错误”是我们的目标,那么就要做到“有问题就尽早失败”,所以提交阶段要捕获开发人员引入到应用程序中的大多数错误。
7.2.2 何时令提交阶段失败
关于“提交阶段只有成功和失败两种状态的限制是否太严格了”有很多争论。有人认为,在提交阶段结束时,应该提供更丰富的信息,比如关于代码覆盖率和其他度量项的一些图表。实际上,这些信息可以使用一系列阈值聚合成一个“交通灯信号”(红色、黄色、绿色),或者浮动的衡量标度。比如,当单元测试覆盖率低于60%就令提交阶段失败,但是如果它高于60%,低于80%的话,就令提交阶段成功通过,但显示成黄色。
可在现实中,我们从来没看到过这么复杂的东西,但曾经做过下面这样的事:写一个脚本,当某次构建的编译警告的数量比前一次增多或者没有减少时,就让提交阶段失败(这就是“渐进式”实践),如3.6.4节所述。当然,如果重复代码的数量超出了某个事先约定的限制,或者有关代码质量的其他度量项不符合约束条件时,就令提交阶段失败,这是完全可以接受的。
但要记住的是,我们的纪律是如果提交阶段失败,交付团队就要立即停下手上的工作,把它修复。如果全团队尚未就某个原因达成一致意见,就不要让提交测试失败,否则大家会不拿失败当回事儿,而持续集成就渐渐会失去其应有的作用。我们强烈建议在提交阶段持续检查应用程序的质量,并在恰当的时候考虑加强代码质量的度量。
7.2.3 精心对待提交阶段
提交阶段中有构建用的脚本和运行单元测试、静态分析等的脚本。这些脚本需要小心维护,就像对待应用程序的其他部分一样。和其他所有软件系统一样如果构建脚本设计得很差,还没得到很好维护的话,那么保持它能够正常工作所需投入的精力会呈指数级增长。这相当于双重打击。一个较差的构建系统不但会把昂贵的开发资源从创造业务功能的工作中拖走,而且会令那些仍在创建业务功能的开发人员的工作效率降低。我们曾经见过几个项目因严重的构建问题导致停工。
正如第6章所述,要确保将脚本做成模块化的。将那些经常使用但很少变化的常见任务与经常需要修改的任务(比如向代码库中增加模块)分开。将部署流水线中不同阶段所用的代码分别写在不同的脚本中。最重要的是,不要写出与具体环境相关的脚本,即要把具体环境配置与构建脚本分离。
7.2.4 让开发人员也拥有所有权
在某些组织中会有一支专家团队,团队成员都精通创建有效且模块化的构建流水线,并且擅长管理这些脚本的运行环境。本书的两位作者都曾经担当过这样的角色。但是,如果真的只有那些专家才有权维护持续集成系统的话,那就是一种失败的管理方式。
交付团队对提交阶段(也包括流水线基础设施的其他部分)拥有所有权是至关重要的,这与交付团队的工作和生产效率是紧密联系在一起的。如果你设置了人为障碍,使开发人员不能快速有效地作出修改,就会减缓他们的工作进程,并在其前进的道路上埋下地雷。
开发人员和运维人员都必须要习惯构建系统的维护工作,而且要对其负责。
7.2.5 在超大项目团队中指定一个构建负责人
在小团队或只有二三十人的团队中,自组织就可以了。如果构建失败了,通常很容易在这种规模的团队中确定谁(一位或多位负责人)该负责修复它,如果他没进行修复的话则提醒一下他,如果他在进行修复,就帮他一下。
但在大团队中,这并不总是一件容易的事。此时,让某个(或多个)人扮演构建负责人的角色是必要的。他们不但要监督和指导对构建的维护,而且还要鼓励和加强构建纪律。如果构建失败,构建负责人要知会当事人并礼貌地(如果时间太长的话,不礼貌也没问题)提醒他们为团队修复失败的构建,否则就将他们的修改回滚。这个角色能起作用的另一种情况是,当团队刚开始接触持续集成时。在这样的团队中,构建纪律还没有建立起来,有个人能不断提醒大家,会令事情走向正轨。
构建负责人不应该是由固定的人担任。团队成员应该轮流担当,比如每星期轮换一次。这个纪律不错,能让每个人都学到一些经验。无论怎么说,想一直做这项工作的人还是不多的。
7.3 提交阶段的结果
与部署流水线的所有阶段一样,提交阶段既有输入,也有输出。输入是源代码,输出是二进制包和报告。产生的报告包括测试结果(假如测试失败,这些结果是找出哪里出了错的重要信息)和代码库的分析报告。分析报告可能包括测试覆盖率、圈复杂度、复制/粘贴分析、输入和输出耦合度以及其他有助于建立健康代码库的度量项。提交阶段生成的二进制包应该在该部署流水线的实例中一直被重用,而且(如果可能)最后还会发布给用户。
制品库
提交阶段的输出(结果报告和二进制包)需要保存在某个地方,以便部署流水线的后续阶段能重用它们,并使团队也能使用它们。最容易想到的地方就是版本控制库,但它却不是一个正确的选择,因为这会让你的硬盘空间很快被吃掉,而且有些版本控制系统对二进制文件支持不佳。
绝大多数时新的持续集成服务器都会提供一个制品库,还能设置保存多长时间之后就自动删除那些不想要的二进制包。它们一般会提供某种机制让你声明需要保留任意任务运行后生成的哪些二进制包,并提供一个Web接口来方便团队获取结果报告和二进制包。当然,你也可以使用一个专用制品库(比如像Nexus或Maven风格的仓库管理器)来处理二进制包,但这些工具通常不适合于结果报告的保存。仓库管理器使我们更容易从开发机器上访问到二进制包,而无需与持续集成服务器集成。
图7-2显示了一个制品库在典型安装中的使用方式。它是为每个候选发布版本保存二进制包、结果报告和元数据的关键资源。下面是一个候选发布版本在理想情况下在部署流水线中成功走向生产环境的每一步,其序号与图7-2中各阶段相对应。
- (1) 交付团队的某个人提交了一次修改。
- (2) 持续集成服务器运行提交阶段。
- (3) 成功结束后,二进制包和所有报告和元数据都被保存到制品库中。
- (4) 持续集成服务器从制品库中获取提交阶段生成的二进制包,并将其部署到一个类生产测试环境中。
- (5) 持续集成服务器使用提交阶段生成的二进制包执行验收测试。
- (6) 成功完成后,该候选发布版本被标记为“已成功通过验收测试”。
- (7) 测试人员拿到已通过验收测试的所有构建的列表,并通过单击一个按钮将其部署到手工测试环境中。
- (8) 测试人员执行手工测试。
- (9) 一旦手工测试也通过了,测试人员会更新这个候选发布版本的状态,指示它已经通过手工测试了。
- (10) 持续集成服务器从制品库中拿到通过验收测试(根据部署流水线的配置,也可能是手工测试)的最新候选发布版本,将其部署到生产测试环境。
- (11) 对这个候选发布版本进行容量测试。
- (12) 如果成功了,将这个候选版本的状态更新为“已通过容量测试”。
- (13) 如果部署流水线中还有后续阶段的话,一直重复这种模式。
- (14) 一旦这个候选发布版本通过了所有相关阶段,把它标记为“可以发布”,并且任何被授权的人都能将其发布,通常是由质量保证人员和运维人员共同批准。
- (15) 一旦发布以后,将其标记为“已发布”。
为简单起见,我们用顺序方式来描述这一过程。对于前面的阶段,的确是按这种顺序方式进行的,它们也应该被顺序执行。然而,根据项目的不同,验收阶段的几个后续阶段不串行执行也是正常的。比如,手工测试和容量测试就可以被验收测试的成功同时触发。另外,测试团队还可以将不同版本的候选发布版本部署到他们的环境中。
7.4 提交测试套件的原则与实践
对于提交测试套件的管理,有一些重要的原则和实践。提交测试中,绝大部分应由单元测试组成,这也是本节中我们主要讲的内容。单元测试最重要的特点就是运行速度非常快。有时候,我们会因为测试套件运行不够快而令构建失败。第二个重要的特点是它们应覆盖代码库的大部分(经验表明一般为80%左右),让你有较大的信心,能够确定一旦它通过后,应用程序就能正常工作。当然,每个单元测试只测试应用程序的一小部分,而且无须启动应用程序。因此,根据定义,单元测试套件无法给你绝对信心说“应用程序可以工作”,而这正是部署流水线后续部分的任务。
设计能快速运行的提交测试并不总是那么简单的事情。下面我们会介绍几种策略,其中大部分都是为了达到一个共同的目标:将指定测试的范围最小化,并让它尽可能聚焦于系统的某个方面。尤其要注意的一点是,运行的单元测试不应该与文件系统、数据库、库文件、框架或外部系统等打交道。所有对这些方面的调用都应该用测试替身代替,比如模拟对象(mock)和桩等。
7.4.1 避免用户界面
然而,对于提交测试来说,我们建议根本不要通过用户界面进行测试。用户界面测试的困难来自两方面。首先,它会涉及很多组件或软件的多个层次。这样是容易出问题的,因为要花很多时间和精力去准备各种各样的组件或数据,才能让测试运行起来。其次,用户界面是提供给用户手工操作的,而手工操作的速度与计算机操作的运行速度相比,是相当慢的。
如果你的项目或所用技术可以避免这两点的话,那么通过用户界面创建单元级别的测试可能也是值得的。然而,根据我们的经验,用户界面测试经常出问题,通常最好由部署流水线的验收测试阶段处理。
7.4.2 使用依赖注入
依赖注入(或控制反转)是一种设计模式,用于描述如何从对象外部建立对象间的关系。显然,只有在使用面向对象语言时才能用上它。
假如我创建了一个类,叫Car。无论我什么时候创建Car的一个实例,都可以让创建自己的Engine。另外,我也可以设计Car,使得在创建Car的同时,它会强制我提供一个Engine类给它。后者就是依赖注入。这样就更灵活了,因为我可以创建Car,并提供不同类型的Engine,却不用改变Car的代码。我们甚至可以为Car创建一个特别的TestEngine,它专门在Car类被测试时模拟Engine。
这种技术不但是构建灵活的模块化软件的很好的方法,而且它还能让测试变得很容易,只需要测试必要的类,那些依赖包就不再是包袱了。
7.4.3 避免使用数据库
刚接触自动化测试的人常常写出一些需要与代码中的某一层进行交互的测试,并将结果写入数据库,然后再验证该结果的确被写到了数据库中。尽管这种方法简单,容易理解,但从其他方面来说,它不是一个很有效的方法。
首先,这种测试运行得非常慢。当想重复测试,或者连续运行几次相似的测试时,这种有状态的测试就是个障碍。其次,基础设施准备工作的复杂性令这种测试方法的建立和管理更加复杂。最后,如果从测试中很难消除数据库依赖的话,这也暗示着,你的代码在通过分层进行复杂性隔离方面做得不好。这也使得可测试性和CI在团队身上施加了一种微妙的压力,迫使其开发出更好的代码。
提交测试套件的这些单元测试根本不应该依赖于数据库。为了达到这一点,你就要把被测试的代码与其存储分离开来。这就要求对代码实现良好的分层,也需要使用像依赖注入这样的技术。实在做不到的话,也至少要使用内存数据库。
然而,在提交测试中,也应该有一两个非常简单的冒烟测试。这些测试应该是端到端的测试,并选自那些高价值的、常用功能的验收测试套件,用来证明应用程序可以真正运行起来。
7.4.4 在单元测试中避免异步
在单个测试用例中的异步行为会令系统很难测试。最简单的办法就是通过测试的切分来避免异步,这样就能做到:一个测试运行到异步点时,切分出来的另一个测试再开始执行。
比如,当系统需要发出一条消息,再根据这个消息作出反应,那么可以自己实现一个接口封装原生的消息发送机制。然后你可以利用一个简单的实现了消息接口的桩或者下一节讲的模拟技术,先在一个测试用例中验证这种调用与你所期望的相同。然后,再增加第二个测试,只要通过消息接口调用一下原来的那个调用点,验证一下消息处理程序(message handler)的行为就可以了。当然这也依赖于你的架构,有时候可能需要很多工作才能做到这一点。
我们建议尽量消除提交阶段测试中的异步测试。依赖于基础设施(比如消息机制或是数据库)的测试可以算做组件测试,而不是单元测试。更复杂、运行得更慢的组件测试应该是验收测试的一部分,而不应该属于提交阶段。
7.4.5 使用测试替身
理想的单元测试集中在很小且紧密相关的代码组件上,典型的就是单个类或一小组极其相关的类。
如果系统设计得比较好,每个类都比较小,并通过与其他类的交互完成其运行目的。这是良好封装设计的核心,即每个类都不对外暴露它是如何达到其目标的。问题是,在这种设计得比较好的模块化系统中,为了测试一个在关系网中心的某个类,可能需要对它周边的很多类进行冗长的设置。解决办法就是与其依赖类进行模拟交互。
为这种依赖代码打桩已有相当长且光辉的历史啦。我们在前面已经描述过依赖注入的使用,而且,在建议将Engine替换为TestEngine时,也提供了一个打桩的简单例子。
打桩是指利用模拟代码来代替原系统中的某个部分,并提供已封装好的响应。桩并不对外界作出响应。这是个极其有用且灵活的方法,可以用在任何软件层次上,从模拟被测试代码依赖的一个非常简单的类,到模拟一个完整的系统。
对于大型组件和子系统的模拟,我们倾向于使用桩技术。但是,对于模拟编程语言级的组件,我们建议少用桩技术,更推荐使用模拟技术。相对来说,模拟技术(mocking)稍微新一些。使用它的动机是希望广泛利用与桩类似的技术,而又不需要我们自己写很多桩代码。让计算机为我们自动生成这些桩,而不是自己写,这样不是更好吗?
模拟技术恰好做到了这一点。现在有几种模拟技术工具集,比如Mockito、Rhino、EasyMock、JMock、NMock和Mocha等。使用模拟技术,你就可以说:“给我构建一个对象,让它假装就是某某类型的一个类。”
7.4.6 最少化测试中的状态
理想情况下,单元测试应聚焦于断言系统的行为。然而,特别对于那些刚接触有
效测试设计的新手来说,常见的问题是测试中状态的不断增加。实际上问题包括两个
方面。首先,很容易想到的是,测试就是为系统中的某个组件提供一些输入信息,然
后得到一定的返回结果。所以在写测试时,你就会组织一下相关的数据结构,以便以
正确的形式提交输入信息,然后再把结果与你期望的进行比较。事实上,所有的测试
或多或少都是这种形式。问题是,如果处理不当的话,这个系统及其相关的测试会变
得越来越复杂。
7.4.7 时间的伪装
在一个要求快速运行的构建里,对于那些需要“确保一定的延时或等待”的行为来说,这一点尤其重要。这么做以后,我们就可以通过调整代码结构保证测试时的所有延迟时间为0,使测试执行够快。假如单元测试需要某种真正延时才能运行的话,你就应该重新考虑一下代码结构和测试设计,避免这种情况发生了。
这在我们自己的开发中已经根深蒂固了。甚至是,只要代码中需要使用时间,我们就会抽象对系统时间服务的请求,而不是直接在业务逻辑中调用它们。
7.4.8 蛮力
开发人员总是为最快的提交周期争论不休。然而,事实上,这要与在提交阶段识别最常见错误的能力平衡考虑。这是个只能通过不断试错才能找到的优化过程。有时候,运行速度稍慢一点儿的提交测试可能优于通过优化测试或减少发现的缺陷数来追求运行速度的提交测试。
有两招儿能加快测试套件的运行。首先,将它分成多个套件,在多台机器上并行执行这些套件。时新的持续集成服务器都有“构建网格”功能,直接支持这种做法。记住,计算能力是廉价的,而人力是昂贵的。及时得到反馈比准备几台服务器的成本要有价值得多。第二招儿就是,作为构建优化过程的一部分,将那些运行时间比较长且不经常失败的测试放到验收测试阶段运行。然而,需要注意的是,这会导致需要更长的时间才能知道这些测试是否失败了。
7.5 小结
提交测试应该聚焦于一点,即尽快地捕获那些因修改向系统中引入的最常见错误,并通知开发人员,以便他们能快速修复它们。提交阶段提供反馈的价值在于,对它的投入可以让系统高效且更快地工作。
提交阶段的创建(一个每次修改都会触发的自动化过程,它将构建二进制包、运行自动化测试,并生成有效的度量报告)是采纳持续集成实践的一个最小集。假如你遵循了由持续集成引入的其他实践,比如定期提交,以及一旦发现缺陷就尽快修复,那么提交阶段会让交付流程在质量和可靠性方面有相当大的进步。尽管它只是部署流水线的起点,但可以为你提供巨大的价值,比如可以马上知道谁在什么时候提交的修改让应用程序无法工作,并能够马上修复,令应用程序恢复工作。