微信号:ThoughtWorks

介绍:最新技术雷达/各类技术干货/精选职位招聘/精彩活动预告/经典案例故事,就在ThoughtWorks.

以自动化测试撬动遗留系统

2013-01-21 15:09 ThoughtWorks

以自动化测试撬动遗留系统


文/胡振波


面对遗留系统,选择合适的测试策略,能让自动化测试的投入在一定时期内看到效果,并且建立可持续进行的机制。同为自动化测试,美中测试在面对遗留系统时遇到的挑战是不同的,起到的效果也不尽相同。


背景

   我目前所服务的企业大部分系统都是遗留系统,其中多数系统都处于相对需求平稳阶段,也就是需求并不多,其中也没有什么大需求。但是这些系统却牵制了和需求所需人力不成比例的大量人力,从系统本身的原因看,有这么几点:

  1. 系统晦涩难懂,可读性可理解性很差。理解原有系统往往占据了进行一个修改的大部分时间。

  2. 系统设计僵化,改动困难,一个小修改,往往会迫使系统很多部分的改动。

  3. 系统难以重用,大多软件单元缺乏可重用性。

  4. 系统脆弱,往往引入一个小功能就会引入几个缺陷,修复一个缺陷又引起了几个新的缺陷。

   投入大量人力,产生的价值却微乎其微。面对竞争激烈的市场,同质化的竞争,成本和质量的问题越来越凸显。而所谓遗留系统,也就是没有自动化测试保护的系统。客户很希望通过引入“自动化测试”来提升系统的质量,最终帮助他们建立自动化测试机制。

   在这几个月的工作中,我先后投入到几个遗留系统,进行自动化测试提升工作。这些工作都取得了不错的进展和突破,很多系统的核心模块都有了自动化测试的覆盖。另外,这次专门针对“遗留系统”所做的自动化测试工作,也带给我一些新的想法:自动化测试,很有可能是我们撬动遗留系统的一个支点。

测试策略选择

 

   上图是测试金字塔模型,从上至下分别是验收测试、API测试、集成测试、单元测试。你可以有不一样的分类,但是从上至下,测试粒度越来越小,测试数量越来越多。一个具备完善自动化测试的系统,应该具备类似的测试分布。

   当我们面对的是遗留系统时,去追求达到理想模型肯定是不现实的,那么应选择何种测试策略呢?

   每个遗留系统的状况都不尽相同,可能选择的策略也会不一样。但有一点是一致的,所有的测试都不是没有成本的,在人力并不宽裕的情况下,必须让测试投入“值回票价”。而且,必须让测试投入在短期之内最有价值的地方,才能让团队尽快看到效果和建立信心。我们选择的策略之一是:

   快速建立稳定性较高的功能测试防护墙,以此保护系统的核心功能不被任意破坏。这里有两个关键点:

  1. 一是快速,就是说我们要选择可以快速建立的测试,让一定的价值在短期之内就能得到体现。

  2. 二是稳定,这里的稳定,指的是测试本身的稳定--不因系统变化而对测试产生太大的冲击,因而维护成本也就相对较低。

   这里的功能测试可以是验收测试、API测试或者集成测试,根据每个系统的情况选择更好满足上面两个关键点的合适测试类型。

   比如,我们曾面对一个Web系统,大部分页面逻辑比较简单,主要是呈现内容;前端通过REST接口跟业务后台交互数据。刚开始我们选择基于WebDriver的验收测试,但随后即发现这类测试编写成本较高,需要学员掌握较多的技能,无法在短期之内快速为整个系统建立一个防护墙;另一方面,它的稳定性也较低,测试较易受到页面布局的影响,维护成本较高。在这种情况下,最后我们转而选择了基于REST接口的测试,因为它的建立成本更低,稳定性也更高(REST接口变化较少),而且也可以覆盖所有核心功能,相比而言,是个更好的选择。

   除了上述之外,我们还选择了另一个策略:针对热点区域添加测试,热点包括需求热点和缺陷热点等。选择这些区域主要基于两点理由:

  1. 首先,“非热点”区域,也就是暂时稳定的区域,在初期并不是最值得投入为其建立测试的。比如,有个Web系统,它有两个相对独立的组件,一个是社区,一个是网店,如果前者是热点区域而后者不是,那么前者就更有价值在初期投入建立测试。

  2. 其次,遗留系统的脆弱性往往体现在“bug重复出现”,“解决一个bug,出现两个bug”等情形。针对这些活跃区域添加测试可以对它们起到保护作用,减少出现上述情形的机会;同时,也是对这块区域的一个重构契机,暂且不表。

   针对“热点”区域,我们一般会在团队中建立类似“完成新需求必须同时完成测试”;“修复缺陷必须添加测试”这样的纪律。

   同时,选择合适粒度的测试也是重要的。各类测试各有优点,比如集成测试在功能保护上体现效果更快;而单元测试却会驱动内部质量的提升。如果条件允许,选择多种粒度的测试结合,别忘了之前提到的测试金字塔。我们无法为整个系统一下子建立完善的测试,但为某一个区域,是有可能的。

为遗留系统写功能测试

   功能测试处于测试金字塔的上端,它的稳定性相对较低,维护成本也较高。所以,写功能测试一定要关注提升它的稳定性,并且降低维护成本,遗留系统在这几个方面遇到的挑战可能会更大。

 

   最近在对一个Web系统建立基于WebDriver的功能测试,其中面临的一个很大问题就是HTML页面缺乏语义化,很多元素的定位都得依靠位置等极不可靠方式,一旦页面发生某些布局上的变化,就会影响到测试,维护成本很高。但,事情总有两面性,正是这些测试,让页面的重构和优化得到了团队的重视。

   影响功能测试稳定性的另一个重要因素是测试数据。对于团队控制范围内的系统,我的建议是随着测试的建立逐步创建一套可靠的、覆盖各种典型场景的测试数据准备脚本。由此,我们即可每次都重新建立干净的测试数据,让测试更加稳定和可控。

   但在遗留系统中,有时候会碰上更严峻的问题,系统依赖于第三方或者其它不在控制范围内的测试系统。功能测试会影响到测试数据,所以我们的测试很有可能无法重复执行。当然,建立一个测试替身系统是一种选择方案,但有时候并不容易,至少短期之内。面对这种情况,我们的解决方案是让测试程序和测试数据解耦。想象一下,如果同样的测试由一个测试人员手工执行,她每次执行的时候不需要选择相同的数据,而只需选择“符合同样要求”的数据。

   比如,一个电商系统,它出售一些数量有限的商品,售完即止。测试数据库中有大量的不同商品,但是每种商品数量所剩无几。如果我们的商品购买测试程序针对某个特定商品,那么在运行几次之后,商品就会卖完,测试就不再具备可执行性。但测试人员不会这么傻,她每次都可以选择还有剩余的商品进行购买测试。既然如此,我们的测试程序也同样可以做到:只要根据商品页面上的信息识别出哪些商品有剩余,随机或者有策略地选择其中某个商品进行购买即可。

// old code

productsPage = loginPage.login("userName", "password");

// purchase a product with specified name

productsPage.purchaseProductByName("The Transformer");

// new code

productsPage = loginPage.login("userName", "password");

// purchase an available product, which is selected  

// randomly or in other strategy

productsPage.purchaseAvailableProduct();



   这样,我们就让测试程序和具体的测试数据得到了解耦,缓解了测试的不可重复执行性,使其更加稳定,维护成本也得到降低。除了上述方法,还有很多具体方法可以避免测试程序和测试数据的耦合,不再赘述。

   功能测试程序,是在用一种自动化的方式代替人的手工执行,但同时也一定程度上丢失了手工执行的灵活性。让功能测试程序保持灵活应变是我们在编写测试程序时应该考虑的一个重点。

为遗留系统写单元测试

   为遗留系统写单元测试会面临和写功能测试不一样的挑战,在复杂度上以及对人的能力要求上也可能会更高一些。原因并不在于测试本身,而在于遗留系统自身。遗留系统内部的强耦合(依赖)以及每个单元的高复杂度使得测试难以开展。比如最近我接触的几个遗留系统,线程池逻辑和业务逻辑交织在一起,SQL拼装逻辑、ORM逻辑和业务逻辑交织在一起,一个方法往往几百上千行,而且有很深的调用链。

   为这样的系统编写单元测试,我们普遍遇到了这样几个问题。

   一:私有方法如何测试:我经常被问到的一个问题是“这个私有方法怎么测”? 对于私有方法的测试,可能的答案是:不要对私有方法进行测试,测试公有方法,就能覆盖到私有方法。这个答案可能正确,但在遗留系统中,这个答案往往错误。很多时候,我往往反问“为什么要对私有方法进行测试?”

   比如下面这个例子,是一个有较复杂逻辑的线程。但主要的逻辑存在于组装和发起HTTP请求和解析返回的XML上。

                                                       

   当想对私有方法进行测试时,往往意味着类过于复杂、私有方法承载着太多的职责,通过公有方法覆盖私有方法的测试成本过高,难度太大。所以,更好的解决办法是分离职责,分而治之,单独测试。通过分离职责,单独对各部分逻辑进行测试,测试就会简单很多。比如:

                 

   又比如这个例子:

   而如果在不改变代码的前提下要为这样的代码写测试,往往首先要花很多时间理解它,理清各个分支,分别为它们建立复杂的测试准备状态,等等,成本很高,有时甚至为不可能的任务。

   二:Mock是否是遗留系统单元测试的“银弹”:当对遗留系统进行单元测试时,mock作为“银弹”时不时地出现了。面对遗留系统中有较深依赖链的一些方法,把那些难以建立的依赖统统mock掉是最快的手段。但,经验告诉我:对mock保持警惕,一旦引入mock,mock就容易被人滥用。当然mock本身无错,错在使用它的人,如果一定要怪mock,就怪有些mock工具过于强大吧。滥用mock会导致:

  1. 测试真实性的减弱,降低了测试程序作为测试本身的价值。

  2. 滥用mock的”交互验证(verification)“,使得测试和实现紧密耦合,降低了测试的稳定性。

   我专门去回顾了之前做的几个系统,发现用到mock的时候微乎其微,大多使用在对不受控依赖建立测试替身上。在保证测试执行速度的前提下,这是我推荐的做法。但面对遗留系统的时候,我们很容易把“难以建立依赖测试状态”作为使用mock的借口,大量滥用mock。

   单元测试,相对于功能测试等高层次的测试,它是代码级别的白盒测试。但是,之于它的测试对象而言,我们仍然应当把单元测试视为黑盒测试 -- 单元级别的黑盒测试。比如对一个排序方法而言,不管它采用什么排序算法实现,大多情况下我们只在乎它是否可以把一个无序数组排序成有序。它的测试也只要基于输入(无序数组)和输出判断(有序数组)即可,就算排序算法改变,测试仍然不受影响。从这个排序方法的角度看,它的测试对它的内部实现不关心,是黑盒的;从而,当内部实现改变时,只要外部行为不变,测试就不会受到影响。滥用mock很容易让测试违反这个特质。

   此种情况下,我们选择的方案是:选择从依赖链的末端开始测试,从这里开始逐渐完备测试状态准备机制。在保证测试速度(运行速度和编写速度)的前提下,尽量避免使用Mock。当然,如果你的代码里面依赖复杂、混乱,那么首先可能要重构代码,重新组织分配职责,简化依赖关系。        

   简而言之,面对遗留系统进行单元测试时,有几个值得关注的实践:

  1. 分离职责,分而测之,优于对私有方法进行测试。

  2. 逐步完备测试状态准备机制,优于使用mock。    

   之前,我个人对于在遗留系统上实施测试自动化建设是总体悲观的:遗留系统庞大,测试的效果并不会在短期之内得以体现;而团队,恰恰经常认为遗留系统足够稳定(没有什么大需求,而且90%的代码可能短期不需要改动),没有动刀的必要。所以,大多数团队,很有可能会在看到测试带来的效果之前就放弃了。

   但请相信我,这一切都是假象。故障频发的正是这些遗留系统:

  1. 稳定是假象,遗留系统里面隐藏着很多的故障和漏洞,只是它们暂时没有爆发出来而已

  2. 稳定是假象,粗劣的设计让任何一个需求的变化都会像霰弹一样影响整个系统,遗留系统大都是极其脆弱的

   而单元测试,除了它本身带来的测试价值之外,对于遗留系统而言,它更大的价值在于:

  1. 驱动遗留系统的重构,提升架构设计和内部质量  (遗留系统虽然庞大,但是坏味道其实都雷同)。这是对于遗留系统而言最关键,而一般情况下最没有可能去做的事情

  2. 暴露并解决系统中的缺陷和漏洞。在这个过程中,我们发现并处理了很多漏洞和缺陷,包括多线程处理上、业务逻辑上等等

  3. 更重要的,是提升团队的重构和设计能力,以及团队的质量意识,特别是内部质量

总结

 

   面对遗留系统,选择合适的测试策略,让自动化测试的投入在一定时期之内可以看到效果;并且建立可持续进行的机制。同为自动化测试,每种测试在面对遗留系统时遇到的挑战是不一样的,它们起到的效果也不尽相同。对于遗留系统而言,功能测试是盾,只有它的保护,我们才能放心地对遗留系统动刀;而单元测试是刃,它撬动的不仅是遗留系统,更是遗留团队。


原文刊于《程序员》杂志 2012年11月



 
ThoughtWorks 更多文章 #TW行业前瞻#【敏捷零售:走入消费互联网时代】 #TW好文集锦# 精益创业和敏捷 文/施韵涛 #TW行业前瞻# 企业的“数字渠道”和IT挑战 上海活动|7月16日DevOps工作坊第二期:持续部署 我为什么选择Angular 2?| TW洞见
猜您喜欢 这些小而美的网站,你值得看一看 REBORN 天合光能携手IBM 以人才战略助力全球业务拓展 使用Icinga2监控Linux和Windows服务器 大数据采集平台差异对比