创建可维护的自动化验收测试
测试即开发
这里的测试指的是自动化测试,从软件的本质上看,测试的自动化乃是测试方面的软件开发,万变不离其宗,这也就意味着那些凡是属于软件开发的定律或者原则也同样适用于测试自动化。对于没有写过代码或者代码经验较少的人来说,或许这其中的道理不能一眼就瞧得出来。
通常情况下软件开发的很大一部分开销是维护——修修补补,更新不断。软件的可维护性强,则开发成本低,同理,测试的自动化开发成功与否也很大程度上体现在它的可维护性成本大小上。我接触过的很多试图尝试引进自动化测试的机构没几个月就决定放弃自动化测试,问之弃因,你会发现大多是因为自动化脚本过于不稳定以及随之衍生的难维护性。举个例子,界面上重命名一个按钮会导致大批的测试用例失败,而与此同时花费在调通和更新这些用例身上的时间成本又太高。
有些团队或机构在自动化测试上取得了成功,难道他们的自动化就可以避免掉这样的维护费用问题?当然不可能。而成功与失败的团队之间一个重要的区别就是:在对待测试开发的维护问题上,失败者往往是被昂贵的维护费用吓住而放弃自动化计划,而成功者则是从一开始就做足了应对措施。那些在自动化取得成功的团队懂得测试即开发这一道理,明白测试开发一旦开始,维护在所难免,所以他们会深思熟虑,想方设法降低维护成本。
软件需求的变更和系统实现的变更会影响测试,需要测试做出相应的调整,这二者任意一种变更都可能导致一系列的自动化测试失败。如果一些自动化测试不能同步新的软件变更和产品新特性,那么它们将会被淘汰,其测试结果也不会得到运用。而要使其回归正常,我们必须不断调整测试以配合需求和系统实现的变更。维护的成本开始显山露水。
因此如果需求和实现的变化是必然的,那么降低自动化测试维护成本的方法只有一个,即编写适应性强的测试脚本。
暴露太多无关紧要的细节或者重复这两大关键因素使得修改代码的困难大大增加,无数惨痛的经验教训让软件开发人员对此深有体会,对于那些正在从事和将要从事的自动化测试开发者们,也肯定不想重蹈覆辙。
验收测试和系统的任务
验收测试用来检测一个系统是否正确履行了某一特定任务。也就意味着,验收测试的核心是关注它所要验证的功能点是否正确,而不考虑用了何种技术、何种方法去测试。
现在假定我们要测某个系统的创建账号这个特性,系统通过传递给 Create 命令用户名和密码来创建新账号。创建账号特性的功能之一是验证密码的有效性。一个合法的密码长度必须介于6~16字符之间且至少包含一个字母、一个数字以及一个标点符号。如果用户提交的密码合法,Create 命令创建成功并报告 Account Created;反之,Create 命令不会执行创建过程,同时报告 Invalid Password。这就是功能职责的本质。无论软件系统以何种技术实现,Web 应用也好,GUI 桌面应用也罢或者是命令行执行的程序,也不管会不会有人像德州电锯杀人狂里的休维特一样,挥舞疯狂的电锯恐吓要锯断那些输错密码童鞋的指头,总之,系统需执行此项职责(密码检查是系统必须实现的职责)。
无关紧要的细节
表 1 不良自动化测试用例脚本
列表 1 展示的是一段不良自动化测试用例脚本,该测试用例用来检测 Create 命令的密码有效性检查这一职责。
这段测试脚本问题很多,一眼望去,最明显的是可读性很差,我们看到第二行 The create command validates passwords,这是测试的标题,表明该测试的测试点和职责,但往下读时,我们会发现,里面充斥了太多累赘的单词和烦人的诸如“{$@^”这样的符号,让人不知其所言。
仔细看一下,我们可以挑出来几个密码,比如 1234!@$^!紧接着再加把劲,啊哈,我们会发现一些密码会导致 status 值为 Invalid Password,而另一些会使得 status 值为 Account Created。从另一方面看,我们可能也会注意不到上述内容因为该测试脚本在密码和状态 status 之间夹杂了太多的实现细节,或曰:测试噪声。试想,这些特殊的符号$、@、^以及单词 Run、Ruby、fred 究竟和密码及其有效性有什么关系!对于自动化测试的脚本来说,从用例的可读性和可维护性角度看这些都是无关紧要的过程实现细节。
过多无关紧要的细节是怎样毁掉可维护性的?假设我们的系统安全分析师指出六位长度的密码本身不安全。于是为了增强安全性,我们将密码长度下限由六改成十,这是一个典型的需求变更,请思考,这时候列表 1 的测试脚本哪里需要修改?怎样改?答案恐怕没那么简单。
让我们再来考虑一个更有挑战性的需求变更。假如我们想让系统管理员能够为任一种情况设定具体的密码长度最长与最短值。这时候该怎么修改刚才的测试脚本?答案还是无法一眼看出。恐怕没那么简单。
这其中的原因“恐怕”在于测试脚本没有清晰表达它所要测试的功能职责。当看不出一段测试用例脚本的本质 ,通常意味着需求变更之时测试人员会需要花数倍的代价来修改原测试脚本。
因此,为方便识别本质就要隐藏非必要细节的,以使自动化脚本用户更容易看到测试的本质,在上面创建用户的例子中,大多数非必要的细节是如何调用 Create 命令。该系统是基于 Ruby 的命令行程序,现在让我们再次返回列表 1 的测式脚本里解读一番,黑色字体加深的第一行告诉自动化测试框架 Robot 启动 Ruby 解释器,加载被测程序文件 app/cli.rb,并调用 Create 命令,参数值为用户名 fred 以及密码 1234!@$^,最后命令返回的结果存在变量${status}中,呵,数数不必要的实现,细节至少有5~6个之多!
再来看字体加深的第二行,实现命令返回值和期望值 Invalid Password 的比较,虽然看起来比刚才那一行较容易理解 ,但措辞笨重,并且过分的语法细节容易分散人们的注意力。
通过 Robot 自动化框架我们可以把实现细节提炼成关键字(Keyword),使之以类似子函数的形式为测试用例脚本调用,一个完整的自动化测试用例便可由数个关键字组合而成。
现在我们演示如何使用关键字来隐藏不必要的实现细节。一个可行的方法就是问自己这样一个问题:假设自己对被测系统实现一无所知,该如何写出自动化测试脚本的第一步?是的,即使对实现一无所知也无大碍,我们只需知道我们要测试创建用户这个产品特性——被测系统显然要提供的功能。继而我们知道创建用户即是被测系统的必要职责,而且从系统需求分析可知创建用户需要提供用户名和密码。
基于以上所述,可能这样修改测试:
Create Account fred 1234@!$^
当然修改后的脚本可能还有其他一些问题,我会在稍后部分接着讨论。
再看看加深的第二行,验证创建用户命令返回的结果是否为 Invalid Password,顺着上面修改的思路则可以变成:Status Should Be Invalid Password。
两行合并,看一看整体效果:
Create Account fred 1234@!$^
Status Should Be Invalid Password
原来的一步经过一次提炼现在看起来简洁多了,可读性也变强了,我们很容易发现这两行的逻辑上的联系:系统必须告知输入的这一密码是无效的。
现在还无法运行新的脚本,因为测试框架 Robot 找不到关键字 Create Account 和 Status Should Be 的定义,两个关键字的 Ruby 实现代码如表2。
表 2 两个关键字的 Ruby 实现代码
字体加深的代码行创建了一个名为 Create Account 的关键字,需要两个参数 user_name 和 password。关键字函数的主体只有两行代码,第一行加载被测程序并调用 Create 命令,第二行保存返回值,对比之前的原测试脚本,我们发现主体第一行代码和表 1 的加深第一行实现了同样的功能,非必要细节即隐藏于此。
你或许已经发现了关键字的实现代码里引入了更多的语法和特殊字符。这不用多虑,通过把细节抽象成关键字, 我们的测试脚本看起来整洁多了,可读性大大增强,现在我把更新过的测试脚本贴上如表 3 所示,更直观展示了修改后的效果。
表 3 更新过的测试脚本
虽然某些程度上增加了关键字这一部分的代码,但获得了整个测试用例脚本的干净清爽,这一做法是值得尝试的。
重复
上面我们已经学会通过提炼可复用的关键字改进了测试脚本,但还存在其他问题。一个问题是我们之前提到的,即每隔一个测试步骤就包含用户名 fred。另一个更大的问题是重复,从表 3 修改过的测试来看,每一对关键字(Create Account 和 Status Should Be)组成一步验证测试,每一步都要提交一个不同的密码并与比较系统返回的状态值和期望值,我们看到,除了输入的密码和期望状态值不同之外,其他部分基本都是一样的。
重复的代码将毁掉可维护性。现在假设我们的用户交互分析师指出产品系统的其他部分并不要求用户创建账号(Create)而是要求他们注册(Register),这就在同一个系统里出现了用户交互接口和使用术语的不一致,而用户交互分析师坚持整个系统应该杜绝这种不一致性,于是我们决定把 Create 命令变成 Register。
这样一来,对测试会产生的影响有多大?我们封装了关键字 Create Account 来创建用户,现在看样子只需要把关键字的实现部分中调用 Create 命令的地方改成 Register 就可以了,但这样一来的又一个问题就在于我们的测试的关键字和被测的功能也出现了不一致的叫法,这会使人困惑。或许以后我们每次验收测试跑完之后,都需要向这些测试报告的经理或者市场人员解释这些关键词。
为了保持术语一致性,最好的做法是修改我们的测试用例。上面的测试用例中,至少有八个用到 Create Account 关键字的地方可能都要改成 Register。还有两个关键字也都用到了 Create Account。而现实会更残酷,可能有成千上万的测试步骤都调用了那个关键字。由此,我们得出结论:重复绝对会增加维护成本。
重复往往预示了潜藏在测试中的某一重要概念。当这样的重复不是发生在个别测试步骤而是一系列的步骤的时候,情况更是这样。
思考一下表 3 中的测试脚本,看看前面两行究竟说明什么。没错,它们核实创建用户命令是否拒绝 1234!@$^这一密码。那再来看看第9~10两行。这两行来证实创建用户命令接受!C2456这一密码,再进行一次抽象概括,我们惊奇地发现,原来这两行测试的本质便是接受(Accept)和拒绝(Reject)。但遗憾的是在表 3 的测试中,这一本质却被埋没了。接下来我们利用两个新的关键字来使概念明朗化,如表 4 所示:
表 4 用两个新的关键字来使概念明朗化
这两个新的关键字不仅将重写我们的测试脚本,同时也给接受密码和拒绝密码下了定义:接受密码即调用被测系统的 Create 命令,系统报告用户创建成功;拒绝密码即调用 Create 命令,系统报告密码无效。
如此一来再次经过修改的测试脚本如表 5 所示,减少了重复并且更能体现被测功能的职责,即密码的接受或拒绝。
表 5 减少了重复的重写测试
让我们捋一捋刚才的思路,总结一下该部分。首先我们经过分析测试代码的重复部分,发现被测功能的两个最基本概念——接受正确的密码和拒绝错误的密码。通过定义两个关键字,我们抽象并命名了这两个基本概念。最后我们在测试用例中使用新的关键字,从而提升了测试的可读性以及可维护性。
给本质一个有意义的名字
经过一番“折腾”,现在表 5 给出测试已经能够比较清晰地表达测试的基本概念。而与此同时,最后一点不够清晰的地方已经开始明朗起来。看看测试中的几个密码,我们不能马上弄清到底给出的密码无效在什么地方?那正确的密码是符合了什么样的规则吗?或许花些时间你就可以找到问题的答案。这里涉及一个重点:任何花在思考测试的意义即其本质的时间都算作维护成本 。这个成本看起来微不足道,但是如果系统需求更改导致大面积关联的测试用例都要修改,这时累加起来的成本是巨大的。我的一些客户已经发现这个问题的严重性,道理很明白,千里之堤,溃于蚁穴。
在上述的测试例子中,我所选取的每一个密码都有特定的目的,也就说每一密码的本质都是和被测系统功能的某一个需求有关。比如 1234!@$^这个密码,它不含字母, 因而这个密码的本质就可以这么描述:一个不包含字母的密码。
我习惯给每一个本质赋予具有意义的名称,在测试代码中给本质命名的是变量。有时候我也创建变量,给它命名一个富有表现力的名字,再给它规定一种能体现其名的价值。如下,我定义了一个变量用来储存没有字母的
密码。
表 6 在测试脚本中使用变量,节省空间
然后在测试脚本中使用变量,节省空间,这里略去了其它变量的定义过程,如表 6 所示,每一个密码都以变量的形式表达其代表的本质意义。至此,离我们开始设定的目标已经很近了,但依然有可以再优化的地方,我将进一步把原来一个测试按照密码不同属性拆分成多个测试用例,如表7。
表 7 把原来一个测试按照密码不同属性拆分成多个测试用例
现在,只需看一眼便可知每一个测试用例或者每一步的意义何在。这里,重要的需求概念被清晰精炼地表述了出来。
现在假定新需求改变了密码极限长度,由于每一个需求和被测功能都由测试用例清晰地显示出来,我能够很快定位哪一个测试用例需要修改。并且由于每一个测试数据也即密码都以有意义的变量的形式储存,我们能够很快的找到需要修改的变量进行重新赋值。先前对测试代码所做的重构带来的好处显而易见,这里再一次强调测试即开发的主旨。
让测试经得起测试:应对系统主要实现架构的改变
在前面部分我们努力让测试能够更灵活地自适应需求变更,可如果是系统的主要实现架构发生了变化呢?测试会受到什么影响?为了找出答案,我们通过改变一点实现的细节——通过 Web 页面创建用户的方式取代之前的命令行调用 Create 命令的方式。现在创建用户只需要打开创建用户的 Web 页面,输入用户名和密码,然后点击创建用户按钮,由页面打印创建结果。严峻的问题来了,我们的测试该如何修改?
还记得我们之前封装不必要细节并提炼了两个关键字 Create Account 和 Status Should Be。这两个关键字封装的细节就是如何调用命令行执行 Create 命令以及获得结果报告。很明显,我们肯定要重写这两个地方,因为现在需要通过 Web 页面操作才能实现。
表 8 修改后的关键字实现
表 8 是修改后的关键字实现。我们修改了与系统交互的实现,即通过使用开源 Web 测试工具 Selenium 进行页面访问和操作,那么现在的问题是对于那几个具体的自动化测试用例,我们还需要修改什么?答案是什么都不需要,此次修改工作已完毕。通过改变几行代码,使我们的自动化测试轻松运行在变化了的实现架构的系统上,这往往就是成功和失败的自动化测试之间的区别。
与此同时,回到现实世界
在真实的测试中,你可能要做更多的工作以应对系统实现架构的变化,你可能不止需要修改两个关键字。但只要你创建了级别较低的关键字,将其他代码和与系统交互的细节分开, 那么你所需要做的就只是修改这些关键字而该测试用例照常运行不需要改动【译者注:如果你看到 Martin Flower 的《重构》一书,就应该明白这样一条重构原则,保持接口不变,改变底层实现】。
真实的项目里很多实现架构上的变动将会对测试开发工具提出更严酷更颠覆的问题。 但即使是最坏情况,你依然可以使用先进的开源测试工具,用以解决很多重复的问题,帮助你撰写能够清晰表达测试本质的用例和脚本。
再次强调,一定要记住其中的本质内容:只有消灭重复的无关紧要细节,让测试清晰表达被测系统功能职责,才能在发生系统需求和实现变更的情况下轻松应对以便降低自动化测试维护的成本。这也正是我们衡量自动化测试开发成功的标志。