編寫可測試的 JavaScript
推ter 的工程師文化要求進行測試,許多的測試。在進入 推ter 之前我還未有過測試 JavaScript 的經驗,所以在這之后我學習到了很多。特別是學到了許多過去我使用、書寫和鼓勵使用的代碼其實是不利于書寫可測試的代碼的。所以我覺得在此分享我所學習到有價值的,如何書寫可測試的 JavaScript 幾條最重要的原則。這里提供的這些示例雖然基于 QUnit,但是也應該適用于其他的 JavaScript 測試框架。
避免單例
我最受歡迎的博文中的其中一篇就是關于如何使用 JavaScript 模塊模式 在程序中創建強大的單例。這種做法簡單有效,但是給測試帶來了問題。理由很簡單: 單例在測試間造成了狀態污染 。與其把單例當作模塊使用,不如把他們寫成可構造的對象。一旦應用程序初始化,就在全局層上分配一個單一的、默認的實例。
例如,考慮如下的單例模塊(當然,是人為的例子):
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 測試套件:
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 模塊的實現改動了以后經常維護這樣的測試模板。更好的做法如下:
function newDataStore() { var data = []; return { push: function (item) { data.push(item); }, pop: function() { return data.pop(); }, length: function() { return data.length; } }; } var dataStore = newDataStore();
現在,測試套件看起來如下:
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 中建立真正的私有成員。這樣做的好處是,可以保持全局可訪問的命名空間免受不必要的,私有實現引用細節的侵擾。然而過度使用這種模式會導致代碼無法測試。這是因為你的測試套件將無法訪問到閉包中隱藏的私有函數,也就無法進行測試了。考慮以下的代碼:
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() 方法,并為了測試而把方法暴露為公有接口,但這并不是一件好的做法。在這個簡單示例中這么做可能問題不大,但是在構建復雜對象的時候,如果使用了重要的私有方法,將會導致依賴不可測試的標紅代碼。這里是上面代碼的可測試版本:
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 測試套件:
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 示例:
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 重定向的情況下測試這個方法,而這樣會完全離開測試套件。
這里是可測試版本:
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 編寫一個簡單的測試套件:
test("_getRedirectPart", function() { equal(_getRedirectPart("#foo"), "hash"); equal(_getRedirectPart("/foo"), "pathname"); equal(_getRedirectPart("http://foo.com"), "href"); });
現在最重要的 redirectTo 已經通過測試,我們就不必擔心會意外地跳轉到測試套件之外了。
注意:有一種備選解決方案是創建 `performRedirect` 函數做地址跳轉,但是在測試套件中隔離此函數。這是許多人的常用實踐,但是我會盡量避免方法隔離。我發現在我目前的所有情形中 QUnit 基本上工作得很好,并且更傾向于像上面那樣,不用在測試中隔離函數,但是你的情形可能會不太一樣。
編寫大量測試
這是明擺著的事情,但是仍然要記住它。許多程序員寫的測試太少,因為寫測試很難,或者很費事。我一直都被這個問題所困擾,所以我寫出了一個 QUnit 助手讓寫大量的測試更簡單。這是一個叫 testCases 的函數,你在 test 塊中可以調用,可以傳進一個函數,調用上下文和輸入/輸出的數組用來嘗試及比對。你可以為你的輸入/輸出函數快速地構建出健壯縝密的測試。
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])); } }
這里是一個簡單的使用示例:
test("foo", function() { testCases(foo, null, [ [["bar", "baz"], "barbaz"], [["bar", "bar"], "barbar", "a passing test"] ]); });
總結
關于可測試的 JavaScript 有很多要寫的內容。我確信這類優秀書籍有很多,但是我希望這篇文章能基于我的日常所得,提供一份實用案例的概覽。因為我并不是一個測試專家,所以如果我出錯了,或者提供了不好的建議,請告訴我。
原文鏈接: adequately good 翻譯: 伯樂在線 - 埃姆杰
譯文鏈接: http://blog.jobbole.com/67560/