第4章 测试
“烈火验真金,逆境磨意志。”
—卢修斯·塞尼加(1)
写单元测试是一种验证行为,更是一种设计行为。同样,它更是一种编写文档的行为。写单元测试避免了相当多的反馈环,尤其是功能验证相关的反馈环。
测试驱动开发(TDD)
如果在开始设计程序之前设计测试会如何呢?如果我们在因为对应方法不存在而注定失败的测试之前拒绝实现这个方法会如何?如果我们在因为实现不存在而注定失败的测试之前拒绝写任何一行代码又会如何?如果我们先写一个失败的测试,这个测试断言了对应功能的存在,然后实现功能让测试通过,如此这般增量地往代码中添加功能又会如何?这种方式对软件的设计有何影响?我们能从大量全面的测试中得到什么好处?
首当其冲同时也最显著的影响是,程序中每个方法都有对应的测试来验证它的行为。这组测试对进一步开发起到了兜底的作用。测试会告诉我们何时不小心破坏了一些既有功能。我们可以往程序中添加方法,也可以改变程序的结构,而无需担心在这个过程中破坏了什么重要的功能。测试告诉我们程序依然稳固如初,所以可以更加随心所欲地改进程序。
一个很重要但不太显著的作用是,先写测试的行为迫使我们采用一种不同的视角,我们必须以一种有利于调用者的视角去看待我们将要写的程序。因此,我们需要在考虑方法实现的同时考虑程序对外的接口。先写测试会使设计出来的软件便于调用。
更重要的是,测试先行会迫使我们设计出可测试的程序。设计出便于调用和测试的程序是非常重要的。为了能够便于调用和测试,软件不得不和周边的程序解耦。因此,先写测试的行为强制我们将软件解耦。
测试先行的另一个重大的好处是测试其实是一种宝贵的文档形式。测试会告诉你想如何调用一个方法或者创建一个对象。测试是一组旨在帮助程序员搞清楚如何使用这些代码的例子程序。这个文档是可以编译执行的,它会始终保持更新,绝不撒谎。
测试先行设计的示例
最近,我写了一个名为《抓怪兽》(2)的程序,纯属娱乐。这是一个简单的冒险类游戏,玩家在洞穴中走动,设法在被怪兽吃掉前杀死它。洞穴是由一系列通道相连的房间组成的。每个房间都有通道通向东、南、西、北四个方向。玩家告诉计算机往哪个方向移动以此模拟四处走动。
我为这个程序事先写的测试中,有一个是程序4-1中的testMove。这个方法创建了一个新的WumpusGame对象,通过东面的通道连通4号房间和5号房间,我把玩家放在4号房间中,发出向东移动的命令,接着断言玩家应该在5号房间中。
程序4-1
public void testMove() { WumpusGame g = new WumpusGame(); g.connect(4, 5, "E"); g.setPlayerRoom(4); g.east(); assertEquals(5, g.getPlayerRoom())); }
这段测试代码是在写WumpusGame程序之前完成的。我采纳了Ward Cunningham(3)的建议,按照我期望的可读的方式写下了这个测试。我相信只要按照测试所暗示的结构写出的程序就能通过测试。这种方法就被称为“意图编程”(intentional programming)。在实现之前,先在测试中阐述你的意图,尽可能使其简单易读,并且相信这种简单和清晰能给程序指出不错的结构。
意图编程立马启发我做出了一个有趣的决定。测试代码中没有用到Room类。把一个房间连通到另一个房间表达了我的意图。看起来,我并不需要一个Room类来提高表达性。相反,我可以仅用整数来表示房间。
这看起来不够直观。毕竟,在你看来这个游戏都是有关房间、在房间之间走动、找到房间中的东西,诸如此类的。那是不是意味着因为缺少Room类,我的设计就有缺陷呢?
我可以争辩说,在《抓怪兽》游戏中,连接(connection)这个概念要比房间的概念重要得多。也可以说最初的测试指明了一个解决问题的好方法。我认为事实的确如此,但那并不是我想强调的点。我想强调的点是测试在非常早的阶段就为我们阐明了一个重要的设计问题。测试先行就是在各种设计决策中进行甄别的行为。
注意,测试告诉了我们程序如何工作,我们大多数人都可以非常容易地根据这个简单的规格实现WumpusGame的4个已命名的方法。同样地,命名并实现其他3个方向的命令也不难。如果以后我们想知道如何把两个房间连通起来,或者怎么朝一个特定的方向走动,这个测试会直接了当地告诉我们。测试在这里扮演着一种角色,它是描述程序行为的可编译、可执行的文档。
测试隔离
在写产品代码之前,先写测试通常能暴露程序中应该解耦的地方。例如,图4.1展示了一个薪水支出应用(payroll application)的简单UML图(4)。Payroll类使用EmployeeDatabase获取一个Employee对象,它让Employee计算自己的薪水。接着,把计算结果传递给CheckWriter对象生成一张支票。最后,在Employee对象中记录下支付信息,并把Employee对象写回数据库中。
图4.1 耦合的薪水支付模型
假设我们还没有编写任何代码。到目前为止,这个图也是在经过快速的设计会议[Jeffries2001]之后刚刚画到白板上的。现在我们需要编写规定Payroll对象行为的测试,与这些测试相关的问题也很多。首先,要使用什么数据库呢?Payroll对象需要从若干种类的数据库中读取数据。我们必须要在能够对Payroll类进行测试前,写一个功能完善的数据库吗?我们要把什么样的数据加载到数据库中呢?其次,我们如何验证打印出来的支票是正确的?我们无法写一个自动化测试来观察打印机打印出来的支票并验证上面的金额是否正确。
解决这些问题的方法就是使用MOCK OBJECT[Mackinnon2000]模式,我们可以在Payroll类及其所有协作者之间插入接口,然后创建实现这些接口的测试桩(test stub)。
图4.2展示了一个这样的结构。Payroll类现在使用接口同EmployeeDatabase、CheckWriter以及Employee交互,创建了3个实现了这些接口的MOCK OBJECT。PayrollTest会对这些MOCK OBJECT进行查询,以此检测Payroll对象是否对它们进行了正确的管理。
程序4.2展示了测试的意图。测试中创建了合适的mock对象,并把它们传递给了Payroll对象,告诉Payroll对象为所有雇员支付薪水,接着要求mock对象验证所有已开支票以及所有已记录支付信息的正确性。
程序4.2 TestPayroll
public void testPayroll() { MockEmployeeDatabase db = new MockEmployeeDatabase(); MockCheckWriter w = new MockCheckWriter(); Payroll p = new Payroll(db, w); p.payEmployees(); assert(w.checksWereWrittenCorrectly()); assert(db.paymentsWerePostedCorrectly()); }
当然,这个测试检查的都是Payroll应该使用正确的数据调用正确的函数。它既没有真正去检查支票的打印,也没有真正去检查一个真实数据库的正确刷新。相反,它检查了Payroll在完全隔离的情况下应该具备的行为。
你可能好奇为什么需要MockEmployee类。看上去好像可以直接使用真实的Employee类。如果真是如此,我会毫不犹豫使用它。在本例中,我认为对于检查Payroll类的功能,Employee类显得有点复杂了。
图4.2 利用Mock对象测试解耦之后的薪水支付模型
意外获得的解耦
对Payroll类的解耦是一件好事,我们因此可以切换不同的数据库和打印机,这种能力既是为了测试也是为了应用的可扩展性。我觉得为了测试而进行解耦很有意思。显然,为了测试而对模块进行隔离的需要,迫使我们向着对整个程序结构都有利的方向进行解耦。测试先行改善了设计。
本书中大量的章节都是关于依赖管理方面的设计原则。这些原则在解耦类和包方面提供了一些指导和技巧。如果把这些原则作为单元测试策略的一部分来实践,就会发现这些原则非常有用。单元测试在解耦方面起到了很大的推动和指导作用。
验收测试
作为验证工具,单元测试是必要的,但不够充分。单元测试是用来验证系统中小的要素可以按照期望的方式工作,但是它们没有验证系统作为一个整体工作时的正确性。它是用来验证系统中个别机制的白盒测试(white-box test,了解并依赖于被测试内部结构的测试)。验收测试是用来验证系统满足客户需求的黑盒测试(black-box test,不了解并依赖于被测试内部结构的测试)。
验收测试是由不了解系统内部机制的人写的。客户可以直接或者和一些技术人员,可能是QA(Quality Assurance)人员,一起写验收测试。验收测试是程序,所以可以运行。不过,通常会用为应用程序的客户专门设计的脚本语言来写。
验收测试是关于一个特性(feature)的终极文档。一旦客户写完用于验证一个特性的验收测试,程序员就可以阅读那些验收测试来真正理解这个特性。所以,正如单元测试作为系统内部结构的可编译运行的文档那样,验收测试则是作为系统特性的可编译执行的文档。
此外,先写验收测试的行为对于系统的架构方面具有深远的影响。为了让系统具有可测试性,就必须在高级别的架构层面对系统解耦。例如,为了使验收测试无需通过用户界面(UI)就能访问业务规则,就必须解除用户界面和业务规则之间的耦合。
在项目迭代的初期,会受到用手工的方式进行验收测试的诱惑。但是,这样做会在迭代的初期就丧失由自动化验收测试施加的对系统解耦的促进作用,所以是不明智的。在最早开始迭代时,如果非常清楚需要自动化验收测试,那么你就会做出非常不同的架构权衡。并且,正如单元测试可以促使你在小的方面做出优良的设计决策一样,验收测试可以在大的方面促使你做出优良的架构决策。
创建一个验收测试框架(framework)可能并不容易。不过,如果仅仅针对单轮迭代中包含的特性进行验收测试,创建验收测试所需要的那部分框架,就会发现并不难,而且你会发现这些努力都是值得的。
验收测试的示例
重新回顾原来的薪水支付应用程序。第一轮迭代中,我们必须要能够往数据库中添加或从数据库中删除员工数据。我们也必须能够为员工创建支票(paycheck)。幸运的是,我们只需要处理领薪水的员工,其它类型的员工可以推迟到后续的迭代中再作处理。
我们还没有写过任何一行代码,也还没有进行丝毫的设计。现在正是开始思考验收测试的最佳时刻。再重申一遍,意图编程是一个很有用的工具。我们应该把验收测试写成期待中的样子,然后,我们就可以围绕这个结构组织脚本语言和薪水支付系统。
我想要验收测试便于编写且易于修改。我想把它们放到一个配置管理的工具中存储起来,这样就可以随时随地随心所欲地运行。因此,把验收测试写入简单的文本文件里是合理的。
下面的代码是一个验收测试脚本的样例:
AddEmp 1429 "Robert Martin" 3215.88 Payday Verify Paycheck EmpId 1429 GrossPay 3215.88
在这个例子里,我们把编号为1429的员工添加到数据库中。他的名字是马丁·鲍勃(Robert Martin),他每个月的薪水是3215.88美元。接下来,我们告诉系统薪水支付日到了,需要给每位员工支付薪水。最后,我们验证为编号1429的员工生成的支票上确实有个数额为3215.88美元的GrossPay字段。
显然,写这种类型的脚本对客户来说非常容易。当然,往这种脚本添加功能也是很容易的。不过,我们得考虑一下这个系统的结构暗合了什么逻辑。
脚本的头两行针对的是薪水支付应用的功能。我们可以把这些行称为薪水支付交易,这是应用程序的用户所期望的功能。然而,Verify所在那一行并不是用户期望的交易功能。这一行是验收测试的专属指令。
因此,验收测试框架必须要解析这个文本文件,把支付交易从验收测试中剥离出去。它必须把薪水支付交易发送给应用程序,然后使用验收测试的指令从应用程序中查询出结果进行验证。
这已经把应用程序的重点放到架构上了。薪水支付程序必须接受直接来自用户的输入,也必须接受来自验收测试框架的输入。我们想把这两条途径尽早合并。因此,看起来薪水支付程序好像需要一个交易处理器,来处理多个输入源带来的形如AddEmp和Payday的交易形式,以便最小化专用代码的数量。
一种解决方案是使用XML来表示输入给薪水支付程序的交易。验收测试框架当然可以产生XML格式的输出,并且薪水支付系统的UI好像也可以产生XML格式的输出。所以,我们可以看到如下的交易数据:
<AddEmp PayType=Salaried> <EmpId>1429</EmpId> <Name>Robert Martin</Name> <Salary>3215.88</Salary> </AddEmp>
这些交易可以通过子程序调用、套接字甚至批处理输入文件的方式进入薪水支付应用程序。在开发的过程中,从一种方式改变成另一种方式是一项简单的工作。因此,在初期迭代中,我们可以先采用从文件中读入交易的方式,后续再调整到API或者套接字方式。
验收测试如何调用Verify指令呢?很明显,它必须使用某些方法访问由薪水支付应用程序所产生的数据。同样,我们不必让验收测试框架从已经打印出来的支票上读取数据,我们有更好的方式。
我们可以让薪水支付应用程序以XML的形式产生它的支票。验收测试框架可以获取这份XML文档,并查询出合适的数据。最后一步是要把XML形式的支票打印出来,这是一件微不足道的事情,用手工足以完成验证。
于是,薪水支付应用程序可以创建包含所有支票信息的XML文档。看上去可能像下面这样:
<Paycheck> <EmpId>1429</EmpId> <Name>Robert Martin</Name> <Grosspay>3215.88</Grosspay> </Paycheck>
很明显,当验收测试框架接收到这样的XML时,它就可以执行Verify指令了。
同样,可以通过套接字、API的方式传递这份XML文档,也可以把它存储到文件中。对于最开始的迭代来说,文件是最简单的方式。因此,我们以最简单的方式开始薪水支付应用程序的开发,它从一个文件读入XML形式的交易,并且以XML的形式把支票输出到一个文件中。验收测试框架会读取文本形式的操作,把它们转化成XML形式并写入一个文件中。接着它会调用薪水支付应用程序执行。最后,框架会读取薪水支付应用程序输出的XML数据,并调用Verify指令进行验证。
意外获得的架构
注意验收测试对薪水支付系统架构施加的影响。有一个无可争议的事实是测试先行让我们很快就有了使用XML来描述输入和输出的想法。这个架构把交易的来源和薪水支付应用本身解耦开来。同时,它也解耦了支票打印机制和薪水支付应用本身。这些都是好的架构决策。
小结
测试套件运行起来越简单,运行就会越频繁。测试运行得越多,就会越快地发现和测试偏离的情况。如果能够一天多次运行所有的测试,那么系统失效的时间就绝不会超过几分钟。这是一个合理的目标。我们决不允许系统倒退。一旦测试工作在某个级别上,就决不能让它倒退到更低的级别上。
然而,验证仅仅是写测试的好处之一。单元测试和验收测试都是一种文档形式,都是可以编译执行的。因此,它是准确和可靠的。此外,写测试所使用的语言是明确的,便于读者阅读。程序员能够阅读单元测试,是因为单元测试是使用他们的编程语言写的。客户能够阅读验收测试,是因为验收测试是使用客户自己设计的语言写的。
也许,测试最重要的好处就是它对于架构和设计的影响。为了使一个模块或者应用程序具有可测试性,必须要对它进行解耦。越是具有可测试性,耦合关系就越弱。全面地考虑验收测试和单元测试的行为对于软件的结构具有深远的正面影响。
参考文献
1. Mackinnon, Tim, Steve Freeman, and Philip Graig. Endo-Testing: Unit Testing With Mock Objects. Extreme Programming Examined. Addison-Wesley, 2001.
2. Jeffries, Ron, et al., Extreme Programming Installed. Upper Saddle River, NJ: Addison-Wesley, 2001.
(1)中文版编注:全名卢修斯·阿奈乌斯·塞内卡或辛尼加(Lucius Annaeus Seneca,约公元前4年—公元65年),古罗马时代著名的斯多葛学派哲学家、政治家和剧作家。著作有《对话录》《论怜悯》《论恩惠》《书信集》《天问》。
(2)中文版编注:《抓怪兽》(Hunt the Wumpus)是早期很重要的一个电脑游戏,基于一个简单的隐藏/搜索形式,有一个神秘的怪兽(Wumpus)潜行于一个由多个房间组成的网络中。玩家可以使用基于命令行的文字界面,通过输入指令来在房间中移动,或者沿着几个相邻房间中弯曲的路径射箭。有20个房间,每个房间与另外三个相连接,排列像一个正十二面体的顶点(或者是一个正二十面体的面)。可能的危险有超级蝙蝠(它会把玩家扔向任意位置)和怪兽。玩家从提示中推断出怪兽所在的房间,向房间内射箭。然而,如果射错了房间,就会惊动怪兽,导致玩家会被它吃掉。这款策略解密类游戏最初由就读于达特茅斯学院的格里戈利·亚伯(Gregory Yob)用Basic写。该游戏的一个简化版后来也变成人工智能领域中描述(一种计算机程序)概念的经典例子。人工智能(AI)经常用来模拟玩家角色。
(3)中文版编注:沃德·坎宁安(1949— ),计算机程序员,维基之父,普渡大学交叉学科(电子工程与计算机科学)工学学士毕业,计算机硕士毕业。2003年加入微软“模式与实践”组,2005年转入Eclipse基金会。代表作有《维基之道》。
(4)如果对UML不了解,可以参见附录A和附录B的详细描述。