自动化测试软件对于开发来说是一个很重要的工具,而单元测试对于自动化测试来说是基本组成部分:软件的每一个组件或者单元可以在非人工介入的情况下,使用测试工具一遍遍的重复执行。换句话说,就是你可以写一次测试,然后不用付出额外成本的任意执行多次。
除了测试覆盖率带来的好处外,测试还可以指导软件设计,这就是TDD(基于测试驱动的设计):先有测试,后有开发代码。你开始写一个简单的测试,然后写实现代码并保证代码能通过测试。完成上述步骤后,扩展你的测试,让他覆盖更多设计功能,然后再编写实现代码。重复上面的步骤直到完成开发,你会发现你的实现代码和之前的版本已经非常不一样了。
JavaScript的单元测试和其他语言没什么不同,你需要一个提供测试运行器的小框架,他同时提供写测试用例的工具。
自动化单元测试
问题
你想自动化测试你的应用和框架,甚至想使用TDD的开发方式。你或许会想些一个自己的测试框架,但是那需要很多额外的工作,涉及到太多的细节,还需要处理在不同浏览器中测试JavaScript的问题。
解决方案
现在已经有很多JavaScript的单元测试框架了,例如你可以选择QUnit。QUnit是jquery使用的单元测试框架,而且他已经被广泛的使用在了不同的项目中。使用QUnit很简单,你只需要添加两个相关文件到你的html页面即可。QUnit包括qunit.js:测试运行器和测试框架,qunit.css:测试页面用于显示测试结果的css文件。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>QUnit basic example</title> <link rel="stylesheet" href="/resources/qunit.css"> </head> <body> <div id="qunit"></div> <div id="qunit-fixture"></div> <script src="/resources/qunit.js"></script> <script> test( "a basic test example", function() { var value = "hello"; equal( value, "hello", "We expect value to be hello" ); }); </script> </body> </html>
在浏览器中打开上面的文件,显示结果如下:
唯一需要的标签是<body>中含有id="qunit-fixture"的<div>,他对于所有QUnit的测试都是必须的,即使这个div元素是空的,他为测试提供夹具(fixture),我们会在“保持测试原子性”中详细介绍。
有趣的部分是跟在测试运行器(qunit.js)后面的脚本标签,他包含一个test方法。他包含两个参数,第一个参数是字符串类型,表示测试名称,他将会显示在测试结果和方法上。第二个参数是一个函数,包含实际的测试代码。他包含一个或者多个断言,上面的例子包含两个断言:ok() 和 equal()。我们会在“断言结果”中详细介绍。
我们注意到这里没有使用到document-ready,这次因为测试运行器会把test()添加到测试队列中,测试用例会被延迟执行。
讨论
测试套件的页眉显示页面名称,所有测试通过的时候,显示绿条;当至少有一条测试失败的时候显示红条。有选择框可供过滤结果,此外还有一个蓝条用来显示浏览器信息。选择框中有"Hide passed tests",当测试很多的时候可以使用它隐藏成功的测试,只显示失败的测试。
选择“”,会让QUnit在每次测试的开始和结束的时候罗列window的所有属性,并比较不同点。如果存在属性的添加和删除操作,测试失败,并显示不同点信息。这样可以验证我们的测试代码和被测试代码没有暴露任何的全局属性。
“notrycatch”选择框的作用是,告诉QUnit不使用try-catch跑测试,当有异常抛出的时候,测试运行器会停止运行。但是你会获得一个内部异常,这样在我们使用老浏览器(例如ie6)做测试的时候会有帮助。
页眉之下是测试总结,显示测试总用时,成功和失败的测试总数。当测试还在运行的时候,他会显示哪个测试用例正在被执行。
页面的主体部分是测试结果,每个实体以名字开头,后面跟着失败数、成功数和总断言数。点击实体将会显示每一个断言,经常会显示期望值和实际值。最后的“Rerun”链接会单独运行测试实体。
断言结果
问题
任何单元测试的实际元素都是断言,测试的开发者需要使用测试框架,将期望值和运行测试获得的实际值进行比较。
解决方案
QUnit提供三种断言。
ok( truthy [, message ] )
ok()是最基本的方法,他只需要一个参数,如果参数等于true,断言成功,否则失败。例外他还接受额外的字符串参数,用于显示测试结果。
test( "ok test", function() { ok( true, "true succeeds" ); ok( "non-empty", "non-empty string succeeds" ); ok( false, "false fails" ); ok( 0, "0 fails" ); ok( NaN, "NaN fails" ); ok( "", "empty string fails" ); ok( null, "null fails" ); ok( undefined, "undefined fails" ); });
equal( actual, expected [, message ] )
equal()方法使用简单的比较符(==)来比较期望值和实际值。当他们相等的时候,断言成功,否则失败。当失败的时候,期望值和实际值都会显示,另外还显示消息。
test( "equal test", function() { equal( 0, 0, "Zero; equal succeeds" ); equal( "", 0, "Empty, Zero; equal succeeds" ); equal( "", "", "Empty, Empty; equal succeeds" ); equal( 0, 0, "Zero, Zero; equal succeeds" ); equal( "three", 3, "Three, 3; equal fails" ); equal( null, false, "null, false; equal fails" ); });
使用ok() 和 equal()让我们更容易的找到失败的测试,因为测试失败的时候他会很明显的告诉我们哪个值导致了问题。当你需要使用严格比较(===)的时候,可以使用strictEqual()。
deepEqual( actual, expected [, message ] )
deepEqual()可以像equal()那样使用,但是他适用的场景更多。他不是使用简单比较符(==),他使用的是更精确的比较符(===)。这种情况下,undefined不等于null,0或者空字符串(“”)。他同时也比较对象的内容,{key: value} 等于
{key: value},甚至比较的两个对象有不同的实例。deepEqual()同样也处理NaN,dates,正则表达式,数组和函数,而equal()只检查对象实例。
test( "deepEqual test", function() { var obj = { foo: "bar" }; deepEqual( obj, { foo: "bar" }, "Two objects can be the same in value" ); });
如果你不想明确的比较两个对象的内容仍然可以使用equal(),但是deepEqual()是更好的选择。
同步回调
问题
有时候你的代码可能会阻止回调断言的执行,导致测试无声无息的就失败了。
解决方案
QUnit提供了一个特殊的断言,定义了测试包含的总断言数。当测试结束的时候,断言总数不相等,无论其他断言的执行情况,都会返回失败。使用上也相当简单,在测试开始的时候调用expect(),只需要传递期望的断言数作为方法参数。
test( "a test", function() { expect( 2 ); function calc( x, operation ) { return operation( x ); } var result = calc( 2, function( x ) { ok( true, "calc() calls operation function" ); return x * x; }); equal( result, 4, "2 square equals 4" ); });
另外一种方式是,把期望断言数作为他的第二个参数传给test():
test( "a test", 2, function() { function calc( x, operation ) { return operation( x ); } var result = calc( 2, function( x ) { ok( true, "calc() calls operation function" ); return x * x; }); equal( result, 4, "2 square equals 4" ); });
实例:
test( "a test", 1, function() { var $body = $( "body" ); $body.on( "click", function() { ok( true, "body was clicked!" ); }); $body.trigger( "click" ); });
异步回调
问题
虽然expect()对于同步回调的测试是有帮助的,但他不能用来处理异步回调的测试,异步回调和测试运行器中的执行队列的执行相冲突。在测试代码中执行一个timeout、interval或者ajax请求的时候,测试运行器只是会继续执行测试用例剩余的代码,然后接着执行测试队列中剩余的用例,而不会去执行异步操作。
解决方案
我们不使用test(),取而代之将使用asyncTest(),当你的测试代码执行完毕准备继续的时候执行start()。
asyncTest( "asynchronous test: one second later!", function() { expect( 1 ); setTimeout(function() { ok( true, "Passed and ready to resume!" ); start(); }, 1000); });
实例:
asyncTest( "asynchronous test: video ready to play", 1, function() { var $video = $( "video" ); $video.on( "canplaythrough", function() { ok( true, "video has loaded and is ready to play" ); start(); }); });
测试用户操作
问题
那些依赖于用户操作的代码,不能通过执行函数来测试。通常元素的事件使用异步函数,例如click,这些需要模拟。
解决方案
你可以使用jQuery的 trigger()方法来触发事件,然后测试预期的行为。如果你不想浏览器事件被触发,你可以使用triggerHandler()来执行事件相关方法。这对于测试链接的click事件是有帮助的,因为trigger()可能会使浏览器改变地址栏信息,这恐怕不是测试过程中想要发生的。假设我们有一个简单的
KeyLogger需要测试:
function KeyLogger( target ) { if ( !(this instanceof KeyLogger) ) { return new KeyLogger( target ); } this.target = target; this.log = []; var self = this; this.target.off( "keydown" ).on( "keydown", function( event ) { self.log.push( event.keyCode ); }); }
我们可以手动的触发keypress事件,然后观察logger是否工作:
test( "keylogger api behavior", function() { var event, $doc = $( document ), keys = KeyLogger( $doc ); // trigger event event = $.Event( "keydown" ); event.keyCode = 9; $doc.trigger( event ); // verify expected behavior equal( keys.log.length, 1, "a key was logged" ); equal( keys.log[ 0 ], 9, "correct key was logged" ); });
讨论
如果你的事件处理不依赖任何特定的事件属性,你可以调用trigger(eventType)。但如果你的事件处理依赖于特殊的事件属性,你就需要使用$.Event创建一个事件对象,并设置必要的属性。对于复杂的行为触发相关事件是非常重要的,例如dragging,他由mousedown、mousemove和mouseup组成。即使是看起来很简单的事件也有可能是有很多事件组成的,例如click是由mousedown、mouseup和click组成的。你是否需要触发所有三个事件,依赖于你的测试代码,只触发click大多数情况是满足要求的。
如果那些仍然不够,你就需要一个框架来帮你模拟用户事件了:
- syn "是一个合成的事件类库,用来处理大多数的 typing, clicking, moving 和 dragging,能准确的模拟用户的实际操作"。基于QUnit的FuncUnit使用了syn,用来对web站点做功能测试。
- JSRobot - "一个web应用的测试工具,可以产生真正的敲击键盘,而不是简单的模拟JavaScript事件触发。允许通过敲击触发浏览器实际的事件,而这对于别的框架来说是办不到的事情"。
- DOH Robot "提供一个 API,允许测试者使用真实的、跨平台的、系统级的输入事件自动运行 UI 测试 "。他为你提供了接近真实浏览器的事件,但是要使用到 Java applets来实现。
保持测试原子性
问题
当测试集中在一起的时候,原本应该通过的测试会失败,本该失败的测试会通过,因为之前测试的副作用,使测试结果失效。
test( "2 asserts", function() { var $fixture = $( "#qunit-fixture" ); $fixture.append( "<div>hello!</div>" ); equal( $( "div", $fixture ).length, 1, "div added successfully!" ); $fixture.append( "<span>hello!</span>" ); equal( $( "span", $fixture ).length, 1, "span added successfully!" ); });
第一个append()添加了一个div,第二个equal()并没有把它考虑进去。
解决方案
使用test()方法保持测试的原子性,使每一个断言清洁而不存在副作用。你应该只依赖 #qunit-fixture元素内部的
fixture标签,修改和依赖其他东西将会存在副作用。
test( "Appends a div", function() { var $fixture = $( "#qunit-fixture" ); $fixture.append( "<div>hello!</div>" ); equal( $( "div", $fixture ).length, 1, "div added successfully!" ); }); test( "Appends a span", function() { var $fixture = $( "#qunit-fixture" ); $fixture.append("<span>hello!</span>" ); equal( $( "span", $fixture ).length, 1, "span added successfully!" ); });
QUnit会在每次测试之后重置 #qunit-fixture中的元素,移出已经存在的事件。如果你只是使用了fixture内部的元素,每次测试之后你不需要执行手工操作来保持它的原子性。
讨论
#qunit-fixture和过滤("
高效发展"会讲到)之外,QUnit还提供了noglobals标志,看看下面的测试:test( "global pollution", function() { window.pollute = true; ok( pollute, "nasty pollution" ); });
一般情况下,测试会得到成功的结果。但是选中noglobals后ok()得到失败的结果,这是因为QUnit认为他污染了window对象。在任何时候都没有必要使用那个标志,但是在整合第三方类库的时候,他对于判断是否引起全局命名污染是有帮助的。而且他也能帮助去发现由于副作用引起的bug。
分组测试
问题
你已经分割了你所有的测试,来让他们保持原子性并不产生副作用,但是你想保证他们的逻辑可组织,它本身能以组的方式运行。
解决方案
你可以使用module()去把测试分组:
module( "group a" ); test( "a basic test example", function() { ok( true, "this test is fine" ); }); test( "a basic test example 2", function() { ok( true, "this test is fine" ); }); module( "group b" ); test( "a basic test example 3", function() { ok( true, "this test is fine" ); }); test( "a basic test example 4", function() { ok( true, "this test is fine" ); });
module()后面的测试会被分在一个组里面,测试结果中每个测试的名称会被加上module名称。你还可以通过module名字选择特定测试来运行。
讨论
module()除了实现分组功能外,还可以用来提取通用代码,他接受一个可选的第二个参数,定义module每次运行测试开始和结束的行为。
module( "module", { setup: function() { ok( true, "one extra assert per test" ); }, teardown: function() { ok( true, "and one extra assert after each test" ); } }); test( "test with setup and teardown", function() { expect( 2 ); });
你可以一起定义setup和teardown属性,或者只定义其中一个。再次调用modile()方法的时候,会重置掉之前方法定义的setup/teardown方法。
高效发展(Efficient Devlopment)
问题
当你的测试需要运行很长时间的时候(例如好几秒),你可以不想浪费时间等待结果。
解决方案
QUnit有一系列的方法可以解决这个问题。最有趣的一个是点击头部的 "Hide passed tests"选项,这样QUnit会只显示失败的测试,他不会对测试时间有影响,只是起到聚焦失败测试的作用。
另外一个有趣的特性是,QUnit会把失败的测试的名字保存在sessionStorage中(你的浏览器必须要支持),默认情况下这个特性是开启的,只是我们没有注意到他的存在。下次你再运行测试的时候,之前失败的测试会被先执行,但是他对输出结果的顺序没有影响,影响的只是执行顺序。结合使用 "Hide passed tests" 你可以立即得到失败的测试。
讨论
自动排序会默认发生,你以为着你的测试需要保持原子性,如果不能保证这点将会产生随机的异常。修复问题是个正确的解决方案,或者存在困难,你可以设置QUnit.config.reorder = false。
除了自动排序之外,还有一些手工可选项。可以点击测试后面的"Rerun"链接,运行单个测试。他会在url中添加"testNumber=N"字符串参数,N代表你点击的测试编号。你可以重刷页面,只运行那个测试,或者使用浏览器的返回按钮区运行所有测试。
运行module中的所有测试,几乎以相同的方式工作。除非你选择页眉右上角的module,他会在url中添加"module=N"字符串,N代表module的编号,例如:"?module=testEnvironment%20with%20object"。