摘要:我們已經(jīng)準備好經(jīng)歷一段痛苦的撰寫單元測試的過程了,但最終我們能夠撰寫可測試的。這種代碼是很容易進行集成測試的,但幾乎不可能針對功能單元進行多帶帶的測試。我們絕對可以寫出集成測試的代碼,但我們應該很難寫出單元測試了。
轉(zhuǎn)自 勾三股四 - 撰寫可測試的 JavaScript
這篇文章算是 A List Apart 系列文章中,包括滑動門在內(nèi),令我印象最深刻的文章之一。最近有時間翻譯了一下,分享給更多人,希望對大家有所幫助!
我們已經(jīng)面對到了這一窘境:一開始我們寫的 JavaScript 只有區(qū)區(qū)幾行代碼,但是它的代碼量一直在增長,我們不斷的加參數(shù)、加條件。最后,粗 bug 了…… 我們才不得不收拾這個爛攤子。
如上所述,今天的客戶端代碼確實承載了更多的責任,瀏覽器里的整個應用都越變越復雜。我們發(fā)現(xiàn)兩個明顯的趨勢:1、我們沒法通過單純的鼠標定位和點擊來檢驗代碼是否正常工作,自動化的測試才會真正讓我們放心;2、我們也許應該在撰寫代碼的時候就考慮到,讓它變得可測試。
神馬?我們需要改變自己的編碼方式?是的。因為即使我們意識到自動化測試的好,大部分人可能只是寫寫集成測試(integration tests)罷了。集成測試的側(cè)重點是讓整個系統(tǒng)的每一部分和諧共存,但是這并沒有告訴我們每個獨立的功能單元運轉(zhuǎn)起來是否都和我們預期的一樣。
這就是為什么我們要引入單元測試。我們已經(jīng)準備好經(jīng)歷一段痛苦的撰寫單元測試的過程了,但最終我們能夠撰寫可測試的 JavaScript。
單元與集成:有什么不同?撰寫集成測試通常是相當直接的:我們單純的撰寫代碼,描述用戶如何和這個應用進行交互、會得到怎樣的結(jié)果就好。Selenium 是這類瀏覽器自動化工具中的佼佼者。而 Capybara 可以便于 Ruby 和 Selenium 取得聯(lián)系。在其它語言中,這類工具也舉不勝舉。
下面就是搜索應用的一部分集成測試:
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
集成測試對用戶的交互行為感興趣,而單元測試往往僅專注于一小段代碼:
當我伴隨特定的輸入調(diào)用一個函數(shù)的時候,我是否收到了我預期中的結(jié)果?
我們按照傳統(tǒng)思路撰寫的程序是很難進行單元測試的,同時也很難維護、調(diào)試和擴展。但是如果我們在撰寫代碼的時候就考慮到我將來要做單元測試,那么這樣的思路不僅會讓我們發(fā)現(xiàn)測試代碼寫起來很直接,也會讓我們真正寫出更優(yōu)質(zhì)的代碼。
我們通過一個簡單的搜索應用的例子來做個示范:
當用戶搜索時,該應用會向服務器發(fā)送一個 XHR (Ajax 請求) 取得相應的搜索結(jié)果。并當服務器以 JSON 格式返回數(shù)據(jù)之后,通過前端模板把結(jié)果顯示在頁面中。用戶在搜索結(jié)果中點“贊”,這個人的名字就會出現(xiàn)在右側(cè)的點“贊”列表里。
一個“傳統(tǒng)”的 JavaScript 實現(xiàn)大概是這個樣子的:
// 模板緩存,緩存的內(nèi)容均為 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. 支持搜索行為并展示結(jié)果 * 2. 支持點“贊”,被贊過的人會出現(xiàn)在點“贊”列表里 */ $(function () { var resultsList = $("#results"); var liked = $("#liked"); var pending = false; // 用來標識之前的搜索是否尚未結(jié)束 // 用戶搜索行為,表單提交事件 $("#searchForm").on("submit", function (e) { // 屏蔽默認表單事件 e.preventDefault(); // 如果之前的搜索尚未結(jié)束,則不開始新的搜索 if (pending) { return; } // 得到要搜索的關鍵字 var form = $(this); var query = $.trim( form.find("input[name="q"]").val() ); // 如果搜索關鍵字為空則不進行搜索 if (!query) { return; } // 開始新的搜索 pending = true; // 發(fā)送 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); // 通過模板渲染搜索結(jié)果 resultsList.html( tmpl({ people : data.results }) ); // 結(jié)束本次搜索 pending = false; }); } }); // 在得到服務器響應之前,清空搜索結(jié)果,并出現(xiàn)等待提示 $("
我的朋友 Adam Sontag 稱之為“自己給自己挖坑”的代碼:展現(xiàn)、數(shù)據(jù)、用戶交互、應用狀態(tài)全部分散在了每一行代碼里。這種代碼是很容易進行集成測試的,但幾乎不可能針對功能單元進行多帶帶的測試。
單元測試為什么這么難?有四大罪魁禍首:
沒有清晰的結(jié)構(gòu)。幾乎所有的工作都是在 $(document).ready() 回調(diào)里進行的,而這一切在一個匿名函數(shù)里,它在測試中無法暴露出任何接口。
函數(shù)太復雜。如果一個函數(shù)超過了 10 行,比如提交表單的那個函數(shù),估計大家都覺得它太忙了,一口氣做了很多事。
隱藏狀態(tài)還是共享狀態(tài)。比如,因為 pending 在一個閉包里,所以我們沒有辦法測試在每個步驟中這個狀態(tài)是否正確。
強耦合。比如這里 $.ajax 成功的回調(diào)函數(shù)不應該依賴 DOM 操作。
組織我們的代碼首當其沖的是把我們代碼的邏輯縷一縷,根據(jù)職責的不同把整段代碼分為幾個方面:
展現(xiàn)和交互
數(shù)據(jù)管理和保存
應用的狀態(tài)
把上述代碼建立并串連起來
在之前的“傳統(tǒng)”實現(xiàn)里,這四類代碼是混在一起的,前一行我們還在處理界面展現(xiàn),后兩行就在和服務器通信了。
我們絕對可以寫出集成測試的代碼,但我們應該很難寫出單元測試了。在功能測試里,我們可以做出諸如“當用戶搜索東西的時候,他會看到相應的搜索結(jié)果”的斷言,但是無法再具體下去了。如果里面出了什么問題,我們還是得追蹤進去,找到確切的出錯位置。這樣的話功能測試其實也沒幫上什么忙。
如果我們反思自己的代碼,那不妨從單元測試寫起,通過單元測試這個角度,更好的觀察,是哪里出了問題。這進而會幫助我們改進代碼,讓代碼變得更易于重用、易于維護、易于擴展。
我們的新版代碼遵循下面幾個原則:
根據(jù)上述四類職責,列出每個互不相干的行為,并分別用一個對象來表示。對象之前互不依賴,以避免不同的代碼混在一起。
用可配置的內(nèi)容代替寫死的內(nèi)容,以避免我們?yōu)榱藴y試而復刻整個 HTML 環(huán)境。
保持對象方法的簡單明了。這會把測試工作變得簡單易懂。
通過構(gòu)造函數(shù)創(chuàng)建對象實例。這讓我們可以根據(jù)測試的需要復刻每一段代碼的內(nèi)容。
作為起步,我們有必要搞清楚,該如何把應用分解成不同的部分。我們有三塊展現(xiàn)和交互的內(nèi)容:搜索框、搜索結(jié)果和點“贊”列表。
我們還有一塊內(nèi)容是從服務器獲取數(shù)據(jù)的、一塊內(nèi)容是把所有的內(nèi)容粘合在一起的。
我們從整個應用最簡單的一部分開始吧:點“贊”列表。在原版應用中,這部分代碼的職責就是更新點“贊”列表:
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(); $("
搜索結(jié)果這部分是完全和點“贊”列表攪在一起的,并且需要很多 DOM 處理。更好的易于測試的寫法是創(chuàng)建一個點“贊”列表的對象,它的職責就是封裝點“贊”列表的 DOM 操作。
var Likes = function (el) { this.el = $(el); return this; }; Likes.prototype.add = function (name) { this.el.find(".no-results").remove(); $("
這段代碼提供了創(chuàng)建一個點“贊”列表對象的構(gòu)造函數(shù)。它有 .add() 方法,可以在產(chǎn)生新的贊的時候使用。這樣我們就可以寫很多測試代碼來保障它的正常工作了:
var ul; // 設置測試的初始狀態(tài):生成一個搜索結(jié)果列表 setup(function(){ ul = $(" *"); }); test("測試構(gòu)造函數(shù)", 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"); // 斷言占位元素已經(jīng)不存在了 assert.equal(ul.find("li.no-results").length, 0); });
怎么樣?并不難吧 :-) 我們這里用到了名為 Mocha 的測試框架,以及名為 Chai 的斷言庫。Mocha 提供了 test 和 setup 函數(shù);而 Chai 提供了 assert。測試框架和斷言庫的選擇還有很多,我們出于介紹的目的給大家展示這兩款。你可以找到屬于適合自己的項目——除了 Mocha 之外,QUnit 也比較流行。另外 Intern 也是一個測試框架,它運用了大量的 promise 方式。
我們的測試代碼是從點“贊”列表這一容器開始的。然后它運行了兩個測試:一個是確定點“贊”列表是存在的;另一個是確保 .add() 方法達到了我們預期的效果。有這些測試做后盾,我們就可以放心重構(gòu)點“贊”列表這部分的代碼了,即使代碼被破壞了,我們也有信心把它修復好。
我們新應用的代碼現(xiàn)在看起來是這樣的:
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); // 新的點“贊”操作的封裝 });
搜索結(jié)果這部分比點“贊”列表更復雜一些,不過我們也該拿它開刀了。和我們?yōu)辄c“贊”列表創(chuàng)建一個 .add() 方法一樣,我們要創(chuàng)建一個與搜索結(jié)果有交互的方法。我們需要一個點“贊”的入口,向整個應用“廣播”自己發(fā)生了什么變化——比如有人點了個“贊”。
// 為每一條搜索結(jié)果的點“贊”按鈕綁定點擊事件 var SearchResults = function (el) { this.el = $(el); this.el.on( "click", ".btn.like", _.bind(this._handleClick, this) ); }; // 展示搜索結(jié)果,獲取模板,然后渲染 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 ]); }; // 對模板渲染數(shù)據(jù)的封裝 SearchResults.prototype._populate = function (results, tmpl) { var html = _.template(tmpl, { people: results }); this.el.html(html); };
現(xiàn)在我們舊版應用中管理搜索結(jié)果和點“贊”列表之間交互的代碼如下:
var liked = new Likes("#liked"); var resultsList = new SearchResults("#results"); // ... $(document).on("like", function (evt, name) { liked.add(name); })
這就更簡單更清晰了,因為我們通過 document 在各個獨立的組件之間進行消息傳遞,而組件之間是互不依賴的。(值得注意的是,在真正的應用當中,我們會使用一些諸如 Backbone 或 RSVP 庫來管理事件。我們出于讓例子盡量簡單的考慮,使用了 document 來觸發(fā)事件) 我們同時隱藏了很多臟活累活:比如在搜索結(jié)果對象里尋找被點“贊”的人,要比放在整個應用的代碼里更好。更重要的是,我們現(xiàn)在可以寫出保障搜索結(jié)果對象正常工作的測試代碼了:
var ul; var data = [ /* 填入假數(shù)據(jù) */ ]; // 確保點“贊”列表存在 setup(function () { ul = $(" *"); }); test("測試構(gòu)造函數(shù)", function () { var sr = new SearchResults(ul); // 斷言對象存在 assert(sr); }); test("測試收到的搜索結(jié)果", function () { var sr = new SearchResults(ul); sr.setResults(data); // 斷言搜索結(jié)果占位元素已經(jīng)不存在 assert.equal(ul.find(".no-results").length, 0); // 斷言搜索結(jié)果的子元素個數(shù)和搜索結(jié)果的個數(shù)相同 assert.equal(ul.find("li.result").length, data.length); // 斷言搜索結(jié)果的第一個子元素的 "data-name" 的值和第一個搜索結(jié)果相同 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` 收到的點“贊”消息,其中的名字是第一個搜索結(jié)果 assert.equal(flag[1], data[0].name, "事件里的數(shù)據(jù)被收到了" ); });
和服務器直接的交互是另外一個有趣的話題。原版的代碼包括一個 $.ajax() 的請求,以及一個直接操作 DOM 的回調(diào)函數(shù):
$.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; }); } });
同樣,我們很難為這樣的代碼撰寫測試。因為很多不同的工作同時發(fā)生在這一小段代碼中。我們可以重新組織一下數(shù)據(jù)處理的部分:
var SearchData = function () { }; SearchData.prototype.fetch = function (query) { var dfd; // 如果搜索關鍵字為空,則不做任何事,立刻 `promise()` if (!query) { dfd = $.Deferred(); dfd.resolve([]); return dfd.promise(); } // 否則,向服務器請求搜索結(jié)果并把在得到結(jié)果之后對其數(shù)據(jù)進行包裝 return $.ajax( "/data/search.json", { data : { q: query }, dataType : "json" }).pipe(function( resp ) { return resp.results; }); };
現(xiàn)在我們改變了獲得搜索結(jié)果這部分的代碼:
var resultList = new SearchResults("#results"); var searchData = new SearchData(); // ... searchData.fetch(query).then(resultList.setResults);
我們再一次簡化了代碼,并通過 SearchData 對象拋棄了之前應用程序主函數(shù)里雜亂的代碼。同時我們已經(jīng)讓搜索接口變得可測試了,盡管現(xiàn)在和服務器通信這里還有事情要做。
首先我們不是真的要跟服務器通信——不然這又變成集成測試了:諸如我們是有責任感的開發(fā)者,我們已經(jīng)確保服務器一定不會犯錯等等,是這樣嗎?為了替代這些東西,我們應該“mock”(偽造) 與服務器之間的通信。Sinon 這個庫就可以做這件事。第二個障礙是我們的測試應該覆蓋非理想環(huán)境,比如關鍵字為空。
test("測試構(gòu)造函數(shù)", function () { var sd = new SearchData(); assert(sd); }); suite("取數(shù)據(jù)", function () { var xhr, requests; setup(function () { requests = []; xhr = sinon.useFakeXMLHttpRequest(); xhr.onCreate = function (req) { requests.push(req); }; }); teardown(function () { xhr.restore(); }); test("通過正確的 URL 獲取數(shù)據(jù)", 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 會返回一個空數(shù)組", function () { var sd = new SearchData(); var req = sd.fetch(); var spy = sinon.spy(); req.then(spy); assert.deepEqual(spy.args[0][0], []); }); test("返回與搜索結(jié)果相對應的對象", 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 ]); }); });
出于篇幅的考慮,這里對搜索框的重構(gòu)及其相關的單元測試就不一一介紹了。完整的代碼可以移步至此查閱。
當我們按照可測試的 JavaScript 的思路重構(gòu)代碼之后,我們最后用下面這段代碼開啟程序:
$(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); }); });
比干凈整潔的代碼更重要的,是我們的代碼擁有了更健壯的測試基礎作為后盾。這也意味著我們可以放心的重構(gòu)任意部分的代碼而不必擔心程序遭到破壞。我們還可以繼續(xù)為新功能撰寫新的測試代碼,并確保新的程序可以通過所有的測試。
測試會在宏觀上讓你變輕松看完這些的長篇大論你一定會說:“納尼?我多寫了這么多代碼,結(jié)果還是做了這么一點事情?”
關鍵在于,你做的東西早晚要放到網(wǎng)上的。同樣是花時間解決問題,你會選擇在瀏覽器里點來點去?還是自動化測試?還是直接在線上讓你的用戶做你的小白鼠?無論你寫了多少測試,你寫好代碼,別人一用,多少會發(fā)現(xiàn)點 bug。
至于測試,它可能會花掉你一些額外的時間,但是它到最后真的是為你省下了時間。寫測試代碼測出一個問題,總比你發(fā)布到線上之后才發(fā)現(xiàn)有問題要好。如果有一個系統(tǒng)能讓你意識到它真的能避免一個 bug 的流出,你一定會心存感激。
額外的資源這篇文章只能算是 JavaScript 測試的一點皮毛,但是如果你對此抱有興趣,那么可以繼續(xù)移步至:
幻燈演示 2012 Full Frontal conference in Brighton, UK
Grunt 一個可以進行自動化測試等諸多事情的工具
測試驅(qū)動的 JavaScript 開發(fā) 及其 中文版
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://specialneedsforspecialkids.com/yun/85502.html
摘要:功能測試函數(shù)功能測試函數(shù)需要接收類型的單一參數(shù),類型用來管理測試狀態(tài)和支持格式化的測試日志。測試函數(shù)的相關說明,可以通過來查看幫助文檔。下面是一個例子被測試的進程退出函數(shù)測試進程退出函數(shù)的測試函數(shù)參考資料原文鏈接 原文鏈接:http://tabalt.net/blog/golang... Golang作為一門標榜工程化的語言,提供了非常簡便、實用的編寫單元測試的能力。本文通過Golan...
摘要:自年發(fā)布以來,走過了漫長的道路。一下子,工程師認為自己不只是前端開發(fā)者了。這種趨勢被稱為全棧的或純的解決方案。可以認為它是文檔結(jié)構(gòu)的數(shù)據(jù)庫,而不是由行列表組成的數(shù)據(jù)庫。也是高度可測試的,這是很重要的。 JavaScript自1995年發(fā)布以來,走過了漫長的道路。已經(jīng)有了幾個主要版本的ECMAScript規(guī)范,單頁Web應用程序也慢慢興起,還有支持客戶端的JavaScript框架。作為一...
摘要:轉(zhuǎn)自前端外刊評論非常感謝,翻譯的很好,受益很多,轉(zhuǎn)到此處讓前端小伙伴們也驚呆下上次我寫前端工程師必知必會已經(jīng)是三年前了,那是我寫過最火的文章了。測試的第二大障礙是工具。 轉(zhuǎn)自:前端外刊評論 非常感謝,翻譯的很好,受益很多,轉(zhuǎn)到此處讓前端小伙伴們也驚呆下........ 上次我寫《前端工程師必知必會》已經(jīng)是三年前了,那是我寫過最火的文章了。三年了,我仍然會在Twitter上...
摘要:轉(zhuǎn)自前端外刊評論非常感謝,翻譯的很好,受益很多,轉(zhuǎn)到此處讓前端小伙伴們也驚呆下上次我寫前端工程師必知必會已經(jīng)是三年前了,那是我寫過最火的文章了。測試的第二大障礙是工具。 轉(zhuǎn)自:前端外刊評論 非常感謝,翻譯的很好,受益很多,轉(zhuǎn)到此處讓前端小伙伴們也驚呆下........ 上次我寫《前端工程師必知必會》已經(jīng)是三年前了,那是我寫過最火的文章了。三年了,我仍然會在Twitter上...
閱讀 1422·2021-11-15 11:38
閱讀 3567·2021-11-09 09:47
閱讀 1969·2021-09-27 13:36
閱讀 3211·2021-09-22 15:17
閱讀 2547·2021-09-13 10:27
閱讀 2862·2019-08-30 15:44
閱讀 1158·2019-08-27 10:53
閱讀 2702·2019-08-26 14:00