您可能知道测试很好,但是在尝试为客户端代码编写单元测试时要克服的第一个障碍是缺少任何实际的单元。JavaScript代码是为网站的每个页面或应用程序的每个模块编写的,并与后端逻辑和相关的HTML紧密混合。在最坏的情况下,代码会与HTML完全混合在一起,作为内联事件处理程序。
当没有使用用于某些DOM抽象的JavaScript库时,可能会出现这种情况;编写内联事件处理程序比使用DOM API绑定那些事件要容易得多。越来越多的开发人员正在使用诸如jQuery之类的库来处理DOM抽象,从而使他们可以将这些内联事件移动到同一页面甚至单独的JavaScript文件中的不同脚本中。但是,将代码放入单独的文件并不意味着它可以作为一个单元进行测试。
单位是什么?在最好的情况下,它是一个纯函数,您可以通过某种方式进行处理-对于给定的输入,该函数始终会为您提供相同的结果。这使单元测试非常容易,但是大多数时候您需要处理副作用,这在这里意味着DOM操作。弄清楚我们可以将代码构建到哪些单元中并相应地构建单元测试,仍然很有用。
考虑到这一点,我们显然可以说,从头开始时,从单元测试开始要容易得多。但这不是本文的目的。本文旨在帮助您解决更棘手的问题:提取现有代码并测试重要部分,潜在地发现和修复代码中的错误。
在不修改其当前行为的情况下提取代码并将其放入其他形式的过程称为重构。重构是一种改进程序代码设计的出色方法。并且由于任何更改实际上都可能会修改程序的行为,因此在进行单元测试时最安全的做法是。
这个“鸡与蛋”问题意味着要将测试添加到现有代码中,您必须承担破坏程序的风险。因此,除非您对单元测试有足够的了解,否则需要继续手动测试以最大程度地降低这种风险。
就目前而言,这应该已经足够了。让我们看一个实际的示例,测试一些当前与页面混合并连接到页面的JavaScript代码。该代码查找具有title
属性的链接,并使用这些标题显示发布时间(例如“ 5天前”)作为相对时间值:
<!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>
</ul>
</body>
</html>
如果运行该示例,将会看到一个问题:所有日期都不会被替换。该代码有效。它遍历页面上的所有锚,并title
在每个锚上检查属性。如果存在,则将其传递给prettyDate
函数。如果prettyDate
返回结果,则使用结果更新innerHTML
链接的。
问题在于,对于任何早于31天的日期,它prettyDate
只会返回未定义的(隐式地,只有一条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>
</ul>
</body>
</html>
现在,链接应显示“ 2小时前”,“昨天”,依此类推。那是什么,但仍然不是实际的可测试单元。因此,无需进一步更改代码,我们所能做的就是尝试测试由此产生的DOM更改。即使这样做确实可行,对标记的任何微小更改都可能会破坏测试,从而导致此类测试的成本效益比非常差。
相反,让我们将代码重构为足以进行单元测试的代码。
为此,我们需要进行两项更改:将当前日期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>
</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>
这将创建临时测试框架,仅使用控制台进行输出。它完全不依赖于DOM,因此您可以通过将script
标记中的代码提取到其自己的文件中,从而在非浏览器的JavaScript环境(例如Node.js或Rhino)中运行它。
如果测试失败,它将输出该测试的预期结果和实际结果。最后,它将输出测试摘要以及测试的总数,失败和通过的数量。
如果所有测试都通过了(如此处应通过的那样),您将在控制台中看到以下内容:
在6个测试中,有0个失败,有6个通过。
要查看失败的断言是什么样子,我们可以更改一些内容以使其破裂:
预计2天前,但2天前。 在6项测试中,有1项失败,有5项通过。
尽管这种临时方法作为概念证明很有趣(您确实可以用几行代码编写测试运行器),但是使用现有的单元测试框架要实用得多,该框架可以提供更好的输出和更多的编写基础结构并组织测试。
框架的选择主要取决于品味。在本文的其余部分中,我们将使用 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通过测试块的回调函数中的第一个参数提供的几个断言之一。第一个参数是对的调用的结果prettyDate
,其中now
变量是第一个参数,而date
字符串是第二个。第二个参数equal
是预期结果。如果两个参数equal
值相同,则断言将通过;否则,它将失败。
最后,在body元素中是一些QUnit特定的标记。这些元素是可选的。如果存在,QUnit将使用它们来输出测试结果。
结果是这样的:
如果测试失败,结果将如下所示:
由于测试包含失败的断言,因此QUnit不会折叠该测试的结果,并且我们可以立即看到出了什么问题。连同期望值和实际值的输出,我们在diff
两者之间得到一个a ,这对于比较较大的字符串很有用。在这里,很明显出了什么问题。
这些断言目前尚不完整,因为我们尚未测试该n weeks ago
变体。在添加它之前,我们应该考虑重构测试代码。当前,我们要求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
变量内联到函数中。我们最终仅获得每个断言的相关数据,从而使它更易于阅读,而基础抽象仍然很明显。
现在已经对该prettyDate
功能进行了充分的测试,现在让我们将重点转移到最初的示例。连同该prettyDate
函数,它还选择了一些DOM元素并在window
加载事件处理程序中对其进行了更新。运用与以前相同的原理,我们应该能够重构该代码并对其进行测试。另外,我们将为这两个函数引入一个模块,以避免使全局命名空间混乱,并能够为这些单个函数赋予更有意义的名称。
<!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的测试从选择a
元素中的所有元素开始#qunit-fixture
。在body元素中更新的标记中,<div id="qunit-fixture">…</div>
是新的。它包含我们最初示例中的标记摘录,足以编写有用的测试。通过将其放在#qunit-fixture
元素中,我们不必担心一个测试的DOM更改会影响其他测试,因为QUnit将在每次测试后自动重置标记。
让我们看看的第一个测试prettyDate.update
。选择这些锚点之后,两个断言将验证它们是否具有其初始文本值。此后,将prettyDate.update
被调用,并经过固定的日期(与之前的测试相同)。之后,再运行两个断言,现在验证innerHTML
这些元素的属性具有正确格式的日期“ 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>
</ul>
</body>
</html>
对于非静态示例,我们将删除的参数prettyDate.update
。总而言之,重构是对第一个示例的巨大改进。而且由于有了prettyDate
我们介绍的模块,我们可以添加更多功能而不会破坏全局名称空间。
测试JavaScript代码不仅仅是使用一些测试运行器并编写一些测试的问题。当将其应用于以前仅手动测试过的代码时,通常需要进行一些重大的结构更改。我们已经看过一个示例,该示例如何更改现有模块的代码结构,以使用临时测试框架运行某些测试,然后将其替换为功能更强大的框架,以获得有用的可视化结果。