2008年12月28日星期日

功能测试之美

功能测试之乐:

功能测试定义了产品的业务需求,通过它业务人员可以了解系统是否能在各个业务场景下正常工作。功能测试通常使用某种自动化测试框架编写,这样开发者可以从自动化的功能测试中获得快速反馈,为下阶段新功能的开发或软件内部实现的重构提供帮助。另一方面,它大大减少了手动环节可能引入的错误,而将枯燥的回归测试交给机器完成,在加快测试速度的同时,将质量保证人员解放出来,从而使他们可以更多地关注于创造性的探索测试。通过从用户角度进行的功能测试,团队对系统在真实条件下的可用性充满信心,而自动化的功能测试也大大提高了工作效率。这样一来,产品能以更高的质量,更快的速度进入市场。

功能测试之苦:

* 保持验收条件及其技术实现的同步

在任何开展自动化功能测试的敏捷开发团队中通常会存在两套系统、 一套是BA(业务分析师)\QA(质量保证人员)所编写、维护的验收条件,可能以纸卡、Wiki等形式记录下来。 另一套是验收条件的技术实现。以源代码的形式记录,由开发者维护。两套系统的并行发展, 带来了同步的问题。如何快速将验收条件转变为技术实现?当验收条件变化时如何使源代码部分同步变化?可不可以将验收条件与测试关联起来,利用Rspec的思路消除这个并行的系统?

* 无法系统化的划分测试

速度是阻碍频繁运行功能测试的主要原因。进行功能测试的团队常常花费数十分钟甚至数小时来运行完整的功能测试套件,加快测试几乎总以失败而告终,从用户角度进行业务场景的测试决定了功能测试的速度天生就是缓慢的。随着软件功能的日益完善,更多的测试被添加到套件中,庞大的套件也使得测试运行的时间越来越长。无法快速得到反馈使团队没有安全感,同时大大减缓开发的脚步,烦躁的开发者甚至开始逃避运行测试,将不安全的代码集成到产品中。

只运行相关的测试,听起来这似乎是一个解决方案。当开发者修改了登录模块的实现后,为什么他们非得花费1个小时等待其他模块的测试结果呢?如果可以仅仅运行登录模块相关的测试,将其余的测试留给持续集成工具运行,开发的效率将大大提高。但遗憾的是在XUnit的世界中,系统化的划分测试并不是件容易的事情。 不论是利用文件名和目录来区分,还是手工维护测试套件,最终总会变成难以维护的大泥球。

* 阅读测试花费的大量时间

大量测试代码总是难以阅读。随着项目的进行, 各种不同习惯的缩写出现在代码中、 测试代码中出现的大量的方法、设置数据越来越复杂等都给阅读测试带来了极大的麻烦。面对失败的测试,试图修复它的开发者总是需要在复杂的代码中挣扎着找出测试的意图,过滤掉准备数据的过程,抽丝剥茧地找到这个测试所覆盖的业务流程,分析究竟是哪个产品模块出了问题。出现这样问题的根源是没有对测试的目的(业务价值)和技术实现做出合理的抽象。

能否有这样一个视图,过滤掉所有让人分散注意力的方法(私有方法,准备数据、清理数据的方法等)让开发者可以清楚地看到测试的目的和步骤,甚至让测试以一种自然语言的形式展现出来,让非技术人员也可以轻易地阅读修改测试呢?


* 启动功能测试的花费

在任何一个团队开始功能测试的第一步,总是耗时和痛苦的。下载各种依赖的库文件,在脚本中进行配置, 保证功能测试可以在命令行执行, 同时还要对IDE进行配置,让开发者可以从IDE中方便地运行单个测试。

能不能缩短这个过程,减轻开发者的痛苦?

功能测试工具的趋势:

敏捷联盟在2007年10月召开了专题讨论来展望下一代的功能测试工具。在这次讨论中, 功能测试领域的专家们提出了对下一代功能测试工具的展望:

* 需要一个整合的开发环境帮助团队:重构测试元素、自动完成命令、增量式的语法检验(基于测试领域特定语言)、快捷键支持、调试等等
* 需要更具描述性的测试领域语言,如将可执行文件、文字、表格、图片、颜色整合到一个测试用例中
* 需要特定的测试领域语言使测试更具阅读性并容易维护
* 需要具备可以使用多种方式查看/导航测试的能力,来帮助我们了解某个部分与整个领域或者特性之间的联系;将测试按照领域上下文来组织;按照用户定义的关键字进行搜索(跨横切关注点)

Twist

经历过很多的功能测试之苦,我们团队尝试了使用Twist来编写、管理功能测试,并得到了很好的效果。它是ThoughtWorks Studio为软件团队设计开发的下一代协作功能测试平台,提供了一个编写、执行以及维护测试的丰富环境。Twist以Eclipse插件的形式设计并开发,充分利用了Eclipse强大的编辑功能, 支持测试专家们通过DSL来表达测试意图。通过自动完成功能,开发人员可以利用Eclipse中广为人知的CTRL + 1 (帮助完成)来快速地将验收条件转换为相应的技术实现(测试类和方法)。由于Twist中的Driver是以Spring Bean的形式注入到测试中,开发者可以以Twist作为平台,使用任何熟悉的DRIVER(如Selenium、Watir、fraiksen)来编写测试。

那么Twist是怎样帮助团队来减轻测试之苦的呢?

* 保持验收条件以及其技术实现的同步
之前我们将验收条件保存在Mingle中的故事卡里,而现在QA和BA通过Twist来编写验收条件,并标记为in-progress。在实现的时候,开发者通过IDE来自动生成测试。在任何人修改验收条件或者技术实现时,IDE的重构功能将自动完成它们之间的同步。例如:测试专家们在Twist IDE 中可能写出如下的验收条件:

Search and buy book :
# search for book written by "Martin Fowler"
# add book "refactoring" to shopping cart
# check book "refactoring" is in my shopping cart


开发者通过TWIST IDE, 可以生成如下测试代码(以Java为例)

public SearchAndBuyBook {

public void searchForBookWrittenBy(String name) {
}

public void addBookToShoppingCart(String name) {
}

public void checkBookIsInMyShoppingCart(String name) {
}
}


之后开发人员可以选择通过手工编写代码(例如通过使用Selenium、 watir作为Driver )或者录制的方式(Twist会记录鼠标、键盘在WEB页面上的动作并转变为相应的测试脚本)来实现功能测试。 更方便的是, 测试专家们编写的DSL验收条件在保持可读性的同时,可以像代码一样自动完成、对重构更加友好、并且可以方便的运行。

在开发者对相应的源代码进行诸如重命名或者引入参数(Introduce Parameter)等重构操作时,相应的测试DSL也会被改变.


* 对测试进行标签管理
Twist使用标签(Tag)对测试进行管理,开发团队可以使用任意标签来标注测试。例如:已完成还是未完成测试、对应的故事卡号、相应或者相关的模块名、属于回归测试还是冒烟测试。这样团队可以方便地划分测试。通过使用Twist发布的ant target中的tag属性,你可以轻松地分组运行测试。比如在我们的项目中最广泛使用的一个Tag是in-progress,这样,QA、BA可以随时提交他们的验收条件,这些尚未完成的验收条件(没有相应的技术实现)会被自动过滤掉,不会引起测试的失败。


*更有效率地 阅读测试
Twist很好地在产品中抽象了验收条件和技术实现,并将它们巧妙地关联起来。这样开发者可以通过易于阅读的DSL来快速了解上下文,并通过Eclipse的快捷键F3,快速地在验收条件和技术实现中进行切换,从而更有效率地阅读测试。

* 减少启动功能测试的花费
用户可以通过在Eclipse中创建Twist项目,快速展开测试。 Twist项目包含了运行Twist测试所必需的所有的第三方库。同时,Twist发布了相应的ANT target,大大减少了团队用于启动功能测试的时间。


在使用Twist的过程中,我们也发现了一些问题,主要集中在Twist的IDE不够稳定,会有一些UI的异常等方面。我们已经将BUG提交到了Twist团队的论坛里,相信稍后的版本会更加稳定易用。 通过使用Twist,很好地将团队以功能测试为中心整合在一起,团队中的所有角色可以通过一套IDE来编写、实现、运行、维护测试,大大减少了交流成本,提高了开发的速度,体会了开发之乐。

2008年12月19日星期五

如何正确的使用Mock

首先我不是反Mock者,但确实对使用Mock持比较审慎的态度,因为Mock是非常难于正确使用的, mock最常见的问题在于假设!假设!假设!

有这样一个功能,当有工作的时候,公民需要买需要买医疗保险,住房公积金和养老保险,如果失业了他只需要买养老保险:


public void requreInsurance(Insurances insurances) {
if (people.getJob() == null) {
insurances.add(new RetirementInsurance());
} else {
insurances.add(new HealthInsurance());
insurances.add(new RetirementInsurance());
insurances.add(new HouseFund());
insurances.add(new UnemploymentInsurance());
}
}


相应的Mock测试有两种情况需要覆盖:

* mock people对象, 假设getJob方法返回null,验证insurances中只有包括养老保险
* mock people对象, 假设getJob方法返回Not null,验证insurances中包含四金


在这里的Mock测试进行了假设,它的coorelation people对象在有工作的时候返回非Null的Job对象,而在没有工作的时候返回Null,类似的代码在任何一个项目中都可以找得到踪迹。

问题在于这样的假设可以被悄无声息的破坏掉,假如有人重构了People对象, 在没有工作时返回一个new NullJob()对象(Null Object Pattern), 这样重构后,失业的人也不得不买四金了,然而我们之前编写的mock测试会100%的通过

如何解决这样的问题呢?

功能测试是一个解决思路,因为在Mock测试中,我们不断的在层与层之间做出假设,一定需要一个端到端的测试来验证我们的假设是否正确。功能测试解决了部分的问题, 回顾上面的问题,你发现至少需要编写两个功能测试才能百分之百的发现刚才重构引入的bug, 如果只编写了happen path的功能测试(有People工作的功能测试),那你只有祈祷QA能帮你及时的找到问题了。 不幸的是,由于功能测试的代价比较大,所以大多数的人都只会编写有限的功能测试,往往这些测试仅仅用于覆盖Happy path. 对于如此简单的问题功能测试尚且不能解决问题,更遑论我们“大型企业级超复杂”的项目呢。

另一个方法就是减少假设,回顾一下我们的实现代码:

if (people.getJob() == null) {
....
} else {
.....
}


getJob的返回值是我们需要进行两次假设的根源,如果没有返回会怎样?


public void requreInsurance(Insurances insurances) {
people.requreInsurance(insurances);
}

public clas People {
public void requreInsurance(Insurances insurances) {
job.requreInsurance(insurances);
}
}

public class Job {
public void requreInsurance(Insurances insurances) {
insurances.add(new HealthInsurance());
insurances.add(new RetirementInsurance());
insurances.add(new HouseFund());
insurances.add(new UnemploymentInsurance());
}
}

public class NullJob {
public void requreInsurance(Insurances insurances) {
insurances.add(new RetirementInsurance());
}
}

在第一个例子中我们需要进行两个假设(Null 和 Not Null),而第二个例子,我们只需要一种假设

第一个例子中稀松平常的代码违反了基本的面向对象的原则“封装”,简而言之是tell do not ask!!原则,因为违反了这个原则,我们不得不做出很多假设,减少假设的一个有效途径就是减少return,tell你的对象替你效劳,如果你对写不出健壮的mock测试烦恼,不妨看看是否写出了符合面向对象原则的产品代码,

正确使用Mock的原则就是尽量不用,改善设计才是王道,代码难于使用通常的方法进行测试而不得不使用mock,绝对是一种smell,不要使用mock来掩盖这种味道。

最后推荐李晓同学的

不要把Mock当作你的设计利器


还有感谢Chris Stevenson今天的Session和帮助。

2008年12月13日星期六

利用mercurial bisect 二分查找bug

前几天QA找到了一个严重的bug,我们编写了一个测试,并修复了它。 问题是我们在哪个版本引入了这个bug? 在1.0版本的时候,这个功能还是正常工作的,

从1.0版本到现在我们进行了上千次提交,找到引入问题的那个版本,看起来是"不可能"的任务,但是因为mercurial提供了bisect,而变的非常容易。

bisect是一个利用二分法来查找在哪个版本中引入bug的一种方法。大致的思路是先标记一个已知没有bug的版本为"good", 再标记一个已知有bug的版本为"bad", 然后hg会自动将版本更新到这两个版本的中间,然后开发者通过运行测试(自动或者手工)决定标记这个版本是"good"或者"bad", hg会再次决定一个中间版本让我们进行测试,直至找到一个相邻的good版本和bad版本,这个bad版本就是我们引入bug的版本。

举个例子:

假如版本1是没有bug的版本,版本10是有bug的版本,我们是在版本7引入了这样一个bug。hg寻找它的办法是:


hg bisect init (初始化数据库)
hg bisect bad R10 (标记版本10是有bug的版本)
hg bisect good R1 (标记版本1是没有bug的版本)


然后,hg会自动更新到R1-R10的中间版本R5,通过运行测试,我们知道这是一个没有bug的版本,于是标记这个版本是没有bug的
hg bisect good


接着,hg自动更新到版本R5-R10的中间版本R8,通过运行测试,我们知道这是一个有bug的版本,于是标记这个版本为bad
hg bisect bad


接着,hg自动更新到版本R5-R8的中间版本R6,通过运行测试,我们知道这是一个没有bug的版本,标记这个版本为good
hg bisect good


hg自动更新到版本R6-R8的中间版本R7,通过运行测试,我们知道这是一个有bug的版本,标记这个版本
hg bisect bad


这样,最后hg通过2分法不断缩小范围,我们得到了这样一个结果

R7 bad
R6 good


这样我们可以得知在R7引入了bug,再分析R7所提交的文件就可以明白当时的错误在哪里了。

这样通过自动化测试和mercurial提供的二分查找,我们可以轻松的找到错误的根源。

2008年12月9日星期二

使用Mercurial Queues

昨天在聊持续集成的时候,同事提出一个问题,因为提交很频繁,能不能让持续集成工具忽略掉某些提交,它们只是完成故事若干步骤的一步,让持续集成工具花1个小时来对明知道不需要的版本进行构建太费时间了。

在ThoughtWorks,很多同事都和我一样患有“频繁提交沉迷综合症”,具体症状是每隔30分钟就得运行提交命令, 否则就血压升高,眼眶发干,手心出汗,焦虑...等。 可是偏偏有时候提交不了,要想提交还得修改不知道多少个文件,你有点恐惧了,可还得硬着头皮干。

正如当时在场的同事田乐回答的一样:根本问题不是持续集成工具是否应该支持这样的功能,而是团队选择的版本控制工具(譬如SVN, CVS, P4)对频繁提交没有提供良好的支持。

我们团队使用的是分布式版本管理工具Mercurial,它医好了我的“频繁提交沉迷综合症”,我把药方写在这里以厮患者。

首先编辑.hgrc文件(* mac和linux用户运行vi ~/.hgrc),在[extensions]条目下加入hgext.mq打开mq的扩展。
[extensions]
hgext.mq =


保存退出,运行

hg --help


你可以在Console上看到新增了很多queue相关的命令

qapplied
qclone
qcommit
qdelete
qdiff
qfold



在mq打开后,如果我们完成了部分修改,可以运行

*hg st (查看本地修改,你应该看到所有被修改过的文件)
*hg qnew -m "[message]" -f "[patch name]" (在queue中压入一条changeset)
*hg st (查看本地修改,现在你应该重新得到了干净的本地目录)


这样在开发的时候我们可以通过hg qnew命令将baby step不断提交,保持一个干净的工作目录。

但是这些changeset是没有提交到主repository上的。在完成了所有的所有的工作后,

我们可以运行
*hg qremove -r qbase:qtip
*hg push
这样,团队中的其他开发人员就可以得到你的修改了。

在我们运行hg qnew后,hg其实帮我们创建了一个标准的patch文件,并把它保存到了
工作目录/.hg/patches


如果你想回家继续开发,可以拷贝走这些patch文件,在另外一台机器上,使用hg qimport或者patch(linux,mac)都可以重建你的工作目录。

如果你有兴趣,请参看mercurial mq的文档

2008年12月3日星期三

真实!真实! 真实!!!

昨天Cruise的功能测试通过了,四十多天来的第一次,团队所有成员都欢呼雀跃,为了这此通过,无数随机失败的测试被找出来,一一修复。 为了让测试运行的速度更快,我们新建了开源的项目test-load-balancer,为了让测试可读性更好,我们扩展了harmcrast。为了应付在不同平台下的测试,我写了Prerequisite

有同事问我,让测试通过很难么? 很难,我们花了40天才得到了一次通过的构建, 我们花了3个月的时间,理清了功能测试的思路,将原来的测试一步步移植到了Twist上,我们花了超过1年的时间从之前失败的经验里汲取教训,学习如何进行测试。值得么? 值得!现在你能站在团队成员身边就能感受到满满的信心,很多信心。 开发变成了一种享受。

变化的起因是:真实!真实! 真实!!! 在尽量真实的环境下测试。

谈起单元测试似乎就离不开Mock,在Mock的环境中搭建的测试就好似沙上建塔,很漂亮,很快,却经不起真实环境的推敲。 Cruise需要跟不同的版本管理工具交互,最实用的方法就是在测试中启动一个版本管理工具的服务器,然后一一测试checkout, sync等功能。没错,它很慢,非常慢。我们有运行时间超过一分钟的单元测试。但是这些测试让我们学习到了WINDOWS, REDHAT, MACOS在某些操作上令人惊诧的不同。 更重要的是,当它们通过的时候,我很有信心,我相信Cruise在与这些版本工具集成方面不会有问题。

使用Mock的最佳实践就是不要使用Mock(李晓 不要把Mock当作你的设计利器), 改进设计才是王道。在我们几乎清理了项目中所有Mock的时候,本地构建时间已经超过了20分钟,全部构建时间在一个半小时左右。后果是我们不得不编写了test-load-balancer,因为一样的原因mingle团队诞生了dtr。 从另一个角度看,让我对Cruise(可以进行并行构建的持续集成工具)的未来充满信心,长时间的构建不可避免,Cruise可以提供成本最低的加快构建的解决方案。

再来说说功能测试,我们之前的方案是在测试环境下启动产品(调整产品的某些参数,调整产品的启动方式等),通过使用数据库设置测试必须的数据环境,然后通过Selenium进行页面操作和验证,测试结束后利用DBUNIT来清理环境。 这样的测试除了验证了测试环境的产品能正常工作外,什么也说明不了。这也是为什么以前虽然也有很多测试,我们却在发布前依然诚惶诚恐。

我承认,使用真实的产品进行测试很复杂,因为很多测试必须的数据设置和查询都难以在外部完成。但是这样单一的,封闭的使用软件的方式是我们希望的么?开放的产品(基于Open API + Restful的设计), 让测试变的更容易,同时也给用户带来了好处(界面进行的重复操作可以通过脚本自动化)。

我们现在的方案是直接在产品包上进行测试,数据的设置通过REST URL进行,同时利用公布出来的JSON来判断产品是否已经被设置为我们希望的状态,再利用selenium进行页面的操作。在这组测试完成时,我们可以对最终交付的软件的质量做到心中有数。

这样的测试方式对开发团队也提出了挑战,产品必须可已在在不同的平台下中通过命令行安装、启动、关闭、卸载,在linux的平台上这点非常容易做到,不管是aptitude还是pkg都通过命令行进行包管理,即便是通过shell启动也可以运行"KILL $PID"轻易的结束进程,而在WINDOWS平台就比较困难(但也是能做到的,只是比较丑陋),再就是如何确保测试可以在一个Repeatable的环境中运行,所谓Repeatable,是说对于每个测试运行前,产品可以被设置到确定的状态,当测试真实的产品时,这点是比较难于做到的,说到底是在两个进程间共享信息,能采取的手段比较有限(Open API + RESTFUL是个比较好的选择)

检验测试好坏的最好标准不是测试覆盖率,而是信心和安全感,如果你所在的团队对测试通过后发布软件信心满满,那么恭喜你,你们在作正确的事情。