編寫可測試的 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/