QUnit 單元測試簡介

原文鏈接:https://qunitjs.com/intro/

譯註:這是從 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 月

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章