本文是一个速成班,介绍了如何编写可维护的JavaScript。我们向一个贯穿全文的例子中逐渐添加新功能,并遵循如下简单的规则:编写一个单元测试,然后让它通过。每个测试都起到质量反馈回路的作用,给那些想修改产品代码的人创建了一个安全保护网,以及一份可以执行的文档。通过简单、失败的测试开始每个功能,我们可以保证所有的功能都被测试覆盖到了。我们也避免了重写代码后再进行测试的高昂代价。考虑到JavaScript开发者很容易深陷泥沼、难以自拔的事实,这显得尤其难能可贵──只需要考虑一下DOM API和JavaScript语言本身之间有多少全局可变状态就够了。
这个贯穿全文的例子是赌场的3轴老虎 机。每轴有5种可能的状态,用图片来表示。当老虎 机的play按钮被按下时,每个轴会随机给出一种状态。老虎 机的余额根据三个轴的状态是否相等而增加或者减少。
我们的工具有stubs、mock对象和一丁点的依赖注入。我们使用JsUnit运行单元测试,以及一个叫做JsMock的JavaScript mock对象库。集成测试──单元测试的补充,则超出了本文的范围。这并不意味着集成测试不重要──仅仅是因为我们希望得到更快的反馈,而不是从类似 Selenium和Watir这样的工具那里得到更慢、更全面的反馈。
JsUnit是JavaScript的开源单元测试框架。它受到JUnit的启发,并完全用JavaScript编写。作为最流行的 JavaScript单元测试框架,它还提供了一些ant任务,使开发人员在持续集成服务器上构建时很容易运行测试套件。持续集成是另外一个重要的实践, 其与TDD结合使用时是对质量的一个“强有力的保证”,不过这也超出了本文的范围。
让我们从JsUnit的test runner开始吧。Test runner是一个普通的HTML和JavaScript web页面,意味着你的单元测试可以直接在浏览器或者你想支持的浏览器中运行。解压缩JsUnit下载文件,你就会在根目录下发现testRunner.html。你不需要通过web服务器访问它──只需要通过文件系统加载它进行浏览就可以了。
Test runner最重要的控件是位于页面顶部的文件输入栏。这个控件意在获取一个指向测试页面或者测试页面套件的路径。现在我们看一个JsUnit测试页面的简单例子。
<html> <title>A unit test for drw.SystemUnderTest class</title> <head> <script type='text/javascript' src='../jsunit/app/jsUnitCore.js'></script> <script type='text/javascript' src='../app/system_under_test.js'></script> <script type='text/javascript'> function setUp(){ // perform fixture set up } function tearDown() { // clean up } function testOneThing(){ // instantiating a SystemUnderTest, a class in the drw namespace var sut = new drw.SystemUnderTest(); var thing = sut.oneThing(); assertEquals(1, thing); } function testAnotherThing(){ var sut = new drw.SystemUnderTest(); var thing = sut.anotherThing(); assertNotEquals(1, thing); } </script> </head> <body/> </html>
JsUnit与其它xUnit框架有很多相似之处。正如你期望的那样,test runner加载测试页面,调用每个测试函数。每个测试函数的调用被夹在setUp和tearDown调用之间。setUp函数给测试者提供了一个机会, 可以选择在此构造测试夹具(test fixture)。测试夹具用以给页面中所有的测试准备状态。tearDown函数则给测试者提供了另外一个机会,可以去清除或者重置测试夹具。
然而,与其他的xUnit框架相比,JsUnit在测试生命周期方面稍有不同。每个测试页面被加载到独立的窗口中,以防止应用程序代码通过开放类覆盖测试框架代码。在每个被加载的窗口中,所有的单元测试函数都会被调用到。页面不会为每个测试函数重新加载。从另一方面来说,在JUnit中,测试页面等同于一个测试用例,test runner会给每个测试方法生成一个单独的测试用例的实例。换言之,
JsUnit加载有N个测试函数的测试页面,只需要1次
JUnit创建有N个测试方法的测试用例,需要N次
JavaScript开发者因此更容易陷入“一招不慎,满盘皆输”的境地,因为对测试页面状态的改变会影响后续测试的结果。而Java开发者在改变 测试用例对象的状态时则不会遇到这种危险。JsUnit为什么这样做呢,而不是对每个测试,简单地重新加载一次测试页面?这是因为在测试套件中给每个测试 函数重新创建DOM会有性能消耗。值得庆幸的是,JavaScript开发者不必过多关心全局状态变化带来的负面影响。在诸如JVM和CLR的程序平台 上,修改静态变量会影响整个测试套件中所有后续的测试,而不仅仅是同一个测试用例的测试。
jsUnitCore.js脚本必须嵌入到所有的 测试页面中。这个重要的文件位于JsUnit下载文件解压之后的app目录中。它包含一组断言函数,与其他xUnit框架的行为多少有些相同。一个细微的 区别源于JavaScript有两个等于符号。一个是相等(==)操作符,还有一个三等(===)操作符。比如,下面的第一个表达式是true,第二个是 false:
0 == false
0 === false
为什么会这样呢?相等操作符不像三等操作符那样严格,允许运行时对第一个布尔表达式执行类型转换。所以不难理解新手会认为下面的断言会通过:
assertEquals(false, 0);
实际上这个断言会失败,因为JsUnit框架提供的断言函数对所有的比较采用更严格的三等操作符,而不是相等操作符。通过避免相等操作符,JsUnit能够避免许多看似正确实则错误的测试。
让我们通过老虎 机这一例子,看一看stubs和mock对象。由于这个单元测试关注单个对象,我们创建一个老虎 机,并把它当作被测系统。现在让我们写一个简单的测试,生成老虎 机。
function testRender() { var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; var randomNumbers = [2, 1, 3]; var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); slotMachine.render(); assertEquals('Pay to play', buttonStub.value); assertTrue(buttonStub.disabled); assertEquals(0, balanceStub.innerHTML); assertEquals('images/2.jpg', reelsStub[0].src); assertEquals('images/1.jpg', reelsStub[1].src); assertEquals('images/3.jpg', reelsStub[2].src); }
testRender函数使用了两个DOM元素的stub,把它们都注入到被测系统的构造函数中,并调用render方法。测试的最后对render方法的期望结果进行断言。请注意通过使用DOM元素的stub,我们可以测试render方法的结果,而不必实际去做任何事情,这些事情可能导致测试页面的其他测试失效。这种方法与使用真实DOM元素各有利弊。使用真实的DOM元素更容易发现跨浏览器不兼容的bug,但是如果每个测试最后或者tearDown时没有进行重置,你的测试本身也更容易带来bug。
被测系统并未直接调用全局函数Math.random,来决定每个轴初始的图片状态。相反,老虎 机是依赖创建时提供给它的参数,来得到这些数字。这让我们可以测试一段不确定的代码,好像完全可以预测一样。请注意测试没有覆盖浏览器原生的Math.random实现,从而避免了状态变化的风险和副作用。
等等,等一会儿... 测试函数有不止一个断言,这样行吗?敏捷社区中部分人认为每个测试中有多于一个断言是邪恶的。然而,给用来赚钱的实际的应用程序很少会这么写测试套件。当亲眼看到JUnit框架本身实物测试套件中每个测试有多少断言时,相信会有很多人非常惊讶。
对象的构造函数和render方法看上去是这样的:
/** * Constructor for the slot machine. */ drw.SlotMachine = function(buttonElement, balanceElement, reels, random, networkClient) { this.buttonElement = buttonElement; this.balanceElement = balanceElement; this.reels = reels; this.random = random; this.networkClient = networkClient; this.balance = 0; }; drw.SlotMachine.prototype.render = function() { this.buttonElement.disabled = true; this.buttonElement.value = 'Pay to play'; this.balanceElement.innerHTML = 0; for(var i = 0; i < this.reels.length;){ this.reels[i++].src = 'images/' + this.random() + '.jpg'; } };
让我们往老虎 机里放一些钱。在这个场景下,老虎 机异步调用服务器端以返回用户余额。这很有挑战性,因为单元测试中没有网络,AJAX调用会失败。当我们编写单元测试时,我们应该尽量编写没有副作用的代码,IO当然也属于这一类。
function testGetBalanceGoesToNetwork(){ var url, callback; var networkStub = { send : function() { url = arguments[0]; callback = arguments[1]; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); assertEquals('/getBalance.jsp', url); assertEquals('function', typeof callback); }
这个测试使用了网络stub。什么是stub呢?stub与mock有什么区别呢?许多开发者经常混淆这个两个词,认为它们是同义词。测试社区中,stub这个词是保留给基于状态测试的。JavaScript中它通常是指一个简单的object literal,能够返回预先硬编码的数值。而mock这个词则是保留给交互测试的。Mock可以针对行为训练。这些行为与被测对象进行交互,并且可以验证这些交互。
通过网络客户端stub,我们现在能够测试getBalance方法。通过本地变量url和callback,应用于构造函数的object literal stub能够记录它与被测系统的交互行为。这些本地变量使我们能够在测试的最后执行断言。不幸的是,我们用错工具了。这是一个经典例子,说明了stub的局限性,以及为什么使用mock对象。这个测试的目的不是在给了一定状态之后,验证被测系统的行为。测试关注的是drw.SlotMachine实例与它的一个协作者——网络客户端之间的交互。
仔细查看你会发现testGetBalanceGoesToNetwork创建了自己的微型mocking框架。现在让我们重构测试,使用一种通用的mocking框架。我们需要在测试页面添加一个独立的脚本标签,并像这样重写测试:
<script type='text/javascript' src='../jsmock/jsmock.js'></script> function testGetBalanceWithMocks(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); mockControl.verify(); }
现在使用更少的代码就能得到相同的反馈,我们甚至打下了更简单的基础,有利于进一步测试。如何做到这样的呢?代码的第一行使用JsMock提供的MockControl构造函数创建一个对象。代码于是创建了一个有send方法的mock对象。在一个有实际NetworkClient类的应用程序中,我们甚至不必把一个object literal应用到createMock方法中。JsMock可以通过原型推断出来。
var mock = mockControl.createMock(NetworkClient.prototype);
一旦network客户端的mock对象创建之后,我们编程期望带有特定参数的send方法被调用了一次。我们关心服务器资源名称是正确的,并且第二个参数是一个回调函数。mock对象被注入到被测系统的构造函数中,通过MockControl对象的验证方法,验证交互行为从而得出结论。如果因为任何原因,老虎 机的实现没有调用network客户端的send方法,或者与预期的参数不符,验证方法会抛出一个异常,测试会失败。
现在让我们编写另外一个测试,验证一个drw.SlogMachine实例什么时候、多久回到客户端一次。如果服务器端响应完成之前getBalance方法被调用,我们不希望余额被返回两次。这会导致老虎 机的余额两次返回到用户账户,并且花费额外的带宽。
function testGetBalanceWithMocksToTheNetworkOnce(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response mockControl.verify(); }
还记得我们在这里的第一个crack吗?当时我们创建了一个自己的微型mocking框架?那看上去像是一个实用的解决方案,但是你想像一下测试这样的交互行为,会写多少代码。仅仅由于参数的原因,让我们看看一个纯粹的stub解决方案,有多少瑕疵。
function testGetBalanceFlawed(){ var networkStub = { send : function() { if(this.called) throw new Error('This should not be called > 1 time'); this.called = true; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response }
测试断言,网络客户端只被调用了一次,第一次使用之后网络stub就简单地抛出错误。这里有一个小问题,因为测试是人工控制待测对象断言。比如,如果待测系统将要多次调用网络stub的send函数,而它自己处理抛出的异常,那么测试永远也不会失败,因为test runner永远也不会收到任何出问题的通知。一个解决方法是创建更精致的微型mocking框架,但是通用的诸如JsMock这样的方法通常更简单。
JsMock不仅仅能够让我们测试方法调用顺序和参数值。这个测试演示老虎 机在网络发生故障时的行为。
function testGetBalanceWithFailure(){ var buttonStub = {}; var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andThrow('network failure'); var slotMachine = new drw.SlotMachine(buttonStub, null, null, null, networkMock); slotMachine.getBalance(); assertEquals('Sorry, can't talk to the server right now', buttonStub.value); mockControl.verify(); }
这里我们验证老虎 机在网络发生故障时可以优雅地失败。这是单元测试能够胜过系统集成测试的一个很好的例子。您能想像每个QA/发布周期中,对服务器每个集成点手工模拟一个网络故障花费的时间和金钱吗?
getBalance方法的实现现在看上去是这样的:
drw.SlotMachine.prototype.getBalance = function() { if(this.balanceRequested) return; try{ // this line of code requires the very excellent functional.js // library, found at http://osteele.com/sources/javascript/functional this.networkClient.send('/getBalance.jsp', this.deposit.bind(this)); this.balanceRequested = true; }catch(e){ this.buttonElement.value = 'Sorry, can't talk to the server right now'; } };
与stub相比,mock的一个缺点是和被测系统的耦合相当多,至少希望拿来就用。当被测系统的行为与期望不符时,你希望测试失败──你并不希望被封装的实现细节有任何变化时,测试就会失败。为了弥补这种情况,JsMock提供了放宽期望的能力。其实你已经看到了这个例子。当我们准备网络mock对象时,这样写的:
networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function));
我们并没有指定哪个回调函数会被用作第二个参数,只需要是一个回调函数就可以了。如果我们想把这些期望放的更宽些,我们可以这样尝试:
networkMock.expects().send(TypeOf.isA(String), TypeOf.isA(Function));
如果我们想引用网络客户端mock的send方法的实际回调函数,我们可以使用JsMock框架的andStub方法:
var depositCallback; networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andStub( function(){depositCallback = arguments[1];} ); depositCallback({responseText:"10"});
在我们继续之前,关于mock对象有两点需要知道。注意到每个测试最后如何调用MockControl的verify方法。这很重要。单元测试不调用verify方法就不会失败。许多开发者遇到过这样的事情,写了一些标准的单元测试函数之后,就认为把对verify方法从测试函数移到tearDown函数更好。虽然这会节省几行代码,也让你不必在每个测试函数的最后都去记住这一重要细节,不幸的是,它会给你带来一个新问题:tearDown中抛出的异常会被测试中抛出的第一个异常掩盖。第二个陷阱是新手经常过度使用mock对象,并用它们完全替代stub。不要这样。用stub来做基于状态的测试,使用mock做基于交互的测试。
我们可以用学到的任何知识测试下面的场景。这个测试模拟了一个用户在老虎 机上先输后赢的情形。
function testLoseThenWin(){ var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; // a losing combination, followed by a winning combination var randomNumbers = [2, 1, 3].concat([4, 4, 4]); var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); var balance = 10; slotMachine.deposit({responseText: String(balance)}); slotMachine.play(); assertEquals(balance - 1, balanceStub.innerHTML); assertEquals('Sorry, try again', buttonStub.value); slotMachine.play(); assertEquals('balance - 2 + 40', 48, balanceStub.innerHTML); assertEquals('You Won!', buttonStub.value); assertEquals('images/4.jpg', reelsStub[0].src); assertEquals('images/4.jpg', reelsStub[1].src); assertEquals('images/4.jpg', reelsStub[2].src); }
drw.SlotMachine类的play方法实现是这样的:
drw.SlotMachine.prototype.play = function(){ var outcomes = []; var msg = 'Sorry, try again'; for(var i = 0; i < this.reels.length; i++){ this.reels[i].src = 'images/' + (outcomes[i] = this.random()) + '.jpg'; } if(outcomes[0] == outcomes[1] && outcomes[0] == outcomes[2]){ msg = 'You Won!'; this.balance += (outcomes[0] * 10); } this.buttonElement.value = msg; this.balanceElement.innerHTML = --this.balance; };
最后,这是一个可以运行的老虎 机的示例:
<html> <title>A Slot Machine Demonstration</title> <head> <script type='text/javascript' src='functional.js'></script> <script type='text/javascript' src='slot_machine.js'></script> <script type='text/javascript' src='network_client.js'></script> <script type='text/javascript'> window.onload = function(){ var leftReel = document.getElementById('leftReel'); var middleReel = document.getElementById('middleReel'); var rightReel = document.getElementById('rightReel'); var random = function(){ return Math.floor(Math.random()*5) + 1; // generate 1 through 5 }; slotMachine = new drw.SlotMachine(document.getElementById('buttonElement'), document.getElementById('balanceElement'), [leftReel, middleReel, rightReel], random, new NetworkClient()); slotMachine.render(); slotMachine.getBalance(); }; </script> </head> <body id='body'> <div style="text-align:center; background-color:#BFE4FF; padding: 5px; width: 160px;"> <div>Slot Machine Widget</div> <div style="padding: 5 0 5 0;"> <img id='leftReel'/> <img id='middleReel'/> <img id='rightReel'/> </div> <div>Balance: <span id="balanceElement"></span></div> <input id="buttonElement" style="width:150px" type="button" onclick="slotMachine.play()"></input> </div> </body> </html>
作者简介
Dennis Byrne住在芝加哥,在DRW Trading工作,这是一个证卷交易公司(proprietary trading firm)和做市商(market maker)。他是一个作家和演讲家,是开源社区的活跃分子。
查看英文原文:JavaScript Test Driven Development with JsUnit and JSMock。