面对遗留系统,选择合适的测试策略,能让自动化测试的投入在一定时期内看到效果,并且建立可持续进行的机制。同为自动化测试,每种测试在面对遗留系统时遇到的挑战是不同的,起到的效果也不尽相同。
背景
我目前所服务的企业大部分系统是遗留系统,其中多数处于相对需求平稳阶段,即需求并不多,也没有大需求。但这些系统牵制了和需求所需人力不成比例的大量人力,从系统本身的原因看,有这么几点。
● 系统晦涩难懂,可读性可理解性很差。理解原有系统往往占据了进行一个修改的大部分时间。
● 系统设计僵化,改动困难,一个小修改,会迫使系统很多部分的改动。
● 系统难以重用,大多软件单元缺乏可重用性。
● 系统脆弱,引入一个小功能会引入几个缺陷,修复一个缺陷又引起几个新缺陷。
投入大量人力,产生的价值却微乎其微。面对激烈的市场,同质化的竞争,成本和质量问题日益凸显。而所谓遗留系统,即没有自动化测试保护的系统。客户很希望通过引入“自动化测试”来提升系统质量,最终帮助他们建立自动化测试机制。
过去几个月里,我先后投入到几个遗留系统的自动化测试提升工作中。这些工作都进展不错,很多系统的核心模块都有了自动化测试的覆盖。另外,这次专门针对“遗留系统”所做的自动化测试工作,也带给我一些新想法:自动化测试,很可能是我们撬动遗留系统的一个支点。
测试策略选择
图1是测试金字塔模型,从上至下分别是验收测试、API测试、集成测试和单元测试。你可以有不一样的分类,但从上至下,测试粒度越来越小,测试数量越来越多。一个具备完善自动化测试的系统,应该具备类似的测试分布。
图1 测试金字塔模型
当我们面对的是遗留系统时,追求理想模型肯定是不现实的,那么应该选择何种测试策略呢?
每 个遗留系统的状况都不尽相同,可能选择的策略也会不一样。但有一点是一致的——所有的测试都不是没有成本的,在人力并不宽裕的情况下,必须让测试投入“值 回票价”。而且,必须让测试投入到短期最有价值的地方,才能让团队尽快看到效果和建立信心。我们选择的策略之一是:快速建立稳定性较高的功能测试防护墙, 以此保护系统的核心功能不被任意破坏。这里有两个关键点。
● 快速,要选择可以快速建立的测试,让一定的价值在短期之内就能得到体现。
● 稳定,指的是测试本身的稳定——不因系统变化而对测试产生太大的冲击,因而维护成本也就相对较低。
这里的功能测试可以是验收测试、API测试或集成测试,根据每个系统的情况选择更好满足上面两个关键点的测试类型。
例如,我们曾面对一个Web系统,大部分页面逻辑比较简单,主要是呈现内容;前端通过REST接口跟业务后台交互数据。刚开始我们选择基于 WebDriver的验收测试,但随后即发现这类测试编写成本较高,需要学员掌握较多技能,无法在短期内快速为整个系统建立一个防护墙;另一方面,它的稳 定性也较低,测试较易受到页面布局的影响,维护成本较高。在这种情况下,最后我们转而选择了基于REST接口的测试,因为它的建立成本更低,稳定性也更高 (REST接口变化较少),而且也可以覆盖所有核心功能,相比而言,是个更好的选择。
我们还选择了另一个策略:针对热点区域(包括需求热点和缺陷热点等)添加测试。选择这些区域主要基于两点理由。
● 首先,“非热点”区域,也就是暂时稳定的区域,在初期并不是最值得投入为其建立测试的。例如,有个Web系统,它有两个相对独立的组件,一个是社区,另一个是网店,如果前者是热点区域而后者不是,那么前者就更有价值在初期投入建立测试。
● 其次,遗留系统的脆弱性往往体现在“Bug重复出现”、“解决一个Bug,出现两个Bug”等情形。针对这些活跃区域添加测试可以对它们起到保护作用,减少出现上述情况出现的机会;同时,也是对这块区域的一个重构契机。
针对“热点”区域,我们一般会在团队中建立类似“完成新需求必须同时完成测试”、“修复缺陷必须添加测试”这样的纪律。
同时,选择合适粒度的测试也很重要。各类测试自己的优点,例如集成测试在功能保护上体现效果更快;而单元测试却会驱动内部质量的提升。如果条件允许,选择多 种粒度的测试结合,别忘了之前提到的测试金字塔。我们无法为整个系统一下子建立完善的测试,但为某一个区域,是有可能的。
为遗留系统写功能测试
功能测试处于测试金字塔的上端,它的稳定性相对较低,维护成本也较高。因此写功能测试一定要关注提升它的稳定性,并降低维护成本,遗留系统在这几个方面遇到的挑战可能会更大。
最近我对一个Web系统建立基于WebDriver的功能进行测试,其中面临的一个很大问题就是HTML页面缺乏语义、很多元素的定位都得依靠位置等极不可 靠方式,一旦页某些布局发生变化,就会影响到测试,维护成本很高。但事情总有两面性,正是这些测试,让页面的重构和优化得到了团队的重视。
影响功能测试稳定性的另一个重要因素是测试数据。对于团队控制范围内的系统,我的建议是随着测试的建立逐步创建一套可靠的、覆盖各种典型场景的测试数据准备脚本。由此,我们每次都重新建立干净的测试数据,让测试更加稳定和可控。
但在遗留系统中,有时会碰上更严峻的问题,系统依赖于第三方或其他不在控制范围内的测试系统。功能测试会影响到测试数据,因此我们的测试很有可能无法重复执 行。当然,建立一个测试替身系统是一种选择方案,但有时并不容易,至少短期之内。面对这种情况,我们的解决方案是让测试程序和测试数据解耦。想象一下,如 果同样的测试由一个测试人员手工执行,每次执行时不需要选择相同的数据,而只需选择“符合同样要求”的数据。
例如一个电商系统,它出售数量 有限的商品,售完即止。测试数据库中有大量不同商品,但每种商品数量所剩无几。如果我们的商品购买测试程序针对某个特定商品,那么在运行几次之后,商品就 会卖完,测试就不再具备可执行性。但测试人员不会这么傻,他每次都可以选择还有剩余的商品进行购买测试。既然如此,我们的测试程序也同样可以做到:只要根 据商品页面上的信息识别出哪些商品有剩余,随机或者有策略地选择其中某个商品进行购买即可。
这样,我们就让测试程序和具体的测试数据得到了解耦,缓解了测试的不可重复执行性,使其更加稳定,维护成本也得到降低。除了上述方法,还有其他方式可以避免测试程序和测试数据的耦合。
功能测试程序,是在用一种自动化的方式代替人的手工执行,但同时也一定程度上丢失了手工执行的灵活性。让功能测试程序保持灵活应变是我们在编写测试程序时应该考虑的一个重点。
为遗留系统写单元测试
为遗留系统写单元测试会面临和写功能测试不一样的挑战,在复杂度及对人的能力要求上也可能会更高一些。原因并不在于测试,而在于遗留系统自身。遗留系统内部 的强耦合(依赖)及每个单元的高复杂度使得测试难以开展。例如最近我接触的几个遗留系统,线程池逻辑和业务逻辑交织在一起,SQL拼装逻辑、ORM逻辑和 业务逻辑也交织在一起,一个方法往往几百上千行,而且有很深的调用链。
为这样的系统编写单元测试,我们普遍遇到了这样几个问题。
1、私有方法如何测试:我经常被问到的一个问题是“这个私有方法怎么测”?对于私有方法的测试,可能的答案是——不要对私有方法进行测试,只要测试公有方法, 就能覆盖到私有方法。这个答案可能正确,但在遗留系统中,往往是错误的。很多时候,我会反问“为什么要对私有方法进行测试?”
下面这个例子(如图2所示),是一个有较复杂逻辑的线程。但主要的逻辑存在于组装和发起HTTP请求和解析返回的XML上。
图2 一个有较复杂逻辑的线程
当想对私有方法进行测试时,往往意味着类过于复杂、私有方法承载着太多的职责,通过公有方法覆盖私有方法的测试成本过高,难度太大。因此,更好的解决办法是 分离职责、分而治之、单独测试。通过分离职责,单独对各部分逻辑进行测试,测试就会简单很多,如图3所示;另一个例子如图4所示。
图3 单独对各部分逻辑进行测试
图4 另一个案例
如果在不改变代码的前提下为这样的代码写测试,首先要花很多时间理解它,理清各个分支,分别为它们建立复杂的测试准备状态等,成本很高,有时甚至为不可能完成的任务。
2、Mock是否是遗留系统单元测试的“银弹”:当对遗留系统进行单元测试时,Mock作为“银弹”时不时地出现了。面对遗留系统中有较深依赖链的一些方法, 把那些难以建立的依赖统统Mock掉是最快的手段。但经验告诉我:对Mock保持警惕,一旦引入它,就容易被人滥用。当然它本身无错,错在使用它的人,如 果一定要怪Mock,就怪有些Mock工具过于强大吧。滥用Mock会导致:
● 测试真实性的减弱,降低测试程序作为测试本身的价值;
● 滥用Mock的“交互验证(Verification)”,使得测试和实现紧密耦合,降低测试的稳定性。
我专门回顾了之前做的几个系统,发现用到Mock的时候微乎其微,大多使用在对不受控依赖建立测试替身上。在保证测试执行速度的前提下,这是我推荐的做法。但面对遗留系统时,我们很容易把“难以建立依赖测试状态”作为使用Mock的借口,大量滥用。
单元测试,相对于功能测试等高层次的测试,是代码级别的白盒测试。但之于它的测试对象而言,我们仍然应当把单元测试视为黑盒测试——单元级别的黑盒测试。例 如对一个排序方法而言,不管它采用什么排序算法实现,大多情况下我们只在乎它是否可以将一个无序数组排序。它的测试也只要基于输入(无序数组)和输出判断 (有序数组)即可,就算排序算法改变,测试仍然不受影响。从这个排序方法的角度看,测试对它的内部实现不关心,是黑盒的;当内部实现改变时,只要外部行为 不变,测试就不会受到影响。滥用Mock很容易让测试违反这个特质。
此种情况下,我们选择的方案是:选择从依赖链的末端开始测试,从这里开始逐渐完备测试状态准备机制。在保证测试速度(运行速度和编写速度)的前提下,尽量避免使用Mock。当然,如果你的代码依赖复杂、混乱,那么首先可能要重构,重新组织分配职责,简化依赖关系。
简而言之,面对遗留系统进行单元测试时,有几个值得关注的实践:
● 分离职责,分而测之,优于私有方法进行测试;
● 逐步完备测试状态准备机制,优于使用Mock。
之前,我个人对于在遗留系统上实施测试自动化建设是总体悲观的:遗留系统庞大,测试的效果并不会在短期内得以体现;而团队,恰恰经常认为遗留系统足够稳定 (没有什么大需求,而且90%的代码可能短期不需要改动),没有动刀的必要。因此,大多数团队很有可能会在看到测试带来的效果之前就放弃了。
但请相信我,这一切都是假象。故障频发的正是这些遗留系统:
● 稳定是假象,遗留系统里隐藏着很多故障和漏洞,只是暂时没有爆发而已;
● 稳定是假象,粗劣的设计让任何一个需求的变化都会像霰弹一样影响整个系统,遗留系统大都是极其脆弱的。
而单元测试,除了它本身带来的测试价值之外,对于遗留系统而言,更大的部分在于以下三个方面:
● 驱动遗留系统的重构,提升架构设计和内部质量(遗留系统虽庞大,但坏味道其实都雷同)。这是对遗留系统而言最关键但一般情况下最没有可能去做的事情;
● 暴露并解决系统中的缺陷和漏洞,在这个过程中,我们发现并处理了很多漏洞和缺陷,包括多线程处理上、业务逻辑等;
● 更重要的是提升团队的重构和设计能力以及团队质量意识,特别是内部质量。
总结
面对遗留系统,选择合适的测试策略,能够让自动化测试的投入在一定时期内看到效果,并且建立可持续进行的机制。同为自动化测试,每种测试在面对遗留系统时遇 到的挑战是不同样的,它们起到的效果也不尽相同。对于遗留系统而言,功能测试是盾,只有它的保护,我们才能放心地对遗留系统动刀;而单元测试是刃,它撬动 的不仅是遗留系统,更是遗留团队。