這是介紹“如何編寫可測試的Javascript UI程式碼”兩篇文章中的第二篇。
在第一篇文章反模式及其它們的解決方案中用一個示例應用程式引入了幾個常見的,可避免的,約束了可測試性的反模式,並且解釋了為什麼這些常見的做法是反模式的以及如何修復他們。
這篇文章來繼續重構原來的應用,以使得它的程式碼更容易閱讀,更容易被複用以及更容易進行測試。一旦重構完成,測試就要開始:建立測試工具,開發XHR模擬,最後,新增一個完整的單元測試用例。
使用最佳實踐來編寫可測試的UI程式碼
在第一篇文章“反模式及其解決方案”中,列出了幾個使得UI程式碼可測試的最佳實踐:
1.外鏈所有的Javascript;
2.提供公共介面;
3.使用可例項化的物件;
4.減少巢狀回撥;
5.將DOM事件處理程式與事件處理函式相分離;
6.當非同步函式完成的時候通知訂閱者;
7.測試完成後清除沒用的物件;
8.在XHR請求中新增模擬物件;
9.把應用初始化分離成自己的模組。
用這個清單作為指引,原來的應用程式被會完全重構,並能達到我們對程式碼進行單元測試的目的。
從HTML開始–外鏈所有的指令碼
原來內聯在HTML檔案中的Javascript程式碼已經被放在了外部並且被放置於兩個檔案中:authentication-form.js 和start.js。原來的大部分的邏輯放於authentication-form.js模組中,應用的初始化則在start.js中進行。
引用自index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<!DOCTYPE html> <html> <head> <title>A Testable Authentication Form</title> </head> <body> ... <script src="jquery.min.js"></script> <!-- Both the authentication form and the initialization code are split into their own files. Javascript resources can be combined for production use. --> <script src="authentication-form.js"></script> <script src="start.js"></script> </body> </html> |
擁有可選的公共介面的邏輯封裝模組
AuthenticationForm是一個公共的可獲取的模組,該模組比較簡潔地封裝了大部分的原始邏輯。AuthenticationForm提供了一個公共的介面,通過該介面其功能可以被測試。
一個公共介面–引用自authentication-form.js
1 2 3 4 5 |
// The Module pattern is used to encapsulate logic. AuthenticationForm is the // public interface. var AuthenticationForm = (function() { "use strict"; ... |
使用可初始化的物件
原來的form表單例子沒有可例項化的部分,這也就意味著它的程式碼只能執行一次。這樣的話有效的單元測試幾乎是不可能的。重構了的AuthenticationForm是一個原型物件,使用Object.create來建立新的例項。
可例項化的物件-引用自authentication-form.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
var AuthenticationForm = (function() { "use strict"; // Module is the prototype object that is assigned to // AuthenticationForm. New instances of AuthenticationForm // are created using: // // var authForm = Object.create(AuthenticationForm) // var Module = { init: function(options) { ... }; return Module; ... }()); |
減少巢狀回撥的使用
重構的AuthenticationForm從原來的的深層巢狀回撥(導致程式碼成金字塔狀)抽取邏輯形成四個公共的可以獲取得到的函式。這些函式中的兩個被用來提供給物件初始化和銷燬,其餘的兩個用於測試介面。
去除金字塔–引用自authentication-form.js
1 2 3 4 5 6 7 8 9 10 11 |
... var Module = { init: ... teardown: ... // BEGIN TESTING API submitForm: submitForm, checkAuthentication: checkAuthentication // END TESTING API }; ... |
將DOM事件處理程式從事件行為中分離出來
將DOM事件處理程式從事件行為中分離出來有助於程式碼的重用和可測試。
將DOM事件處理程式從事件行為中分離出來–引用自authentication-form.js
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 |
... init: function(options) { ... // A little bit of setup is needed for teardown. This will be // explained shortly. this.submitHandler = onFormSubmit.bind(this); $("#authentication_form").on("submit", this.submitHandler); }, ... }; ... // Separate the submit handler from the actual action. This allows // onFormSubmit takes care of the event then calls submitForm like any // other function would. function onFormSubmit(event) { event.preventDefault(); submitForm.call(this); } // submitForm to be called programatically without worrying about // handling the event. function submitForm(done) { ... } |
在非同步函式中使用回撥(或者其他的通知機制)
AuthenticationForm的測試介面中的兩個函式,submitForm和checkAuthentication是非同步的。當所有處理程式都完成的時候他們都接受一個函式進行回撥。
有回撥函式的非同步回撥–引用自authentication-form.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
}; ... // checkAuthentication is asynchronous but the unit tests need to // perform their checks after all actions are complete. "done" is an optional // callback that is called once all other actions complete. function submitForm(done) { ... } // checkAuthentication makes use of the ajax mock for unit testing. function checkAuthentication(username, password, done) { ... } ... |
將沒用的物件處理掉
單元測試應該獨立的進行。任何的狀態,包括附屬的DOM事件處理器,在測試的時候必須被重置。
移除附加的DOM事件處理程式–引用自authentication-form.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
... init: function(options) { ... // If unit tests are run multiple times, it is important to be able to // detach events so that one test run does not interfere with another. this.submitHandler = onFormSubmit.bind(this); $("#authentication_form").on("submit", this.submitHandler); }, teardown: function() { // detach event handlers so that subsequent test runs do not interfere // with each other. $("#authentication_form").off("submit", this.submitHandler); }, ... |
將應用的初始化邏輯分離出一個單獨的(初始化)模組
start.js是一個自呼叫的函式,它在多有的js檔案都下載完成後執行。因為我們的應用很簡單,只需要很少的初始化程式碼–一個AuthenticationForm例項被建立並初始化。
start.js
1 2 3 4 5 6 |
(function() { "use strict"; var authenticationForm = Object.create(AuthenticationForm); authenticationForm.init(); }()); |
在這一點上,原來的整個應用程式被重構並且重新實現了。使用者應該能看到在功能上並沒有做改變,純粹是程式碼結構的修改。
如何進行單元測試?
儘管當前我們的程式碼是可測試的,一篇關於單元測試的文章卻沒有寫任何相關的測試程式碼!有幾個高質量的測試框架,在這個例子中我們使用QUnit。
首先,我們需要一個測試工具。一個測試工具由一個模擬DOM和Javascript程式碼組成。模擬DOM由測試中要使用的元素組成,通常是類似於form或者你要檢測可見性的元素這些東西。為了避免測試交叉汙染,在每一個單元測試之後都將DOM元素進行重置。QUnit期望模擬元素包含在id為#qunit-fixture的元素中。
Javascript程式碼包含一個單元測試執行器,要被測試的程式碼,獨立的模擬以及對他們自己的一些測試。
測試工具–引用自tests/index.html
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 |
... <h1 id="qunit-header">Authentication Form Test Suite</h1> ... <!-- A slimmed down form mock is used so there are form elements to attach event handlers to --> <div id="qunit-fixture"> <form> <input type="text" id="username" name="username"></input> <input type="password" id="password" name="password"></input> </form> <p id="username_password_required" style="display: none;"> Both the username and password are required. </p> ... </div> <!-- Javascript used for testing --> <script src="qunit.js"></script> <!-- Include the ajax mock so no XHR requests are actually made --> <script src="ajax-mock.js"></script> <!-- Include the module to test --> <script src="../authentication-form.js"></script> <!-- The tests --> <script src="authentication-form.js"></script> ... |
書寫XHR模擬
XHR請求需要依賴於服務端,從前端發起的請求必須被服務端響應否則應用什麼都幹不了。用真正的XHR請求進行測試意味著服務端必須做好準備,這會嚴重阻礙前後端並行開發。
與其發起真正的XHR請求,不如使用一個模擬的請求來做。模擬物件是一些替代物件–可以在測試中進行精確地控制。一個模擬物件必須實現使用者要使用的所有功能。幸運的是,XHR模擬(也叫AjaxMock)只需要實現所有jQuery.ajax功能的很小的一部分即可。這個單獨的模擬功能提供了整合所有服務端響應的能力。幾個額外的函式被加進來輔助單元測試。
AjaxMock介面
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 |
AjaxMock = (function() { ... /* * AjaxMock mimicks portions of the $.ajax functionality. * See http://api.jquery.com/jQuery.ajax/ */ var AjaxMock = { // The only jQuery function that is needed by the consumer ajax: function(options) { ... }, // What follows are non standard functions used for testing. setSuccess: ... setError: ... getLastType: ... getLastURL: ... getLastData: ... }; return AjaxMock; }()); |
完成一些測試!
現在,測試工具和XHR模擬都已經準備好了,我們可以寫一些單元測試了!測試包含6個獨立的測試。每個測試都會例項化一個新的AuthenticationForm物件和XHR模擬。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 |
(function() { "use strict"; var ajaxMock, authenticationForm; module("testable-authentication-form", { setup: function() { // create a mock XHR object to inject into the authenticationForm for // testing. ajaxMock = Object.create(AjaxMock); authenticationForm = Object.create(AuthenticationForm); authenticationForm.init({ // Inject the ajax mock for unit testing. ajax: ajaxMock.ajax.bind(ajaxMock) }); }, teardown: function() { // tear down the authenticationForm so that subsequent test runs do not // interfere with each other. authenticationForm.teardown(); authenticationForm = null; } }); asyncTest("submitForm with valid username and password", function() { $("#username").val("testuser"); $("#password").val("password"); ajaxMock.setSuccess({ success: true, username: "testuser", userid: "userid" }); authenticationForm.submitForm(function(error) { equal(error, null); ok($("#authentication_success").is(":visible")); start(); }); }); ... }()); |
總結
花了一些時間,但是我們達到了我們的目的。我們的程式碼易於閱讀,易於重用,並且有一個完整的測試套件。
編寫可測試的程式碼通常是一個挑戰,但是你一旦適應,基礎的部分還是很容易的。在開始一行程式碼之前,你要問你自己“我要如何來對程式碼進行測試?”。這個簡單的問題最終將節省大量時間和並在你重構或新增新功能的時候給你信心。
所有的程式碼在Github上可以獲取:https://github.com/shane-tomlinson/shanetomlinson.com/tree/master/2013-jan-writing-testable-ui-javascript
如果你有任何問題,歡迎發表評論。
最終產品
index.html
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 |
<!DOCTYPE html> <!-- /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ --> <html> <head> <title>A Testable 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> <!-- Both the authentication form and the initialization code are split into their own files. They can be combined for production use. --> <script src="authentication-form.js"></script> <script src="start.js"></script> </body> </html> |
authentication-form.js
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 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
// The Module pattern is used to encapsulate logic. AuthenticationForm is the // public interface. var AuthenticationForm = (function() { "use strict"; var Module = { init: function(options) { options = options || {}; // Use an injected request function for testing, use jQuery's xhr // function as a default. this.ajax = options.ajax || $.ajax; // If unit tests are run multiple times, it is important to be able to // detach events so that one test run does not interfere with another. this.submitHandler = onFormSubmit.bind(this); $("#authentication_form").on("submit", this.submitHandler); }, teardown: function() { // detach event handlers so that subsequent test runs do not interfere // with each other. $("#authentication_form").off("submit", this.submitHandler); }, // BEGIN TESTING API // A build script could strip this out to save bytes. submitForm: submitForm, checkAuthentication: checkAuthentication // END TESTING API }; return Module; // Separate the submit handler from the actual action. This allows // submitForm to be called programatically without worrying about // handling the event. function onFormSubmit(event) { event.preventDefault(); submitForm.call(this); } // checkAuthentication is asynchronous but the unit tests need to // perform their checks after all actions are complete. "done" is an // optional callback that is called once all other actions complete. function submitForm(done) { var username = $("#username").val(); var password = $("#password").val(); if (username && password) { checkAuthentication.call(this, username, password, function(error, user) { if (error) { $("#authentication_error").show(); } else { updateAuthenticationStatus(user); } // surface any errors so tests can be done. done && done(error); }); } else { $("#username_password_required").show(); // pass back an error message that can be used for testing. done && done("username_password_required"); } } // checkAuthentication makes use of the ajax mock for unit testing. function checkAuthentication(username, password, done) { this.ajax({ type: "POST", url: "/authenticate_user", data: { username: username, password: password }, success: function(resp) { var user = null; if (resp.success) { user = { username: resp.username, userid: resp.userid }; } done && done(null, user); }, error: function(jqXHR, textStatus, errorThrown) { done && done(errorThrown); } }); } function updateAuthenticationStatus(user) { if (user) { $("#authentication_success").show(); } else { $("#authentication_failure").show(); } } }()); |
start.js
1 2 3 4 5 6 |
(function() { "use strict"; var authenticationForm = Object.create(AuthenticationForm); authenticationForm.init(); }()); |
tests/index.html
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 |
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <link type="text/css" rel="stylesheet" href="qunit.css" /> </head> <body> <h1 id="qunit-header">Authentication Form Test Suite</h1> <h2 id="qunit-banner"></h2> <div id="qunit-testrunner-toolbar"></div> <h2 id="qunit-userAgent"></h2> <ol id="qunit-tests"></ol> <div id="qunit-test-area"></div> <div id="qunit-fixture"> <!-- A slimmed down form mock is used so there are form elements to attach event handlers to --> <form> <input type="text" id="username" name="username"></input> <input type="password" id="password" name="password"></input> </form> <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> </div> <script src="../jquery.min.js"></script> <!-- QUnit is used for this example. Jasmine and Mocha are two other popular test suites --> <script src="qunit.js"></script> <!-- Include the ajax mock so no XHR requests are actually made --> <script src="ajax-mock.js"></script> <!-- Include the module to test --> <script src="../authentication-form.js"></script> <!-- The tests --> <script src="authentication-form.js"></script> </body> </html> |
tests/ajax-mock.js
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 |
AjaxMock = (function() { "use strict"; /* * The AjaxMock object type is a controllable XHR module used for unit * testing. It is injected into the AuthenticationForm so that real XHR * requests are not made. Instead, the mock can be controlled to return * expected values. * * AjaxMock mimicks the portions of the $.ajax functionality. * See http://api.jquery.com/jQuery.ajax/ */ var AjaxMock = { // The only jQuery function used for ajax requests ajax: function(options) { this.type = options.type; this.url = options.url; this.data = options.data; if ("successValue" in this) { // Neither our code nor our tests make use of jqXHR or textStatus if (options.success) options.success(this.successValue); } else if ("errorValue" in this) { // Neither our code nor our tests make use of jqXHR or textStatus if (options.error) options.error(null, 500, this.errorValue); } else { throw new Error("setSuccess or setError must be called before ajax"); } }, // What follows are non standard functions used for testing. setSuccess: function(successValue) { this.successValue = successValue; }, setError: function(errorValue) { this.errorValue = errorValue; }, getLastType: function() { return this.type; }, getLastURL: function() { return this.url; }, getLastData: function() { return this.data; } }; return AjaxMock; }()); |
tests/authentication-form.js
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 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
(function() { "use strict"; var ajaxMock, authenticationForm; module("testable-authentication-form", { setup: function() { // create a mock XHR object to inject into the authenticationForm for // testing. ajaxMock = Object.create(AjaxMock); authenticationForm = Object.create(AuthenticationForm); authenticationForm.init({ // Inject the ajax mock for unit testing. ajax: ajaxMock.ajax.bind(ajaxMock) }); }, teardown: function() { // tear down the authenticationForm so that subsequent test runs do not // interfere with each other. authenticationForm.teardown(); authenticationForm = null; } }); asyncTest("submitForm with valid username and password", function() { $("#username").val("testuser"); $("#password").val("password"); ajaxMock.setSuccess({ success: true, username: "testuser", userid: "userid" }); authenticationForm.submitForm(function(error) { equal(error, null); ok($("#authentication_success").is(":visible")); start(); }); }); asyncTest("submitForm with invalid username and password", function() { $("#username").val("testuser"); $("#password").val("invalidpassword"); ajaxMock.setSuccess({ success: false }); authenticationForm.submitForm(function(error) { equal(error, null); ok($("#authentication_failure").is(":visible")); start(); }); }); asyncTest("submitForm with missing username and password", function() { $("#username").val(""); $("#password").val(""); authenticationForm.submitForm(function(error) { equal(error, "username_password_required"); ok($("#username_password_required").is(":visible")); start(); }); }); asyncTest("submitForm with XHR error", function() { $("#username").val("testuser"); $("#password").val("password"); ajaxMock.setError("could not complete"); authenticationForm.submitForm(function(error) { equal(error, "could not complete"); ok($("#authentication_error").is(":visible")); start(); }); }); asyncTest("checkAuthentication with valid user", function() { ajaxMock.setSuccess({ success: true, username: "testuser", userid: "userid" }); authenticationForm.checkAuthentication("testuser", "password", function(error, user) { equal(error, null); equal(ajaxMock.getLastType(), "POST"); equal(ajaxMock.getLastURL(), "/authenticate_user"); var data = ajaxMock.getLastData(); equal(data.username, "testuser"); equal(data.password, "password"); equal(user.username, "testuser"); equal(user.userid, "userid"); start(); }); }); asyncTest("checkAuthentication with missing XHR error", function() { ajaxMock.setError("could not complete"); authenticationForm.checkAuthentication("testuser", "password", function(error) { equal(error, "could not complete"); start(); }); }); }()); |
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式