Twitter 的工程師文化要求進行測試,許多的測試。在進入 Twitter 之前我還未有過測試 JavaScript 的經驗,所以在這之後我學習到了很多。特別是學到了許多過去我使用、書寫和鼓勵使用的程式碼其實是不利於書寫可測試的程式碼的。所以我覺得在此分享我所學習到有價值的,如何書寫可測試的 JavaScript 幾條最重要的原則。這裡提供的這些示例雖然基於 QUnit,但是也應該適用於其他的 JavaScript 測試框架。
避免單例
我最受歡迎的博文中的其中一篇就是關於如何使用 《JavaScript 模組模式》 在程式中建立強大的單例。這種做法簡單有效,但是給測試帶來了問題。理由很簡單: 單例在測試間造成了狀態汙染 。與其把單例當作模組使用,不如把他們寫成可構造的物件。一旦應用程式初始化,就在全域性層上分配一個單一的、預設的例項。
例如,考慮如下的單例模組(當然,是人為的例子):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var dataStore = (function() { var data = []; return { push: function (item) { data.push(item); }, pop: function() { return data.pop(); }, length: function() { return data.length; } }; }()); |
有了這個模組,我們可能想測試 foo.bar 方法。以下是一個簡單的 QUnit 測試套件:
1 2 3 4 5 6 7 8 9 10 11 |
module("dataStore"); test("pop", function() { dataStore.push("foo"); dataStore.push("bar") equal(dataStore.pop(), "bar", "popping returns the most-recently pushed item"); }); test("length", function() { dataStore.push("foo"); equal(dataStore.length(), 1, "adding 1 item makes the length 1"); }); |
在執行測試套件時,length 斷言會測試失敗,但是這裡很難弄清它為什麼會失敗。問題就在於上一次測試中 dataStore 的狀態留了下來。如果只是給測試重新排序的話 length 測試會通過,但是會有紅色標誌標明某處出現了問題。我們當然可以使用 setup 或者 teardown 方法,用恢復 dataStore 的狀態來修復此問題,但那也同時代表著我們需要在 dataStore 模組的實現改動了以後經常維護這樣的測試模板。更好的做法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function newDataStore() { var data = []; return { push: function (item) { data.push(item); }, pop: function() { return data.pop(); }, length: function() { return data.length; } }; } var dataStore = newDataStore(); |
現在,測試套件看起來如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
module("dataStore"); test("pop", function() { var dataStore = newDataStore(); dataStore.push("foo"); dataStore.push("bar") equal(dataStore.pop(), "bar", "popping returns the most-recently pushed item"); }); test("length", function() { var dataStore = newDataStore(); dataStore.push("foo"); equal(dataStore.length(), 1, "adding 1 item makes the length 1"); }); |
這讓我們的全域性 dataStore 和以前的行為保持一致,同時避免了測試之間的相互汙染。每項測試都有自己的DataStore 例項物件,都會在測試完成時進入垃圾回收。
避免基於閉包的私有形式
我過去所推崇的另一個模式是 在 JavaScript 中建立真正的私有成員。這樣做的好處是,可以保持全域性可訪問的名稱空間免受不必要的,私有實現引用細節的侵擾。然而過度使用這種模式會導致程式碼無法測試。這是因為你的測試套件將無法訪問到閉包中隱藏的私有函式,也就無法進行測試了。考慮以下的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function Templater() { function supplant(str, params) { for (var prop in params) { str.split("{" + prop +"}").join(params[prop]); } return str; } var templates = {}; this.defineTemplate = function(name, template) { templates[name] = template; }; this.render = function(name, params) { if (typeof templates[name] !== "string") { throw "Template " + name + " not found!"; } return supplant(templates[name], params); }; } |
Templater 物件中的關鍵方法是 supplant,但是我們並不能從構造器閉包的外部訪問到此方法。所以,與 QUnit 類似的測試套件並不能如我們期待的那般工作。另外,我們無法在不嘗試呼叫 .render() 方法,讓它作用於模板,檢視所生成異常的情況下來驗證 defineTemplate 方法的效果。我們當然可以簡單地新增一個 getTemplate() 方法,併為了測試而把方法暴露為公有介面,但這並不是一件好的做法。在這個簡單示例中這麼做可能問題不大,但是在構建複雜物件的時候,如果使用了重要的私有方法,將會導致依賴不可測試的標紅程式碼。這裡是上面程式碼的可測試版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function Templater() { this._templates = {}; } Templater.prototype = { _supplant: function(str, params) { for (var prop in params) { str.split("{" + prop +"}").join(params[prop]); } return str; }, render: function(name, params) { if (typeof this._templates[name] !== "string") { throw "Template " + name + " not found!"; } return this._supplant(this._templates[name], params); }, defineTemplate: function(name, template) { this._templates[name] = template; } }; |
這裡是對應的 QUnit 測試套件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
module("Templater"); test("_supplant", function() { var templater = new Templater(); equal(templater._supplant("{foo}", {foo: "bar"}), "bar")) equal(templater._supplant("foo {bar}", {bar: "baz"}), "foo baz")); }); test("defineTemplate", function() { var templater = new Templater(); templater.defineTemplate("foo", "{foo}"); equal(template._templates.foo, "{foo}"); }); test("render", function() { var templater = new Templater(); templater.defineTemplate("hello", "hello {world}!"); equal(templater.render("hello", {world: "internet"}), "hello internet!"); }); |
注意程式碼中對 render 的測試僅僅是一個確保 defineTemplate 和 supplant 能夠互相整合的測試。我們已經單獨測試了這些方法,從而讓我們可以很容易發現 render 的測試失敗是具體哪個元件導致的。
編寫緊密聯絡的多個函式
在任何語言中,緊密聯絡的函式都是重要的,JavaScript 也展示了這麼做的原因。你使用 JavaScript 完成的大部分都是由環境提供的全域性單例,也是測試套件所依賴的東西。例如,如果你的所有方法都在嘗試給 window.location 賦值,那麼測試 URL rewriter 就會有困難。與此相反,你應當將系統分解成對應的邏輯元件,決定它們如何去做,並編寫實際完成的簡短函式。你可以使用多個輸入輸出測試這些函式邏輯,而不測試那個修改 window.location 的最終函式。這麼做既可以正確地組合系統,也能保證安全。
這裡是不可測試的 URL rewriter 示例:
1 2 3 4 5 6 7 8 9 |
function redirectTo(url) { if (url.charAt(0) === "#") { window.location.hash = url; } else if (url.charAt(0) === "/") { window.location.pathname = url; } else { window.location.href = url; } } |
雖然示例中的邏輯很簡單,但我們也能設想到情況更復雜的 redirecter 。隨著複雜度的上升,我們不能在不觸發 window 重定向的情況下測試這個方法,而這樣會完全離開測試套件。
這裡是可測試版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function _getRedirectPart(url) { if (url.charAt(0) === "#") { return "hash"; } else if (url.charAt(0) === "/") { return "pathname"; } else { return "href"; } } function redirectTo(url) { window.location[_getRedirectPart(url)] = url; } |
而現在我們可以為 _getRedirectPart 編寫一個簡單的測試套件:
1 2 3 4 5 |
test("_getRedirectPart", function() { equal(_getRedirectPart("#foo"), "hash"); equal(_getRedirectPart("/foo"), "pathname"); equal(_getRedirectPart("http://foo.com"), "href"); }); |
現在最重要的 redirectTo 已經通過測試,我們就不必擔心會意外地跳轉到測試套件之外了。
注意:有一種備選解決方案是建立 performRedirect
函式做地址跳轉,但是在測試套件中隔離此函式。這是許多人的常用實踐,但是我會盡量避免方法隔離。我發現在我目前的所有情形中 QUnit 基本上工作得很好,並且更傾向於像上面那樣,不用在測試中隔離函式,但是你的情形可能會不太一樣。
編寫大量測試
這是明擺著的事情,但是仍然要記住它。許多程式設計師寫的測試太少,因為寫測試很難,或者很費事。我一直都被這個問題所困擾,所以我寫出了一個 QUnit 助手讓寫大量的測試更簡單。這是一個叫 testCases 的函式,你在 test 塊中可以呼叫,可以傳進一個函式,呼叫上下文和輸入/輸出的陣列用來嘗試及比對。你可以為你的輸入/輸出函式快速地構建出健壯縝密的測試。
1 2 3 4 5 6 |
function testCases(fn, context, tests) { for (var i = 0; i < tests.length; i++) { same(fn.apply(context, tests[i][0]), tests[i][1], tests[i][2] || JSON.stringify(tests[i])); } } |
這裡是一個簡單的使用示例:
1 2 3 4 5 6 |
test("foo", function() { testCases(foo, null, [ [["bar", "baz"], "barbaz"], [["bar", "bar"], "barbar", "a passing test"] ]); }); |
總結
關於可測試的 JavaScript 有很多要寫的內容。我確信這類優秀書籍有很多,但是我希望這篇文章能基於我的日常所得,提供一份實用案例的概覽。因為我並不是一個測試專家,所以如果我出錯了,或者提供了不好的建議,請告訴我。