本文是一个速成班,介绍了如何编写可维护的 JavaScript。我们向一个贯穿全文的例子中逐渐添加新功能,并遵循如下简单的规则:编写一个单元测试,然后让它通过。每个测试都起到质量反馈回路的作用,给那些想修改产品代码的人创建了一个安全保护网,以及一份可以执行的文档。通过简单、失败的测试开始每个功能,我们可以保证所有的功能都被测试覆盖到了。我们也避免了重写代码后再进行测试的高昂代价。考虑到 JavaScript 开发者很容易深陷泥沼、难以自拔的事实,这显得尤其难能可贵──只需要考虑一下 DOM API 和 JavaScript 语言本身之间有多少全局可变状态就够了。
这个贯穿全文的例子是赌场的 3 轴老虎机。每轴有 5 种可能的状态,用图片来表示。当老虎机的 play 按钮被按下时,每个轴会随机给出一种状态。老虎机的余额根据三个轴的状态是否相等而增加或者减少。
我们的工具有 stubs、mock 对象和一丁点的依赖注入。我们使用 JsUnit 运行单元测试,以及一个叫做 JsMock 的 JavaScript mock 对象库。集成测试──单元测试的补充,则超出了本文的范围。这并不意味着集成测试不重要──仅仅是因为我们希望得到更快的反馈,而不是从类似 Selenium 和 Watir 这样的工具那里得到更慢、更全面的反馈。
JsUnit,一个 JavaScript 单元测试框架
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 vs. Mocks
让我们通过老虎机这一例子,看一看 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 实例与它的一个协作者——网络客户端之间的交互。
JsMock,JavaScript 的 Mock 对象库
仔细查看你会发现 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(); {1} } {1}
这里我们验证老虎机在网络发生故障时可以优雅地失败。这是单元测试能够胜过系统集成测试的一个很好的例子。您能想像每个 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'; } }; {1}
与 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>
参考资料
- JSMock ,一个用于 JavaScript 的具有完备功能的 Mock 对象库,作者是 Justin DeWind
- JsUnit ,一个用于客户端(浏览器内)JavaScript 的单元测试框架
- Mocks Aren’t Stubs ,Martin Fowler 的一篇文章
- Functional 是一个用于 JavaScript 的功能程序类库,作者是 Oliver Steele
- Dependency Injection ,Martin Fowler 的一篇文章
作者简介
Dennis Byrne 住在芝加哥,在 DRW Trading 工作,这是一个证卷交易公司(proprietary trading firm)和做市商(market maker)。他是一个作家和演讲家,是开源社区的活跃分子。
查看英文原文: JavaScript Test Driven Development with JsUnit and JSMock 。
给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
活动推荐:
2023年9月3-5日,「QCon全球软件开发大会·北京站」 将在北京•富力万丽酒店举办。此次大会以「启航·AIGC软件工程变革」为主题,策划了大前端融合提效、大模型应用落地、面向 AI 的存储、AIGC 浪潮下的研发效能提升、LLMOps、异构算力、微服务架构治理、业务安全技术、构建未来软件的编程语言、FinOps 等近30个精彩专题。咨询购票可联系票务经理 18514549229(微信同手机号)。
评论