验收测试在部署流水线中是一个关键阶段:它让交付团队超越了基本的持续集成。一旦正确实施自动化验收测试,你就是在测试应用程序的业务验收条件,即验证应用程序是否为用户提供了有价值的功能。验收测试通常是在每个已通过提交测试的软件版本上执行的。部署流水线的验收测试阶段的工作流程如图8-1所示。
对于一个单独的验收测试,它的目的是验证一个用户故事或需求的验收条件是否被满足。验收条件有多种类型,如功能性验收条件和非功能性验收条件。非功能性验收条件包括容量(capacity)、性能(performance)、可修改性(modifiability)、可用性(availability)、安全性(security)、易用性(usability),等等。其中的关键点在于,当与某个具体用户故事或需求相关的验收测试成功后,就表明这个用户故事或需求已满足验收条件,可以认为它已完成并且是可正常工作的。
作为一个整体,验收测试套件既验证了应用程序是否交付了用户期望的业务价值,又能防止回归问题或缺陷破坏了应用程序的原有功能。
把验收测试作为证明应用程序是否满足了每个需求验收条件的方法来重点考虑,这种做法还有一个附带的好处。它能让与交付流程有关的每个人(包括客户、测试人员、开发人员、分析人员、运维人员和项目经理)都参与其中,共同考虑“每个需求需要达到什么要求才算成功”
关于自动化验收测试,总是有很多争议。项目经理和客户常常认为创建和维护它们的成本太高。的确,如果实现不好,成本确实相当高。很多开发人员相信,通过测试驱动开发(TDD)方式创建的单元测试套件足以防止回归问题的发生。而我们的经验是,通过合理地创建和维护自动验收测试套件,其成本就会远低于频繁执行手工验收和回归测试的成本,或者低于发布低质量软件带来的成本。我们还发现,自动化验收测试能捕获那些即使单元或组件测试特别全面也都无法捕获的一些问题。
要写出可维护的验收测试套件,首先需要细心地关注分析过程。验收测试来源于验收条件,因此写应用程序的验收条件时必须想着如何使其自动化,并要遵循INVEST原则(INVEST原则指独立性(independent)、可协商的(negotiable)、有价值的(valuable)、可估计的
(estimable)、小的(small)和可测试的(testable)。),尤其是“对最终用户有价值”和“可测试”这两点。
在验收测试中,第一层就是验收条件。像Cucumber、JBehave、Concordion、Twist和FitNesse这样的工具让你能够把验收条件直接写在测试中,并把它们与底层实现关联在一起。然而,正如本章后面会讲到的那样,当使用XUnit Test这类测试框架时,可以将验收条件写在测试的名字中,然后通过XUnit测试框架直接运行验收测试。
使用领域语言来实现测试是至关重要的,不要把与应用程序如何交互的细节也包含在其中。直接引用应用程序的内部API或UI来实现验收测试是很脆弱的,即使在UI上做很小的改动也立刻会导致引用该元素的所有测试失败。这种事情是很常见的。
测试实现应该通过一个较低的层次(称为应用程序驱动层)与被测试的系统进行交互。这个应用程序驱动层有一个API,它知道如何执行动作并返回结果。如果测试基于应用程序的这种公共API来运行的,那么应用程序的驱动层就是了解这个API的细节并能正确调用它的层次。如果测试是基于GUI(Graphical User Interface,图形用户界面)的,这一层就要包括一个窗口驱动器(window driver)。在一个结构良好的窗口驱动器中,某个GUI元素只会被引用很少的几次。也就说,如果它发生了变化,那么只有对它的引用需要更新。
长期维护验收测试,就需要有很强的原则性。必须注意保持测试实现的高效性及结构良好性,特别是在状态管理、超时处理以及测试替身(Test Double)的使用方式等方面。当新增验收条件时,要对验收测试套件进行重构,确保它们的相关性。
在写验收测试时,一个非常重要的考虑是:测试是否直接基于应用程序的GUI运行。由于验收测试试图模拟用户与系统的交互,因此如果有图形界面的话,理想情况下理应通过系统提供的这个用户界面与系统打交道。如果没有通过用户接口进行测试,那么就没有测试用户与系统进行真实交互所执行的代码路径。然而,直接通过GUI进行测试会遇到几个问题:界面变化速度很快、场景的设置复杂、拿到测试结果很难,以及不可测的GUI技术。
还有另一种方式通过GUI进行测试。假如应用程序设计得比较好,GUI层仅是清晰定义用于数据展现的代码,不包括任何业务逻辑。在这种情况下,绕过界面,基于界面下的代码进行测试的风险会相对小一些。将可测试性铭记在心,写出来的应用程序就会有一个API,使GUI和测试用具(test harness)都能用它来驱动应用程序。
如果应用程序没有设计成这个样子的话,就只能通过UI来测试了。本章后面部分将讨论管理这种情况的策略,主要策略还是窗口驱动器模式(window driver pattern)。
本节将讨论如何创建自动化验收测试。首先,分析人员、测试人员应该和客户一起工作,确定验收条件,然后再讨论以某种可以被自动化的方式来展现这些验收条件。
开发流程应该经过裁剪,来满足个体项目的需求。但是, 一般来说,我们建议大多数项目(无论大小)都应该有一个业务分析师作为核心团队的一部分,与团队一同工作。业务分析师这个角色主要代表客户和系统的用户。他们与客户一起工作,识别需求,并排定优先级。他们与开发人员一起工作,确保开发人员能从用户的角度很好地理解应用程序。他们对开发人员进行指导,确保那些用户故事真正交付了它们应有的业务价值。他们与测试人员一起工作,确保验收条件已被合理阐明,并且开发出来的功能满足这些验收条件,交付了期望的价值。
任何项目中,测试人员都是至关重要的。他们的角色就是确保交付团队的每个人(包括客户)都了解并理解正在开发的软件的当前质量和生产准备情况。为了做到这一点,他们要与客户和业务分析师一起工作,为用户故事或需求定义验收条件,与开发人员一起工作,编写自动化验收测试,他们还要执行手工测试活动,比如探索性测试、手工验收测试和演示。
并不是每个项目都需要不同的人担任不同的角色,来完成这些工作。有时候,开发人员会做一些分析人员的工作,或者分析人员会做一些测试人员的工作。理想情况下,与团队在一起的客户可以担任分析师的角色。关键是这些角色在团队中不能缺失。
总的来说,本书一直试图避免限定你所使用的开发流程。我们相信,我们描述的这些模式对所有交付团队都有益处,无论这些团队使用什么样的开发流程。然而,我们仍旧认为,对于创建高质量的软件,迭代开发过程是至关重要的。所以,假如这里更多地谈到了迭代开发过程的话,请你谅解,因为它有助于勾画出分析人员、测试人员和开发人员的角色。
在迭代交付方法中,分析人员会花大量时间定义验收条件。团队用这些验收条件来评判某个具体需求是否被满足。最开始,分析人员会与测试人员和客户紧密合作,定义验收条件。在这个阶段,鼓励分析人员和测试人员协作不仅对双方都有利,并且能使流程更加有效。分析师会有所收获,因为测试人员会根据他们的经验提供一些信息,比如哪些事情可能或应该用于定义用户故事是否做完了。而测试人员在测试这些需求之前,就能获得对这些需求本质的理解。
对那些使用迭代过程的项目来说,由于自动化测试变得更加重要,所以,很多实践者都认识到,自动化测试不仅仅是测试而已。相反,验收测试就是正在开发的应用程序行为的一个可执行规格说明书。这作为自动化测试的一种新方法,被称为行为驱动开发。行为驱动开发的核心理念之一就是验收测试应该以客户期望的应用程序行为的方式来书写。这样,就可以拿这些写好的验收条件直接在应用程序之上运行,来验证它是否满足规格说明了。
让我们再回顾一下这个过程:
相比于传统方式(比如使用Word文档或者跟踪工具来管理验收条件,或者使用录制回放方式创建验收测试),这种方式有很多优点。可执行的规格说明组成了对测试的记录系统,因为它们真的是可执行的规范。测试人员和分析人员不再需要写Word文档,然后把文档扔给开发人员,因为在整个开发过程中,分析人员、客户、测试人员和开发人员可在这些可执行规范上协作。
对于在那些有特殊规定限制的项目上工作的读者来说,值得注意的是,这些可执行的规格说明一般可以使用一个简单的自动化流程将它转化为一个文档,用于审计。我们曾工作过的好几个团队都使用这种方法,而且很成功,审计人员对结果非常满意。
应用程序驱动层是一个知道如何与应用程序(即被测试的系统)打交道的层次。应用程序驱动层所用的API是以某种领域语言表达的,可以认为是一种针对它自己的领域专属语言。
DSL(Domain-Specific Language,领域专属语言)是一种计算机编程语言,用于解决某个具体问题域的某个问题。它与通用编程语言不同,因为它无法像通用编程语言那样可以解决很多类型的问题,它专门为解决某个专属问题域的问题而设计。
DSL可以分为两种类型:内部的和外部的。外部的领域专属语言在其指令被执行之前需要明确的解析。前面使用的Cucumber例子中,最顶层的那个验收测试脚本就是一种外部DSL。另外一些例子还包括Ant和Maven的XML构建脚本。外部的DSL不必是图灵完备的(Turing-complete)。
内部DSL是那种直接在代码中表达的。在下面Java的例子就是一个内部DSL。Rake也是一个内部DSL。总的来说,内部DSL更强大一些,因为能够使用熟悉的底层语言,但是它们可能变得非常难懂(决定于底层语言的语法)。
在可执行的规格说明方面有几个有趣的事情,它在现代计算中横跨两个领域:意图编程和领域专属语言。当开始定义应用程序的意图时,你就可以开始看一下测试套件,或者更进一步,看一下可执行规范。你陈述这个意图的方式就可以被认为是一种领域专属语言,而这里的领域就是指应用程序规范
如果有一个设计良好的应用程序驱动层,就能够完全放弃验收条件层,在测试的实现中表达验收条件。对于前面我们用Cucumber写的一些验收测试,只用JUnit测试也可以表达。下面这个例子就是Dave目前的项目上的真实测试。
public class PlacingAnOrderAcceptanceTest extends DSLTestCase {
@Test
public void userOrderShouldDebitAccountCorrectly() {
adminAPI.createInstrument("name: bond");
adminAPI.createUser("Dave", "balance: 50.00");
tradingUI.login("Dave");
tradingUI.selectInstrument("bond");
tradingUI.placeOrder("price: 10.00", "quantity: 4");
tradingUI.confirmOrderSuccess("instrument: bond", "price: 10.00", "quantity: 4");
tradingUI.confirmBalance("balance: 10.00");
}
}
这个测试创建了一个新用户,并成功登录了,并且确保他有足够的资金进行交易。然后,又创建了一个新工具(instrument),用于后续的交易。这两个创建活动都有各自权限带来的复杂度,但DSL把它们抽象到一定程度,使初始测试的任务仅用几行代码就搞定了。以这种方式写出的测试,其关键特性在于将测试从实现细节中抽取出来
将这两种方式(使用JUnit和Cucumber写验收测试)对比一下,对我们是很有启发性的。首先,这两种方法都能够很好地工作,而且各有其优缺点。另外,它们都要比传统的验收测试做得好。本书作者Jez在当前的项目中使用Cucumber形式的方法(尽管使用Twist的时间比Cucumber更多一些),而另一作者Dave则在其项目中直接使用JUnit(比如上面的例子)。
外部DSL方法的好处在于,可以在验收条件之间任意切换。无需用跟踪工具管理验收条件之后再用xUnit写一遍测试,这种方式下,验收条件和用户故事就是可执行的规范。然而,虽然这些现代工具能够减少撰写可执行的验收条件及使其与验收测试实现保持同步所需的开销,但还是有一定的开销的。
如果分析人员和客户有足够的技术背景,能够使用内部DSL编写的xUnit测试的话,直接使用xUnit这种方法最好。它不太需要那些复杂的工具,只要会使用开发环境中的自动完成功能就可以了。也可以在测试中直接使用DSL,而无须再利用前面所说的别名方式拐弯抹角地查看DSL。你当然可以使用像AgileDox这样的工具将类名和方法名转成一个文本文档,其中列出所有的功能特性(比如前面例子中的“Placing an order”)和场景(如“User order should debit account correctly”)。但是,将实际的测试转成一堆用文本描述的执行步骤仍是比较困难的。而且,这种转换是单方向的(只能直接在测试代码中做一些修改,但不能直接修改验收条件)。
本章中的例子清晰地表明,验收测试分为三层:可执行的验收条件、测试实现和应用程序驱动器层。只有应用程序驱动器层知道如何与应用程序打交道,而其他两层只用业务的领域语言。如果应用程序有GUI,而且已经决定验收测试需要基于GUI来做的话,应用程序驱动器层就要了解如何与其进行交互。应用程序驱动器层中与GUI交互的这部分就叫做窗口驱动器。
窗口驱动器模式是通过提供一个抽象层,减少验收测试和被测试系统GUI之间的耦合,从而让基于GUI的测试运行时更加健壮。它有助于隔离系统GUI的修改对测试的影响。实际上是写了一个抽象层,作为测试的用户接口。所有测试都要通过这个抽象层与真正的UI进行交互。所以,如果对GUI做了一些修改,我们可以对窗口驱动器做相应的修改,这样就不用改测试了。
验收测试的实现当然不仅仅是分层问题。它还包括让应用程序达到某种特定状态,然后再执行几个操作,之后再验证结果。另外,它还要能处理异步问题和超时问题。测试数据也要细心管理。还常常需要使用测试替身,以便模拟与外部系统的集成。这些都是本节所要讲的内容
当说“有状态的测试”时,实际上我们用了一个缩略语。实际上,这里的 “有状态”是指为了测试应用程序的某个行为,应用程序必须处于某种特定的起始状态(就是行为驱动开发中,“假如”那段所描述的内容)。应用程序可能需要一个拥有某种特殊权限的账户,或者需要对某支特定的股票进行操作。无论所需的起始状态是什么,令应用程序处于某种被测试的状态常常是写测试过程中最困难的部分。
最直截了当的测试是那些不需要权限就能验证需求的测试,所以,它们也是所有验收测试的榜样。刚接触自动化测试的人会发现,想让代码可测试,必须修改对它的设计,事实的确如此。但是他们常常希望在代码上开很多秘密的后门,用于验证结果。这就不对了。正如我们所说的,自动化测试会给你压力,让你的代码更趋向于模块化和更好的封装性。但是如果你通过破坏封装性让它变得可测试,那么通常就会错过达到同一目的的好方法。
异步系统的测试有其独特之处。就单元测试来说,在单个测试范围之内,应该避免所有异步情况,也要避免跨越测试边界的情况。后者会引起难以发现的偶然性测试失败。对于验收测试来说,根据应用程序本身的特点,异步可能是不可避免的。这个问题不仅会发生在那些明显具有异步的系统中,在任何使用线程和事务的系统都会有异步问题。在这种系统中,有些调用可能必须要等待另一个线程或事务执行完。
能够在类生产环境中执行自动化测试是做验收测试的必备条件。然而,这种测试环境的一个关键属性是它能够完全支持自动化测试。自动化验收测试与用户验收测试并不完全一样。其中一个不同点就是:自动化验收测试不应该运行在包含所有外部系统集成点的环境中。相反,应该为自动化验收测试提供一个受控环境,并且被测系统应该能在这个环境上运行。这里所说的“受控”是指,可以为每个测试创建正确的初始化状态。如果与真正的外部系统集成,我们很可能就无法做到这一点。
一旦有了验收测试套件,就应该把它作为部署流水线的一个组成部分来运行。提交测试一旦成功完成,就应该开始在通过提交测试的软件版本上运行验收测试套件。下面是一些运行验收测试可以使用的实践。
令验收测试失败的构建版本不能被部署。在部署流水线模式中,只有已经通过这一阶段的候选发布版本才能走向后续阶段。而后续阶段常常被认为是需要人为评判的:在大多数项目中,如果某个候选发布版本无法通过容量测试,就会有人来决定这次失败是否足以严重到要取消这个候选版本的发布资格,还是让它继续走下去。可是,对于验收测试,不应该提供这种人为评定的机会。如果成功,就可以继续,如果失败,就不能向前。
由于运行高效的验收测试套件的时间问题,它通常运行在部署流水线中比较靠后的位置。这么做引起的一个问题是,如果开发人员没有像等待提交测试那样,坐在那里等着这些测试运行通过的话,那么他们常常会忽视验收测试的失败。
对于部署流水线来说,这种低效性是我们能够接受的妥协,因为这样能在提交测试阶段快速捕获大多数失败,并且也维持了比较高的自动化测试覆盖率。但这也是一种反模式。说到底这是一个纪律问题,整个交付团队应该为保持验收测试通过负责。
如前所述,好的验收测试关注于验明某个具体用户故事或需求的某个具体验收条件是否被满足了。最好的验收测试是具有原子性的,即它们创建自己的起始条件,并在结束时将环境清理干净。这些理想测试会将其对状态的依赖最少化,并且通过公共入口而不是预留后门来测试应用程序。然而,仍有某些类型的测试不满足这种要求。但无论如何,在验收测试阶段运行它们都是非常有价值的。
当运行验收测试时,我们设计的测试环境会尽可能与期望的生产环境一致。如果成本不太高的话,它们就应该是一样的。否则,尽可能利用虚拟技术来模拟生产环境。所用的操作系统和任何中间件都应该和生产环境一致,在开发环境中已经模拟或者被忽略的那些重要的流程边界一定会在这里出现。
土豚检录
在一个项目中,我们曾使用JUnit写验收测试。我们所掌握的唯一方便控制运行测试套件的方式就是利用套件的名字,因为它们是按字母顺序排列的。我们组织了一组环境测试,并把它命名为“土豚”(Aardvarks),以确保它在所有其他测试之前执行。
请记住,在运行其他测试之前,一定做土豚检录测试。
由于自动化验收测试是用来断言应用软件交付了用户期望的价值的,所以,它们的性能并不是主要考虑的问题。在项目一开始就创建部署流水线的原因之一就是:通常验收测试由于运行时间太长,所以不能把它放在提交阶段。有些人反对这种观点,认为性能太差的验收测试套件是验收测试套件缺乏维护的一种症状。让我们澄清一下:我们认为,持续地关注维护验收测试套件,以保持它的良好结构和连贯性是非常重要的,但是自动化验收测试的全面性要比测试在10分钟内运行完成更重要。
最显而易见且快速奏效的方法就是每次构建结束后都找到最慢的几个测试,再花上一点儿时间找些办法让它们更加高效。这种策略与我们管理单元测试的方法相同。
这之后就要寻找通用模式,尤其是在测试准备阶段。一般来说,根据验收测试的特点,它要比单元测试有更多的状态。由于我们建议你使用端到端的方法来做验收测试,尽可能减少共享状态,这也暗示着,每个验收测试应该准备自己的起始条件。
在前面几章中,我们已经描述了一些技术,可以帮助提交测试阶段中的那些测试达到适当的测试起始状态。这些技术也同样适合于验收测试,但对于验收测试的黑盒特性来说,可能会有个别选项并不适合。
解决这种问题的直接办法就是在某个测试开始之前,创建一个标准的空白的应用程序实例,并在它结束之后,把这个实例销毁。该测试自行负责用它所需要的初始数据来填充这个实例。这种做法简便且非常可靠,并且让每个测试都从一个受控且可完全重现的起始状态开始执行,这是非常有价值的属性。然而,遗憾的是,对于我们创建的大多数系统来说,它执行得非常慢,因为除了那些最简单的软件系统以外,其他软件系统都要花相当长的时间来清理它的状态,并启动应用程序。
所以,妥协是必要的。我们要找出测试间会共享哪些资源,以及哪些资源要被单个测试独占。通常,对于大多数基于服务器的应用程序来说,都可以共享这个服务器的同一个实例。在执行验收测试前,创建一个干净的系统运行实例用于测试,在这个实例上运行所有的验收测试,最后再将它关闭。根据被测系统的特质,有时候可对其他的耗时资源进行优化,使验收测试套件在整体上能更快地执行。
当验收测试间的独立性比较好时,还有一种办法可加速测试的运行,那就是“并行执行测试”。对于那些基于服务器的多用户系统来说,这是显而易见的。如果你能将测试分开,并且保证它们之间没有互相影响的话,那么,在同一个系统实例上并行执行测试会大大减少验收测试阶段运行的总时长。
对于那些非多用户系统,或者那些极其昂贵的测试,或者那些需要模拟并发用户的测试来说,使用计算网格的益处非常大。当与虚拟服务器结合使用时,这种方法就变得极其灵活且可扩展了。你甚至能让每个测试运行在属于它自己的虚拟机器上。这样,验收测试套件的时间再长,也就是那个运行得最慢的测试所用的时间了。
使用验收测试对提高开发流程的效率非常重要。它使交付团队的所有成员都关注于真正的工作:用户想从应用程序中得到什么。
自动化验收测试通常要比单元测试复杂,需要更多的时间进行维护。而且,由于它在修复某个失败与使所有验收测试套件成功通过之间那种固有的滞后性,所以与单元测试相比,它处于失败状态的时间要长一些。然而,如果把它作为从用户角度看待系统行为的一种保障的话,它为复杂的应用程序在整个生命周期中的回归问题提供了一个良好的防范性。