撰寫可測試的 JavaScript

轉自 勾三股四 - 撰寫可測試的 JavaScriptjavascript

這篇文章算是 A List Apart 系列文章中,包括滑動門在內,令我印象最深入的文章之一。最近有時間翻譯了一下,分享給更多人,但願對你們有所幫助!html


咱們已經面對到了這一窘境:一開始咱們寫的 JavaScript 只有區區幾行代碼,可是它的代碼量一直在增加,咱們不斷的加參數、加條件。最後,粗 bug 了…… 咱們纔不得不收拾這個爛攤子。前端

如上所述,今天的客戶端代碼確實承載了更多的責任,瀏覽器裏的整個應用都越變越複雜。咱們發現兩個明顯的趨勢:一、咱們無法經過單純的鼠標定位和點擊來檢驗代碼是否正常工做,自動化的測試纔會真正讓咱們放心;二、咱們也許應該在撰寫代碼的時候就考慮到,讓它變得可測試。java

神馬?咱們須要改變本身的編碼方式?是的。由於即便咱們意識到自動化測試的好,大部分人可能只是寫寫集成測試(integration tests)罷了。集成測試的側重點是讓整個系統的每一部分和諧共存,可是這並無告訴咱們每一個獨立的功能單元運轉起來是否都和咱們預期的同樣。git

這就是爲何咱們要引入單元測試。咱們已經準備好經歷一段痛苦的撰寫單元測試的過程了,但最終咱們可以撰寫可測試的 JavaScriptgithub

單元與集成:有什麼不一樣?

撰寫集成測試一般是至關直接的:咱們單純的撰寫代碼,描述用戶如何和這個應用進行交互、會獲得怎樣的結果就好。Selenium 是這類瀏覽器自動化工具中的佼佼者。而 Capybara 能夠便於 Ruby 和 Selenium 取得聯繫。在其它語言中,這類工具也舉不勝舉。ajax

下面就是搜索應用的一部分集成測試:json

def test_search
    fill_in('q', :with => 'cat')
    find('.btn').click
    assert( find('#results li').has_content?('cat'), 'Search results are shown' )
    assert( page.has_no_selector?('#results li.no-results'), 'No results is not shown' )
end

集成測試對用戶的交互行爲感興趣,而單元測試每每僅專一於一小段代碼:數組

當我伴隨特定的輸入調用一個函數的時候,我是否收到了我預期中的結果?promise

咱們按照傳統思路撰寫的程序是很難進行單元測試的,同時也很難維護、調試和擴展。可是若是咱們在撰寫代碼的時候就考慮到我未來要作單元測試,那麼這樣的思路不只會讓咱們發現測試代碼寫起來很直接,也會讓咱們真正寫出更優質的代碼。

咱們經過一個簡單的搜索應用的例子來作個示範:

clipboard.png

當用戶搜索時,該應用會向服務器發送一個 XHR (Ajax 請求) 取得相應的搜索結果。並當服務器以 JSON 格式返回數據以後,經過前端模板把結果顯示在頁面中。用戶在搜索結果中點「贊」,這我的的名字就會出如今右側的點「贊」列表裏。

一個「傳統」的 JavaScript 實現大概是這個樣子的:

// 模板緩存,緩存的內容均爲 jqXHR 對象
var tmplCache = {};

/**
 * 載入模板
 * 從 '/templates/{name}' 載入模板,存入 tmplCache
 * @param  {string} name 模板名稱
 * @return {object}      模板請求的 jqXHR 對象
 */
function loadTemplate (name) {
  if (!tmplCache[name]) {
    tmplCache[name] = $.get('/templates/' + name);
  }
  return tmplCache[name];
}

/**
 * 頁面主要邏輯
 * 1. 支持搜索行爲並展現結果
 * 2. 支持點「贊」,被贊過的人會出如今點「贊」列表裏
 */
$(function () {

  var resultsList = $('#results');
  var liked = $('#liked');
  var pending = false; // 用來標識以前的搜索是否還沒有結束

  // 用戶搜索行爲,表單提交事件
  $('#searchForm').on('submit', function (e) {
    // 屏蔽默認表單事件
    e.preventDefault();

    // 若是以前的搜索還沒有結束,則不開始新的搜索
    if (pending) { return; }

    // 獲得要搜索的關鍵字
    var form = $(this);
    var query = $.trim( form.find('input[name="q"]').val() );

    // 若是搜索關鍵字爲空則不進行搜索
    if (!query) { return; }

    // 開始新的搜索
    pending = true;

    // 發送 XHR
    $.ajax('/data/search.json', {
      data : { q: query },
      dataType : 'json',
      success : function (data) {
        // 獲得 people-detailed 模板
        loadTemplate('people-detailed.tmpl').then(function (t) {
          var tmpl = _.template(t);

          // 經過模板渲染搜索結果
          resultsList.html( tmpl({ people : data.results }) );

          // 結束本次搜索
          pending = false;
        });
      }
    });

    // 在獲得服務器響應以前,清空搜索結果,並出現等待提示
    $('<li>', {
      'class' : 'pending',
      html : 'Searching …'
    }).appendTo( resultsList.empty() );
  });

  // 綁定點「贊」的行爲,鼠標點擊事件
  resultsList.on('click', '.like', function (e) {
    // 屏蔽默認點擊事件
    e.preventDefault();

    // 找到當前人的名字
    var name = $(this).closest('li').find('h2').text();

    // 清除點「贊」列表的佔位元素
    liked.find('.no-results').remove();

    // 在點「贊」列表加入新的項目
    $('<li>', { text: name }).appendTo(liked);
  });

});

個人朋友 Adam Sontag 稱之爲「本身給本身挖坑」的代碼:展示、數據、用戶交互、應用狀態所有分散在了每一行代碼裏。這種代碼是很容易進行集成測試的,但幾乎不可能針對功能單元進行單獨的測試。

單元測試爲何這麼難?有四大罪魁禍首:

  • 沒有清晰的結構。幾乎全部的工做都是在 $(document).ready() 回調裏進行的,而這一切在一個匿名函數裏,它在測試中沒法暴露出任何接口。
  • 函數太複雜。若是一個函數超過了 10 行,好比提交表單的那個函數,估計你們都以爲它太忙了,一口氣作了不少事。
  • 隱藏狀態仍是共享狀態。好比,由於 pending 在一個閉包裏,因此咱們沒有辦法測試在每一個步驟中這個狀態是否正確。
  • 強耦合。好比這裏 $.ajax 成功的回調函數不該該依賴 DOM 操做。

組織咱們的代碼

首當其衝的是把咱們代碼的邏輯縷一縷,根據職責的不一樣把整段代碼分爲幾個方面:

  • 展示和交互
  • 數據管理和保存
  • 應用的狀態
  • 把上述代碼創建並串連起來

在以前的「傳統」實現裏,這四類代碼是混在一塊兒的,前一行咱們還在處理界面展示,後兩行就在和服務器通訊了。

clipboard.png

咱們絕對能夠寫出集成測試的代碼,但咱們應該很難寫出單元測試了。在功能測試裏,咱們能夠作出諸如「當用戶搜索東西的時候,他會看到相應的搜索結果」的斷言,可是沒法再具體下去了。若是裏面出了什麼問題,咱們仍是得追蹤進去,找到確切的出錯位置。這樣的話功能測試其實也沒幫上什麼忙。

若是咱們反思本身的代碼,那不妨從單元測試寫起,經過單元測試這個角度,更好的觀察,是哪裏出了問題。這進而會幫助咱們改進代碼,讓代碼變得更易於重用、易於維護、易於擴展。

咱們的新版代碼遵循下面幾個原則:

  • 根據上述四類職責,列出每一個互不相干的行爲,並分別用一個對象來表示。對象以前互不依賴,以免不一樣的代碼混在一塊兒。
  • 用可配置的內容代替寫死的內容,以免咱們爲了測試而復刻整個 HTML 環境。
  • 保持對象方法的簡單明瞭。這會把測試工做變得簡單易懂。
  • 經過構造函數建立對象實例。這讓咱們能夠根據測試的須要復刻每一段代碼的內容。

做爲起步,咱們有必要搞清楚,該如何把應用分解成不一樣的部分。咱們有三塊展示和交互的內容:搜索框、搜索結果和點「贊」列表。

clipboard.png

咱們還有一塊內容是從服務器獲取數據的、一塊內容是把全部的內容粘合在一塊兒的。

咱們從整個應用最簡單的一部分開始吧:點「贊」列表。在原版應用中,這部分代碼的職責就是更新點「贊」列表:

var liked = $('#liked');
var resultsList = $('#results');

// ...

resultsList.on('click', '.like', function (e) {
  e.preventDefault();
  var name = $(this).closest('li').find('h2').text();
  liked.find( '.no-results' ).remove();
  $('<li>', { text: name }).appendTo(liked);
});

搜索結果這部分是徹底和點「贊」列表攪在一塊兒的,而且須要不少 DOM 處理。更好的易於測試的寫法是建立一個點「贊」列表的對象,它的職責就是封裝點「贊」列表的 DOM 操做。

var Likes = function (el) {
  this.el = $(el);
  return this;
};

Likes.prototype.add = function (name) {
  this.el.find('.no-results').remove();
  $('<li>', { text: name }).appendTo(this.el);
};

這段代碼提供了建立一個點「贊」列表對象的構造函數。它有 .add() 方法,能夠在產生新的讚的時候使用。這樣咱們就能夠寫不少測試代碼來保障它的正常工做了:

var ul;

// 設置測試的初始狀態:生成一個搜索結果列表
setup(function(){
  ul = $('

*');
});

test('測試構造函數', function () {
  var l = new Likes(ul);
  // 斷言對象存在
  assert(l);
});

test('點一個「贊」', function () {
  var l = new Likes(ul);
  l.add('Brendan Eich');

  // 斷言列表長度爲1
  assert.equal(ul.find('li').length, 1);
  // 斷言列表第一個元素的 HTML 代碼是 'Brendan Eich'
  assert.equal(ul.find('li').first().html(), 'Brendan Eich');
  // 斷言佔位元素已經不存在了
  assert.equal(ul.find('li.no-results').length, 0);
});

怎麼樣?並不難吧 :-) 咱們這裏用到了名爲 Mocha測試框架,以及名爲 Chai斷言庫。Mocha 提供了 testsetup 函數;而 Chai 提供了 assert。測試框架和斷言庫的選擇還有不少,咱們出於介紹的目的給你們展現這兩款。你能夠找到屬於適合本身的項目——除了 Mocha 以外,QUnit 也比較流行。另外 Intern 也是一個測試框架,它運用了大量的 promise 方式。

咱們的測試代碼是從點「贊」列表這一容器開始的。而後它運行了兩個測試:一個是肯定點「贊」列表是存在的;另外一個是確保 .add() 方法達到了咱們預期的效果。有這些測試作後盾,咱們就能夠放心重構點「贊」列表這部分的代碼了,即便代碼被破壞了,咱們也有信心把它修復好。

咱們新應用的代碼如今看起來是這樣的:

var liked = new Likes('#liked'); // 新的點「贊」列表對象
var resultsList = $('#results');

// ...

resultsList.on('click', '.like', function (e) {
  e.preventDefault();
  var name = $(this).closest('li').find('h2').text();
  liked.add(name); // 新的點「贊」操做的封裝
});

搜索結果這部分比點「贊」列表更復雜一些,不過咱們也該拿它開刀了。和咱們爲點「贊」列表建立一個 .add() 方法同樣,咱們要建立一個與搜索結果有交互的方法。咱們須要一個點「贊」的入口,向整個應用「廣播」本身發生了什麼變化——好比有人點了個「贊」。

// 爲每一條搜索結果的點「贊」按鈕綁定點擊事件
var SearchResults = function (el) {
  this.el = $(el);
  this.el.on( 'click', '.btn.like', _.bind(this._handleClick, this) );
};

// 展現搜索結果,獲取模板,而後渲染
SearchResults.prototype.setResults = function (results) {
  var templateRequest = $.get('people-detailed.tmpl');
  templateRequest.then( _.bind(this._populate, this, results) );
};

// 處理點「贊」
SearchResults.prototype._handleClick = function (evt) {
  var name = $(evt.target).closest('li.result').attr('data-name');
  $(document).trigger('like', [ name ]);
};

// 對模板渲染數據的封裝
SearchResults.prototype._populate = function (results, tmpl) {
  var html = _.template(tmpl, { people: results });
  this.el.html(html);
};

如今咱們舊版應用中管理搜索結果和點「贊」列表之間交互的代碼以下:

var liked = new Likes('#liked');
var resultsList = new SearchResults('#results');

// ...

$(document).on('like', function (evt, name) {
  liked.add(name);
})

這就更簡單更清晰了,由於咱們經過 document 在各個獨立的組件之間進行消息傳遞,而組件之間是互不依賴的。(值得注意的是,在真正的應用當中,咱們會使用一些諸如 BackboneRSVP 庫來管理事件。咱們出於讓例子儘可能簡單的考慮,使用了 document 來觸發事件) 咱們同時隱藏了不少髒活累活:好比在搜索結果對象裏尋找被點「贊」的人,要比放在整個應用的代碼裏更好。更重要的是,咱們如今能夠寫出保障搜索結果對象正常工做的測試代碼了:

var ul;
var data = [ /* 填入假數據 */ ];

// 確保點「贊」列表存在
setup(function () {
  ul = $('

*');
});

test('測試構造函數', function () {
  var sr = new SearchResults(ul);
  // 斷言對象存在
  assert(sr);
});

test('測試收到的搜索結果', function () {
  var sr = new SearchResults(ul);
  sr.setResults(data);

  // 斷言搜索結果佔位元素已經不存在
  assert.equal(ul.find('.no-results').length, 0);
  // 斷言搜索結果的子元素個數和搜索結果的個數相同
  assert.equal(ul.find('li.result').length, data.length);
  // 斷言搜索結果的第一個子元素的 'data-name' 的值和第一個搜索結果相同
  assert.equal(
    ul.find('li.result').first().attr('data-name'),
    data[0].name
  );
});

test('測試點「贊」按鈕', function() {
  var sr = new SearchResults(ul);
  var flag;
  var spy = function () {
    flag = [].slice.call(arguments);
  };

  sr.setResults(data);
  $(document).on('like', spy);

  ul.find('li').first().find('.like.btn').click();

  // 斷言 `document` 收到了點「贊」的消息
  assert(flag, '事件被收到了');
  // 斷言 `document` 收到的點「贊」消息,其中的名字是第一個搜索結果
  assert.equal(flag[1], data[0].name, '事件裏的數據被收到了' );
});

和服務器直接的交互是另一個有趣的話題。原版的代碼包括一個 $.ajax() 的請求,以及一個直接操做 DOM 的回調函數:

$.ajax('/data/search.json', {
  data : { q: query },
  dataType : 'json',
  success : function( data ) {
    loadTemplate('people-detailed.tmpl').then(function(t) {
      var tmpl = _.template( t );
      resultsList.html( tmpl({ people : data.results }) );
      pending = false;
    });
  }
});

一樣,咱們很難爲這樣的代碼撰寫測試。由於不少不一樣的工做同時發生在這一小段代碼中。咱們能夠從新組織一下數據處理的部分:

var SearchData = function () { };

SearchData.prototype.fetch = function (query) {
  var dfd;

  // 若是搜索關鍵字爲空,則不作任何事,馬上 `promise()`
  if (!query) {
    dfd = $.Deferred();
    dfd.resolve([]);
    return dfd.promise();
  }

  // 不然,向服務器請求搜索結果並把在獲得結果以後對其數據進行包裝
  return $.ajax( '/data/search.json', {
    data : { q: query },
    dataType : 'json'
  }).pipe(function( resp ) {
    return resp.results;
  });
};

如今咱們改變了得到搜索結果這部分的代碼:

var resultList = new SearchResults('#results');
var searchData = new SearchData();

// ...

searchData.fetch(query).then(resultList.setResults);

咱們再一次簡化了代碼,並經過 SearchData 對象拋棄了以前應用程序主函數裏雜亂的代碼。同時咱們已經讓搜索接口變得可測試了,儘管如今和服務器通訊這裏還有事情要作。

首先咱們不是真的要跟服務器通訊——否則這又變成集成測試了:諸如咱們是有責任感的開發者,咱們已經確保服務器必定不會犯錯等等,是這樣嗎?爲了替代這些東西,咱們應該「mock」(僞造) 與服務器之間的通訊。Sinon 這個庫就能夠作這件事。第二個障礙是咱們的測試應該覆蓋非理想環境,好比關鍵字爲空。

test('測試構造函數', function () {
  var sd = new SearchData();
  assert(sd);
});

suite('取數據', function () {
  var xhr, requests;

  setup(function () {
    requests = [];
    xhr = sinon.useFakeXMLHttpRequest();
    xhr.onCreate = function (req) {
      requests.push(req);
    };
  });

  teardown(function () {
    xhr.restore();
  });

  test('經過正確的 URL 獲取數據', function () {
    var sd = new SearchData();
    sd.fetch('cat');

    assert.equal(requests[0].url, '/data/search.json?q=cat');
  });

  test('返回一個 promise', function () {
    var sd = new SearchData();
    var req = sd.fetch('cat');

    assert.isFunction(req.then);
  });

  test('若是關鍵字爲空則不查詢', function () {
    var sd = new SearchData();
    var req = sd.fetch();
    assert.equal(requests.length, 0);
  });

  test('若是關鍵字爲空也會有 promise', function () {
    var sd = new SearchData();
    var req = sd.fetch();

    assert.isFunction( req.then );
  });

  test('關鍵字爲空的 promise 會返回一個空數組', function () {
    var sd = new SearchData();
    var req = sd.fetch();
    var spy = sinon.spy();

    req.then(spy);

    assert.deepEqual(spy.args[0][0], []);
  });

  test('返回與搜索結果相對應的對象', function () {
    var sd = new SearchData();
    var req = sd.fetch('cat');
    var spy = sinon.spy();

    requests[0].respond(
      200, { 'Content-type': 'text/json' },
      JSON.stringify({ results: [ 1, 2, 3 ] })
    );

    req.then(spy);

    assert.deepEqual(spy.args[0][0], [ 1, 2, 3 ]);
  });
});

出於篇幅的考慮,這裏對搜索框的重構及其相關的單元測試就不一一介紹了。完整的代碼能夠移步至此查閱。

當咱們按照可測試的 JavaScript 的思路重構代碼以後,咱們最後用下面這段代碼開啓程序:

$(function() {
  var pending = false;

  var searchForm = new SearchForm('#searchForm');
  var searchResults = new SearchResults('#results');
  var likes = new Likes('#liked');
  var searchData = new SearchData();

  $(document).on('search', function (event, query) {
    if (pending) { return; }

    pending = true;

    searchData.fetch(query).then(function (results) {
      searchResults.setResults(results);
      pending = false;
    });

    searchResults.pending();
  });

  $(document).on('like', function (evt, name) {
    likes.add(name);
  });
});

比干淨整潔的代碼更重要的,是咱們的代碼擁有了更健壯的測試基礎做爲後盾。這也意味着咱們能夠放心的重構任意部分的代碼而沒必要擔憂程序遭到破壞。咱們還能夠繼續爲新功能撰寫新的測試代碼,並確保新的程序能夠經過全部的測試。

測試會在宏觀上讓你變輕鬆

看完這些的長篇大論你必定會說:「納尼?我多寫了這麼多代碼,結果仍是作了這麼一點事情?」

關鍵在於,你作的東西遲早要放到網上的。一樣是花時間解決問題,你會選擇在瀏覽器裏點來點去?仍是自動化測試?仍是直接在線上讓你的用戶作你的小白鼠?不管你寫了多少測試,你寫好代碼,別人一用,多少會發現點 bug。

至於測試,它可能會花掉你一些額外的時間,可是它到最後真的是爲你省下了時間。寫測試代碼測出一個問題,總比你發佈到線上以後才發現有問題要好。若是有一個系統能讓你意識到它真的能避免一個 bug 的流出,你必定會心存感激。

額外的資源

這篇文章只能算是 JavaScript 測試的一點皮毛,可是若是你對此抱有興趣,那麼能夠繼續移步至:

相關文章
相關標籤/搜索