編寫可測試的 JavaScript

jopen 10年前發布 | 23K 次閱讀 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/

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!