這是介紹編寫可測試的Javascript UI程式碼兩篇文章中的第一篇。這一篇展示了一個基本的示例應用程式,該應用程式包含了幾個常見的反模式以及它們的解決方案。
在第二篇中,我們將用文中描述的優化技能來重構該應用,提出了一個簡單地XHR模擬,並且新增了一個測試用例以幫助後面的開發者維護程式碼。
前端開發帶來了一系列的挑戰,但是很少有文章是來討論單元測試的。自動初始化,邏輯的封裝,DOM事件處理,XHR請求以及回撥巢狀都會使得測試變的困難。
幸運的是,我們可以編寫可測試的前端程式碼,但是,這確實需要一些知識和思考。
常見的編碼實踐–容易理解,但難以測試
雖然較短,但這個人為的例子使用了幾個常見的反模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
<!DOCTYPE html> <html> <head> <title>An Untestable Authentication Form</title> </head> <body> <form id="authentication_form"> <label for="username">Username:</label> <input type="text" id="username" name="username"></input> <label for="password">Password:</label> <input type="password" id="password" name="password"></input> <button>Submit</button> <p id="username_password_required" style="display: none;"> Both the username and password are required. </p> <p id="authentication_success" style="display: none;"> You have successfully authenticated! </p> <p id="authentication_failure" style="display: none;"> This username/password combination is not correct. </p> <p id="authentication_error" style="display: none;"> There was a problem authenticating the user, please try again later. </p> </form> <script src="jquery.min.js"></script> <!-- Inline Javascript is impossible to test from an external test harness --> <script> // Even if test harness was included in the HTML, Javascript is // inaccessible to tests $(function() { // Pyramid of doom - A mixture of disparate concerns and // very difficult to test individual parts $("#authentication_form").on("submit", function(event) { // Event handler logic is mixed with form handling logic event.preventDefault(); var username = $("#username").val(); var password = $("#password").val(); if (username && password) { // Without a mock, XHR requests require a functioning // back end, adding extra dependencies and delay $.ajax({ type: "POST", url: "/authenticate_user", data: { username: username, password: password }, success: function(data, status, jqXHR) { // Knowing when this completes requires some sort // of notification if (data.success) { $("#authentication_success").show(); } else { $("#authentication_failure").show(); } }, error: function(jqXHR, textStatus, errorThrown) { $("#authentication_error").show(); } }); } else { $("#username_password_required").show(); } }); }); </script> </body> </html> |
反模式使得應用的程式碼變的難以測試
1.內聯Javascript – 嵌入在HTML檔案中的Javascript程式碼是無法包含在外部單元測試工具中的。
2.無法複用的程式碼 – 即使Javascript程式碼單獨放在外面,也沒有提供公共的介面供其他人呼叫。
3.沒有建構函式/原型物件 – 個人的單元測試就意味著獨立的操作。測試一個單例是很困難的,因為一個測試的結果可能會影響到其他測試的結果。
4.金字塔厄運 – 深層的巢狀在Javascript開發中非常多見,但是他們是讓人各種擔憂的抓狂的東西。深層巢狀在內部的程式碼邏輯是很難進行單獨測試的,並且隨著時間的推移,會有變得像義大利麵條式的難以維護的傾向。
5.拙劣的DOM事件處理程式 – 事件處理程式和表單提交邏輯混在一起,從而導致無法避免的擔憂。
6.真正的XHR請求 – 真正的XHR請求需要一個可用的後端服務,前端和後端高速並行的開發是很困難的,因為XHR請求需要一個能工作的後端才能看到請求結果。
7.狀態通知 – 更少的非同步邏輯 – 沒有某種形式的通知,是無法知道一個非同步函式是什麼時候執行完成的。
如何編寫可測試的Javascript UI程式碼
上面列出的每一個問題都是可以解決的。稍微動下腦子思考一下,其實前端的程式碼是很容易測試的。
外鏈所有的Javascript程式碼
直接嵌入到一個HTML檔案中的Javascript程式碼是無法被另一個HTML檔案使用的。外鏈的Javascript程式碼是可複用的,並且可以被不止一個的HTML檔案所引入。
提供一個公共介面
程式碼必須要提供公共介面才能被測試。在提供一個公共介面的時候,被用來封裝邏輯的最經常使用的模式是使用模組。在Addy Osmani的非常優秀的Javascript設計模式必知必會一書中,他指出:模組模式最初在傳統軟體行業中作為類的私有和公共介面的封裝被提出的。
原來的樣例應用程式沒有公共介面,所有的程式碼都封裝在一個自呼叫的私有函式中。唯一可以進行測試的地方就是表單提交事件功能部分。雖然確定是可以(進行測試)的,用僅有的混合事件處理程式來編寫測試用例會有不必要的麻煩。
適當的封裝模組可以用來限制功能訪問,減少全域性名稱空間的汙染,並且可以提供公共介面方便測試。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
var PublicModule = (function() { "use strict"; // This is the public interface of the Module. var Module = { // publicFunction can be called externally publicFunction: function() { return "publicFunction can be invoked externally but " + privateFunction(); } }; // privateFunction is completely hidden from the outside. function privateFunction() { return "privateFunction cannot"; } return Module; }()); |
正如Addy指出的,模組模式的一個弊端在於“無法建立對私有成員的自動化單元測試”。一個函式如果不能被直接訪問,那麼它就不能被直接進行測試。模組設計的時候,在保持成員私有化和向公眾公開成員之間存在一定的擴充套件性。
在Mozilla Persona程式碼庫中,我們經常在測試公共介面的私有函式時暴露出困難,會很明顯的把額外的函式作為測試API的一部分。雖然其他的開發者仍然可以呼叫這些私有函式,但作者的意圖是很明顯的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
... publicFunction: function() { return "publicFunction can be invoked externally but " + privateFunction(); } // BEGIN TESTING API , privateFunction: privateFunction // END TESTING API }; // privateFunction is now accessible via the TESTING API function privateFunction() { ... |
在註釋標記// BEGIN TESTING API和//END TESTING API 之間的程式碼可以在專案構建的時候刪除掉。
使用可例項化的物件
最初的應用程式並沒有使用可例項化的物件,它的程式碼被設計的只執行一次。這一約束使得重置應用狀態以及獨立的執行單元測試變得很困難。
測試可以被多次初始化的模組相對來說是更容易的。在Javascript中,存在兩個相似的方法:建構函式和Object.create.
前面兩個例子中的PublicModule變數是物件而不是函式,而Object.create可以被用來建立物件的副本。可以在原型中增加一個可選的初始化函式以便在建構函式中執行初始化。
1 2 3 4 5 6 7 8 9 |
... // the init function takes care of initialization traditionally done // in a constructor init: function(options) { this.valueSetOnInit = options.valueSetOnInit; }, publicFunction: function() { ... |
1 2 3 4 5 |
// create an instance of the PublicModule. var objInstance = Object.create(PublicModule); objInstance.init({ valueSetOnInit: "value set during initialization" }); |
減少巢狀回撥
非常不幸的是,巢狀回撥是前端Javascript程式設計很重要的一部分。上面不可測試驗證的Form表單的例子絕沒有額外的包含三層巢狀的回撥。深層的巢狀回撥程式碼是這樣的–他們將功能糟糕的混雜在一起並且讓人擔憂重重。
將金字塔式的程式碼拆分為各功能元件我們可以得到較“平坦”的程式碼,這些程式碼會由小的,有粘著力的以及功能易測試的程式碼組成。
將DOM事件處理程式和它的行為分離
不可測試驗證的Form例子用了一個單獨的提交處理程式來同時關注事件處理和表單提交。不僅僅是同時關注了這兩件事,而且這個混合結果導致如果不使用混合的事件程式將無法提交表單。
1 2 3 4 5 6 7 8 9 10 |
... $("form").on("submit", function(event) { event.preventDefault(); // this code is impossible to invoke programmatically // without using a synthetic DOM event. var name = $("#name").val(); doSomethingWithName(name); }); ... |
將表單處理邏輯從事件處理程式中分離出來讓我們可以程式設計提交表單而不用求助於混合的事件處理程式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
... $("form").on("submit", submitHandler); function submitHandler(event) { event.preventDefault(); submitForm(); }); // form submission can now be done programmatically // by calling submitForm directly. function submitForm() { var name = $("#name").val(); doSomethingWithName(name); } ... |
單元測試可以使用submitForm而不必使用混合的表單提交事件處理程式。
模擬XHR請求
幾乎所有的現代網站都是用XHR(AJAX)請求。XHR請求依賴於服務端;從前端的請求必須被服務端響應,否則應用什麼都做不了。只有服務端也準備好了才能測試真正的XHR請求,否則會嚴重影響並行開發。
1 2 3 4 5 6 7 8 9 10 11 12 |
... // This is an explicit dependency on the jQuery ajax functionality as well // as a working back end. $.ajax({ type: "POST", url: "/authenticate_user", data: { username: username, password: password }, success: function(data, status, jqXHR) { ... |
與其執行真正的XHR請求,不如用一種格式定義良好的XHR響應來模擬。Mock物件是一種以可控的方式來模擬真正物件的行為的模擬物件。模擬物件經常被用於那些需要依賴不可獲得的、較慢的、不可控的或者缺陷太多而無法信任的功能上面。XHR請求恰好是一個很好的例子。
同時測試前端和後端是很重要的,但是這最好留給功能測試。單元測試意味著測試單獨的條目。
一個發起XHR請求的模組應該接受一個包含在其建構函式或者初始化函式中的XHR模擬物件。然後這個模組使用這個被包含的模擬物件而不是去直接呼叫$.ajax。模組在執行單元測試的時候使用模擬物件,但是在生產中使用$.ajax。
合理的預設值可以減少生產體系中初始化程式碼的數量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
... init: function(options) { // Use the injected ajax function if available, otherwise // use $.ajax by default. this.ajax = options.ajax || $.ajax; }, submitForm: function() { ... // This can call either an XHR mock or a production XHR resource // depending on how the object is initialized. this.ajax({ type: "POST", url: "/authenticate_user", data: { username: username, password: password }, ... }); } ... |
非同步程式設計需要通知機制
上面不可測試驗證的Form表單的例子缺少通知的機制來表明什麼時候所有的程式已結束。這在非同步函式執行結束後的需要執行的單元測試中會是一個問題。
Javascript中存在很多的通知機制,回撥,觀察者模式以及事件是幾個。簡單的回撥函式是目前最常用的。
1 2 3 4 5 6 7 8 9 10 11 |
... submitForm: function(done) { ... this.ajax({ ... // an ajax call is asynchronous. When it successfully completes, // it calls the done function. success: done }); } ... |
單元測試後清除不必要的程式碼
單元測試應該單獨的進行;一旦一個單元測試結束,所有的測試狀態應該被清除,包括DOM事件處理。導致物件將DOM事件處理程式繫結到相同的DOM元素的兩個測試用例容易相互影響,而這容易被開發者疏忽。為了排除這種影響,一個沒用的物件應該從它的DOM事件處理程式中移除。額外的工作會提供一些額外的好處;在應用中建立和銷燬物件可以大大的減少記憶體溢位。
1 2 3 4 5 |
... teardown: teardown() { $("form").off("submit", submitHandler); } ... |
總結
就是這樣。實際上沒有編寫多少前端Javascript程式碼,這樣就可以進行單元測試。公共介面,初始化物件,少巢狀的程式碼結構,組織良好的事件處理程式以及測試之後不必要程式碼的清除。
這篇文章的程式碼可以在Github上看到。
後記
在文章的第二部分,上面提到的幾點優化建議將被用來重構上面那個簡單的例子,並編寫一個簡單的XHR模擬,增加一個完整的單元測試用例,敬請期待!
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式