QUnit 单元测试简介
这是从 QUnit 官网上摘录的一篇关于如何利用 `QUnit` 进行单元测试的一篇入门级文档。文章从最初的示例源代码开始,通过逐步分析、重构,最终实现了适应 QUnit 框架的可扩展的新代码,其演变过程与重构思路值得借鉴,因此决定试着翻译一下加深印象。水平有限,翻译不妥的地方,还望不吝赐教,共同进步。
译注:这是从 QUnit 官网上摘录的一篇关于如何利用
QUnit
进行单元测试的入门级文档。文章从最初的示例源代码开始,通过逐步分析、重构,最终实现了适应QUnit
框架的可扩展的新代码,其演变过程与重构思路值得借鉴,因此决定试着翻译一下加深印象,同时也顺带熟悉Markdown
编辑风格。由于水平有限,翻译不妥的地方,还望不吝赐教,共同进步。
单元测试简介 | QUnit
你可能知道代码有单元测试是件好事,但对客户端代码进行单元测试所遇到的第一个拦路虎,就是缺少所谓的单元;JavaScript
代码可能遍布某应用的各网页、各模块,并且很可能与后台业务逻辑、相关 HTML 紧密混合。最糟糕的情况,是代码以内联事件函数的方式完全与 HTML 混为一谈。
这种情况常见于手头缺少现成的处理 DOM 的 JS 库的场合。相较于调用 DOM 的 API 来绑定事件函数,编写内联代码要容易得多。越来越多的开发人员使用 jQuery 这样的库来操作 DOM,以便将这些内联代码抽取为独立的脚本,放到页面某个固定位置,甚至是放到单独的 JavaScript 文件中来引用。然而,这些经过处理的代码还不能被视为一个可供测试的单元。
那么单元究竟指什么?最理想的情况,单元是一个某种意义上的纯函数 —— 对给定输入始终输出同一结果的函数。纯函数的单元测试非常容易,但需要花大部分时间在消除其不良影响上。这里的不良影响特指 DOM 操作。此外,纯函数也有助于弄清将代码重构成哪些单元并构建相应的测试用例。
构建单元测试
有了前面的知识储备,着手进行单元测试就比完全从零开始容易得多了,但这不是本文的重点。这篇文章旨在处理更难的问题:抽取既有代码并测试其重点部分,暴露并调试代码中的潜在漏洞。
像这样,在不修改当前行为的情况下,提取代码并将其转换为其他形式的过程,叫做重构。重构是完善程序代码设计的一种绝佳手段,鉴于代码的任何改动都可能改变程序的实际行为,最保险的做法是在测试阶段进行重构。
这类鸡生蛋蛋生鸡的问题,意味着在现有代码上加入测试,就不得不承担相应的破坏性风险。为了将这样的风险降至最低,在对单元测试有很好的了解之前,还是有必要继续手动测试。
至此,理论就介绍得差不多了,来看一个实际的例子,测试某段与页面内容联系并混杂在一起的 JavaScript 代码。这段代码检索带 title
属性的链接,并将 title
属性值用于显示,相对于某个特定时间,某内容是在何时发布的:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mangled date examples</title>
<script>
function prettyDate(time){
var date = new Date(time || ""),
diff = (((new Date()).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
}
window.onload = function() {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate(links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
若运行这个示例,会看到一个问题:没有任何日期被替换,尽管代码是有效的。它遍历了页面上的所有锚标记并逐一检查其 title
属性值,若存在,就传给 prettyDate
函数执行。如果 prettyDate
进一步返回一个值,就把这个结果值更新到该链接的 innerHTML
中。
让代码可测
问题出在对超过 31 天的日期,prettyDate
返回 undefined
(通过一个单一的 return
语句隐式返回),这样就保留了原来锚点的文本。来看看硬编码一个“当前”日期,执行情况如何:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mangled date examples</title>
<script>
function prettyDate(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
}
window.onload = function() {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate("2008-01-28T22:25:00Z",
links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
可以看到,链接会显示“2 hours ago”,“Yesterday” 等字样。但这仍然不是可供测试的单元,在没有对代码作进一步改动的情况下,只能对 DOM 的变动情况进行测试。即便这样可行,任一标记上的改动都可能打断测试,这样的测试事倍功半。
重构,阶段0
相反地,我们来重构这段代码,使其仅仅足以运行单元测试。
需要做两件事:传入一个当前时间给 prettyDate
函数作参数,而不是用 new Date
;再将函数抽取到一个独立文件中,以便在一个单独的页面引入该文件进行单元测试。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<script src="prettydate.js"></script>
<script>
window.onload = function() {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate("2008-01-28T22:25:00Z",
links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
prettydate.js
内容如下:
function prettyDate(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
}
有了可以测试的代码单元,就可以写一些实际的单元测试用例了:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<script src="prettydate.js"></script>
<script>
function test(then, expected) {
results.total++;
var result = prettyDate("2008/01/28 22:25:00", then);
if (result !== expected) {
results.bad++;
console.log("Expected " + expected +
", but was " + result);
}
}
var results = {
total: 0,
bad: 0
};
test("2008/01/28 22:24:30", "just now");
test("2008/01/28 22:23:30", "1 minute ago");
test("2008/01/28 21:23:30", "1 hour ago");
test("2008/01/27 22:23:30", "Yesterday");
test("2008/01/26 22:23:30", "2 days ago");
test("2007/01/26 22:23:30", undefined);
console.log("Of " + results.total + " tests, " +
results.bad + " failed, " +
(results.total - results.bad) + " passed.");
</script>
</head>
<body>
</body>
</html>
- 运行示例 (先确认启用类似 Firebug 或 Chrome 的 Web Inspector 这样的 console 控制台)
上述示例将创建一个随机测试框架,仅使用控制台作输出。由于不依赖 DOM,可以将代码放入文件中的 script
标签直接在无浏览器的 JavaScript 环境中运行,如 Node.js
或 Rhino
。
若测试失败,控制台会输出测试的期望值和实际值,最后给出一段测试小结,显示测试总数,失败总数和通过总数。
如果通过所有测试,控制台会看到如下结果:
Of 6 tests, 0 failed, 6 passed.
可以改动部分内容,来看看断言失败的情况:
Expected 2 day ago, but was 2 days ago.
Of 6 tests, 1 failed, 5 passed.
这段随机测试代码旨在进行概念验证(proof of concept),你也可以再写一些代码作测试程序运行,但更实际的做法是选用能提供更好的输出、预设更多的基础环境的现成的单元测试框架。
JavaScript 的 QUnit 测试套件
测试框架的选取通常因人而异。本文剩余篇幅将使用 QUnit(读作 “q-unit”),因其描述测试的风格与文中的随机测试框架更为吻合。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<script src="prettydate.js"></script>
<script>
QUnit.test("prettydate basics", function( assert ) {
var now = "2008/01/28 22:25:00";
assert.equal(prettyDate(now, "2008/01/28 22:24:30"), "just now");
assert.equal(prettyDate(now, "2008/01/28 22:23:30"), "1 minute ago");
assert.equal(prettyDate(now, "2008/01/28 21:23:30"), "1 hour ago");
assert.equal(prettyDate(now, "2008/01/27 22:23:30"), "Yesterday");
assert.equal(prettyDate(now, "2008/01/26 22:23:30"), "2 days ago");
assert.equal(prettyDate(now, "2007/01/26 22:23:30"), undefined);
});
</script>
</head>
<body>
<div id="qunit"></div>
</body>
</html>
这里有三处值得留意。
一是在常规 HTML 样板外引入的三个文件:其中两个是与 QUnit
相关的(qunit.css
与 qunit.js
),以及之前的 prettydate.js
。
再者,引入了新的脚本代码块,调用了一次 test
方法,传入一个字符串作第一参数(为本次测试命名)、一个函数作第二参数。该函数具体执行本次测试代码。测试代码先定义了一个变量 now
,便于下文重用,然后用不同的参数多次调用了 equal
方法。equal
方法是 QUnit
内置的一个断言方法,通过测试代码块中、回调函数的第一个参数进行调用。equal
方法的第一个参数,是 prettyDate
函数的执行结果,该函数的第一个参数是变量 now
,第二个参数是一个字符串 date
。equal
方法的第二个参数是期望结果,如果 equal
的两个参数是同一个值,则断言通过,否则断言失败。
最后,是 body
元素中与 QUnit
相关的标记。这些元素是可选的,引入后,QUnit 会将测试结果写入这些标记。
测试结果如下:
若测试未通过,会得到类似下面的运行结果:
由于包含断言失败的测试用例,QUnit 不会收起该用例的测试结果,以便立即查看出错信息。除了显示期望值与实际值,还可以看到两者的差异 diff
,适用于比较更长的字符串。本示例中的出错信息一目了解。
重构,阶段1
上述断言部分还不太完整,因为漏测了 n weeks ago
(n 周前)的情况。补充完整前,应考虑再次重构测试用例代码。注意到在每例断言中调用了 prettyDate
函数并传入参数 now
。因此可以将其重构到一个自定义断言方法中:
QUnit.test("prettydate basics", function( assert ) {
function date(then, expected) {
assert.equal(prettyDate("2008/01/28 22:25:00", then), expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
这里将 prettyDate
函数的调用提取到自定义的 date
函数。该函数内置了变量 now
。最终,各断言仅用到了相关的数据,可读性更强;同时底层抽象的逻辑也很清晰。
测试 DOM 操作
prettyDate
函数测得差不多了,再回到先前的例子。除了 prettyDate
函数,源代码还通过 window
的加载事件选取了 DOM 元素并更新了元素内容。这部分代码也能用与之前相同的原则进行重构并测试。这里将为这两个函数引入一个模块,以免混淆全局命名空间,同时也可以给每个函数起一个更有意义的名称。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<script src="prettydate2.js"></script>
<script>
QUnit.test("prettydate.format", function( assert ) {
function date(then, expected) {
assert.equal(prettyDate.format("2008/01/28 22:25:00", then),
expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
QUnit.test("prettyDate.update", function( assert ) {
var links = document.getElementById("qunit-fixture")
.getElementsByTagName("a");
assert.equal(links[0].innerHTML, "January 28th, 2008");
assert.equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update("2008-01-28T22:25:00Z");
assert.equal(links[0].innerHTML, "2 hours ago");
assert.equal(links[2].innerHTML, "Yesterday");
});
QUnit.test("prettyDate.update, one day later", function( assert ) {
var links = document.getElementById("qunit-fixture")
.getElementsByTagName("a");
assert.equal(links[0].innerHTML, "January 28th, 2008");
assert.equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update("2008/01/29 22:25:00");
assert.equal(links[0].innerHTML, "Yesterday");
assert.equal(links[2].innerHTML, "2 days ago");
});
</script>
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture">
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z"
>January 28th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-27T22:24:17Z"
>January 27th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
</ul>
</div>
</body>
</html>
prettydate2.js
内容如下:
var prettyDate = {
format: function(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff === 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff === 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
},
update: function(now) {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate.format(now, links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
}
};
新函数 prettyDate.update
是对原例的提取,并带了一个参数 now
以供内部 prettyDate.format
调用。这个基于 QUnit 的测试用例先选取了 #qunit-fixture
元素内所有 a
元素。执行更新后,body
元素内的 <div id="qunit-fixture">…</div>
也更新了,里面包含了抽取自原例的内容,以便进行更多有用的测试。将其放入 #qunit-fixture
元素后,不必担心某次测试操作完 DOM 引起的变动对另一次测试的影响,因为 QUnit 会在每次测试完成后自动重置标记中的内容。
考察对 prettyDate.update
的第一次测试。选中锚点后执行的两个断言用于验证它们的初始文本值,然后调用 prettyDate.update
,传入一个固定的日期(同前例)。接着又执行了两次断言,此时验证这些元素中的 innerHTML
属性值分别是变更后的格式化日期:“2 hours ago
” 与“Yesterday
”。
重构,阶段2
另一个对 prettyDate.update, one day later
的测试,大同小异,只是传入了一个不同于 prettyDate.update
的日期,由此得到两个不同的结果。让我们看看是否可以重构这些测试用例来消除代码上的重复。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<script src="prettydate2.js"></script>
<script>
QUnit.test("prettydate.format", function( assert ) {
function date(then, expected) {
assert.equal(prettyDate.format("2008/01/28 22:25:00", then),
expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
function domtest(name, now, first, second) {
QUnit.test(name, function( assert ) {
var links = document.getElementById("qunit-fixture")
.getElementsByTagName("a");
assert.equal(links[0].innerHTML, "January 28th, 2008");
assert.equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update(now);
assert.equal(links[0].innerHTML, first);
assert.equal(links[2].innerHTML, second);
});
}
domtest("prettyDate.update",
"2008-01-28T22:25:00Z", "2 hours ago", "Yesterday");
domtest("prettyDate.update, one day later",
"2008/01/29 22:25:00", "Yesterday", "2 days ago");
</script>
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture">
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
January 28th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-27T22:24:17Z"
>January 27th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
</ul>
</div>
</body>
</html>
至此,出现一个新函数 domtest
,它封装了前两次 test
方法的调用,引入了测试名称、日期字符串,以及两个期望值等参数,然后被调用了两次。
回到最初的例子
回到最开始介绍的源代码中,看看重构之后的样子。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Final date examples</title>
<script src="prettydate2.js"></script>
<script>
window.onload = function() {
prettyDate.update("2008-01-28T22:25:00Z");
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
作为非静态的示例,还应该移除 prettyDate.update
方法的参数。总而言之,重构较最初的示例有了很大的改进。借助引入的 prettyDate
模块,可以在不破坏全局命名空间的情况下添加更多的测试功能。
结语
测试 JavaScript
代码不仅仅是使用某个测试程序和编写几个测试用例的问题。在对以往手动测试的源代码执行单元测试时,通常需要作一些重大的结构性变更。我们已经介绍了一个示例,演示了如何变更现有模块的代码结构,以便利用一个随机测试框架来运行一些测试用例,然后将其替换为更全面的框架,以期更实用的视觉呈现。
QUnit 还有更多功能有待发掘,如支持对超时、AJAX 及事件处理等异步代码的测试。其可视化的测试程序有助于代码调试,以便重新运行特定的测试,并为失败的断言和捕获的异常提供堆栈跟踪信息。更多详情,参考 QUnit Cookbook.
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)