译注:这是从 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.jsRhino

若测试失败,控制台会输出测试的期望值和实际值,最后给出一段测试小结,显示测试总数,失败总数和通过总数。

如果通过所有测试,控制台会看到如下结果:

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.cssqunit.js),以及之前的 prettydate.js

再者,引入了新的脚本代码块,调用了一次 test 方法,传入一个字符串作第一参数(为本次测试命名)、一个函数作第二参数。该函数具体执行本次测试代码。测试代码先定义了一个变量 now,便于下文重用,然后用不同的参数多次调用了 equal 方法。equal 方法是 QUnit 内置的一个断言方法,通过测试代码块中、回调函数的第一个参数进行调用。equal 方法的第一个参数,是 prettyDate 函数的执行结果,该函数的第一个参数是变量 now,第二个参数是一个字符串 dateequal 方法的第二个参数是期望结果,如果 equal 的两个参数是同一个值,则断言通过,否则断言失败。

最后,是 body 元素中与 QUnit 相关的标记。这些元素是可选的,引入后,QUnit 会将测试结果写入这些标记。

测试结果如下:
4a-green
若测试未通过,会得到类似下面的运行结果:
4b-red
由于包含断言失败的测试用例,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.


本文最早发表于 Smashing Magazine,2012 年 6 月

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐