單元測試101:你測試過你的javascript嗎? .

你當然是測試你的代碼。沒有寫出相當數量的代碼後不運行一下就直接丟到產品中。在本文中我對你是如何測試的進行質疑。如果你不是已經儘可能的多的自動化測試,爲生產力和信息提升做好準備吧。

一句話的警告:我將在本文中談論單元測試和測試驅動開發(TDD),如果你已經得出結論:下面的任何理由對你都不適合,那麼請繼續閱讀,或者至少閱讀從我爲什麼要關心?到最後:

  •  我使用一個庫,如jQuery,它保證我的代碼正確的工作
  •  測試是一個對專業人員的高級的實踐,不適合我
  •  測試太費時間,我只想寫產品代碼

不同的目的,不同的測試

測試意味着很多事,如何把測試做的最好依賴於一個詳盡的測試目標。這裏有一些可能會在你的應用中遇到的測試的例子:

  •  易用性測試
  •  性能測試
  •  一致性/迴歸測試

在本文中,我們專注於一致性和迴歸測試。換句話說,是那種保障代碼做它應該做的事,並且沒有缺陷。絕大多數情況下,不能證明絕對沒有缺陷。我們能做的就是保證有效的減少缺陷的數量,並且防止已知缺陷爬回到我們的代碼中。

如何發現缺陷

大多的程序員都會面對定期查找和修改缺陷。過去,這個任務最常用的方法是在代碼中散置一些alert調用(this task was most commonly carried out bysprinkling code with alert calls),並刷新瀏覽器監查變量的值,或者觀察哪裏出現了期望的流和腳本期望的流的一致(or to observewhere the expected flow of a script diverged from the expected flow)。

如今,大多瀏覽器都內建一個強大的控制檯。那也不容易獲得一個像Firebug Lite一樣有用的工具。調試過程幾乎都是一樣的:在代碼散置console.log調用,刷新瀏覽器,觀察實際行爲,並和預期行爲進行人爲比較。

調試:一個例子

例如一個調試session的例子,我們來看一個jQuery插件,它期望一個元素具有一個datetime屬性(如HTML5時間元素),或一個自定義data-datetime屬性,包含一個日期字符串,用人類可讀的、和當前時間對比的內容(如3小時之前)替換元素的innerHTML

  1. <SPAN style="FONT-SIZE: 14px">1.    jQuery.fn.differenceInWords = (function () {  
  2. 2.      var units = {  
  3. 3.          second: 1000,  
  4. 4.          minute: 1000 * 60,  
  5. 5.            hour: 1000 * 60 * 60,  
  6. 6.             day: 1000 * 60 * 60 * 24,  
  7. 7.            week: 1000 * 60 * 60 * 24 * 7,  
  8. 8.           month: 1000 * 60 * 60 * 24 * 30  
  9. 9.      };  
  10. 10.    
  11. 11.     function format(num, type) {  
  12. 12.         return num + " " + type + (num > 1 ? "s" : "");  
  13. 13.     }  
  14. 14.    
  15. 15.     return function () {  
  16. 16.         this.each(function () {  
  17. 17.             var datetime = this.getAttribute("datetime") ||  
  18. 18.                              this.getAttribute("data-datetime");  
  19. 19.             var diff = new Date(datetime) - new Date();  
  20. 20.    
  21. 21.             if (diff > units.month) {  
  22. 22.                 this.innerHTML = "more than a month ago";  
  23. 23.             } else if (diff > units.week) {  
  24. 24.                 this.innerHTML = format(Math.floor(diff / units.week), "week") + " ago";  
  25. 25.             } else {  
  26. 26.                 var pieces = [], num, consider = ["day""hour""minute""second"], measure;  
  27. 27.    
  28. 28.                 for (var i = 0, l = consider.length; i < l; ++i) {  
  29. 29.                     measure = units[consider[i]];  
  30. 30.    
  31. 31.                     if (diff > measure) {  
  32. 32.                         num = Math.floor(diff / measure);  
  33. 33.                         pieces.push(format(num, consider[i]));  
  34. 34.                     }  
  35. 35.                 }  
  36. 36.    
  37. 37.                 this.innerHTML = (pieces.length == 1 ? pieces[0] :  
  38. 38.                                   pieces.slice(0, pieces.length - 1).join(", ") + " and " +  
  39. 39.                                   pieces[pieces.length - 1]) + " ago";  
  40. 40.             }  
  41. 41.         });  
  42. 42.     };  
  43. 43. }());  
  44. </SPAN>  
1.	jQuery.fn.differenceInWords = (function () {
2.	    var units = {
3.	        second: 1000,
4.	        minute: 1000 * 60,
5.	          hour: 1000 * 60 * 60,
6.	           day: 1000 * 60 * 60 * 24,
7.	          week: 1000 * 60 * 60 * 24 * 7,
8.	         month: 1000 * 60 * 60 * 24 * 30
9.	    };
10.	 
11.	    function format(num, type) {
12.	        return num + " " + type + (num > 1 ? "s" : "");
13.	    }
14.	 
15.	    return function () {
16.	        this.each(function () {
17.	            var datetime = this.getAttribute("datetime") ||
18.	                             this.getAttribute("data-datetime");
19.	            var diff = new Date(datetime) - new Date();
20.	 
21.	            if (diff > units.month) {
22.	                this.innerHTML = "more than a month ago";
23.	            } else if (diff > units.week) {
24.	                this.innerHTML = format(Math.floor(diff / units.week), "week") + " ago";
25.	            } else {
26.	                var pieces = [], num, consider = ["day", "hour", "minute", "second"], measure;
27.	 
28.	                for (var i = 0, l = consider.length; i < l; ++i) {
29.	                    measure = units[consider[i]];
30.	 
31.	                    if (diff > measure) {
32.	                        num = Math.floor(diff / measure);
33.	                        pieces.push(format(num, consider[i]));
34.	                    }
35.	                }
36.	 
37.	                this.innerHTML = (pieces.length == 1 ? pieces[0] :
38.	                                  pieces.slice(0, pieces.length - 1).join(", ") + " and " +
39.	                                  pieces[pieces.length - 1]) + " ago";
40.	            }
41.	        });
42.	    };
43.	}());

該代碼首先處理兩種特殊的情況:差值大於一個月表示成“超過一個月(more than a month)”,差值大於一個星期時顯示星期數。函數然後差值收集準確的天數、小時數和秒數。當差值小於一天時,忽略天數,依此類推。

代碼看上去很合理,但是使用它時,馬上就會發布有些不太對勁。"Humanizing"一個8天的日期,返回“"and undefined”。使用console.log調試策略,我們應該開始並記錄初始的中間值來判定什麼錯了。如記錄初始差值提醒我們實際得到的順序是錯誤的。好了,我們修正它:

  1. <SPAN style="FONT-SIZE: 14px">1.    var diff = new Date(datetime.replace(/\+.*/, "")) - new Date();</SPAN>  
1.	var diff = new Date(datetime.replace(/\+.*/, "")) - new Date();

得到正確差值解決了問題,我們現在得到了我們期望的“"1 week ago”。然後我們把這個插件放到產品中並期望它作爲產品的一部分也能工作的很好(So we toss theplugin into production and keep happily hacking on some other part of theapplication.)。

第二天,有人溫和的通知我們那個“三天,80小時,4854分鐘和291277秒”("3 days, 80 hours, 4854minutes and 291277 seconds" )是不可接受的時間戳格式。結果我們在測試日期小於一週時失敗了。鍵入console.log,我們亂丟包括記錄語句的代碼(可能可能引入一些我們剛剛清除的記錄語句)最後發現剩下的差值不應該每次都重新計算:

  1. <SPAN style="FONT-SIZE: 14px">1.    if (diff > measure) {  
  2. 2.      num = Math.floor(diff / measure);  
  3. 3.      diff = diff - (num * measure); // BUG: This was missing in our first attempt  
  4. 4.      pieces.push(format(num, consider[i]));  
  5. 5.  }  
  6. </SPAN>  
1.	if (diff > measure) {
2.	    num = Math.floor(diff / measure);
3.	    diff = diff - (num * measure); // BUG: This was missing in our first attempt
4.	    pieces.push(format(num, consider[i]));
5.	}

一旦我們定位並且修正該故障,我們清除所有的console.log調用,避免代碼在沒有定義console對象的瀏覽器中崩潰。

分步調試器

Firebug和類似的工具使調試javascript比過去更容易。但是很多人似乎認爲console.log是比原始的alert更高級的工具。的確,console不阻塞UI並且更少可能讓你強制關閉瀏覽器,但是console.log調試和alert調試是一樣的優雅或不優雅。

一個稍微複雜的方法是使用像Firebug一樣的工具使分步調試成爲可能。


使用分步調試,你可以通過設置一些斷點和檢查所有有效值而不是記錄每個你想查看的變量的值來節省一些時間。


Console.log的問題

Console.log風格調試有一些問題:首先,console.log有討厭的引入自身缺陷的風險。如果在演示或部署之前忘記移除最後記錄語句,你知道我在說什麼。懸浮的記錄語句會使你的代碼在不支持console對象的瀏覽器上崩潰,包括Firebug不可用時的火狐。“但是JavaScript是動態的”,我聽到你說,“你可以定義你自己的無操作的console,然後問題就會消除”。的確,你可以這樣做,但那就像是用刷漆解決你的汽車生鏽的問題。

如果懸浮的console.log調用是不可接受的,我們立即認識到下一個問題:它是不可重複的。一旦調試會話結束,你去除了所有的記錄語句。如果(當)新問題出現在代碼的相同部分時,你又回到了起點,重新採用巧妙的記錄語句。分步調試也同樣是暫時的。特設調試(Adhoc debugging)是費時的、容易出錯的和不可重複的。

更有效的發現缺陷

單元測試是查找缺陷和驗證正確性的方法,並且不需要面對調試器臨時性和人爲console.log/alert調試。單元測試還有其他大量的優勢,我將通過這篇文章介紹。

什麼是單元測試

單元測試是你的產品代碼按照預期結果的可執行部分。例如,假如我們之前在 jQuery.fn.differenceInWords中發現有兩個錯誤沒有修正,並試圖用單元測試找到它們:

  1. 1.  var second = 1000;  
  2. 2.  var minute = 60 * second;  
  3. 3.  var hour = 60 * minute;  
  4. 4.  var day = 24 * hour;  
  5. 5.     
  6. 6.  try {  
  7. 7.      // Test that 8 day difference results in "1 week ago"  
  8. 8.      var dateStr = new Date(new Date() - 8 * day).toString();  
  9. 9.      var element = jQuery('Replace me');  
  10. 10.     element.differenceInWords();  
  11. 11.    
  12. 12.     if (element.text() != "1 week ago") {  
  13. 13.         throw new Error("8 day difference expected\n'1 week ago' got\n'"+  
  14. 14.                         element.text() + "'");  
  15. 15.     }  
  16. 16.    
  17. 17.     // Test a shorter date  
  18. 18.     var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;  
  19. 19.     dateStr = new Date(new Date() - diff).toString();  
  20. 20.     var element = jQuery('Replace me');  
  21. 21.     element.differenceInWords();  
  22. 22.    
  23. 23.     if (element.text() != "3 days, 2 hours, 16 minutes and 10 seconds ago") {  
  24. 24.         throw new Error("Small date difference expected\n" +  
  25. 25.                         "'3 days, 2 hours, 16 minutes and 10 seconds ago' " +  
  26. 26.                         "got\n'" + element.text() + "'");  
  27. 27.     }  
  28. 28.    
  29. 29.     alert("All tests OK!");  
  30. 30. } catch (e) {  
  31. 31.     alert("Assertion failed: " + e.message);  
  32. 32. }  
1.	var second = 1000;
2.	var minute = 60 * second;
3.	var hour = 60 * minute;
4.	var day = 24 * hour;
5.	 
6.	try {
7.	    // Test that 8 day difference results in "1 week ago"
8.	    var dateStr = new Date(new Date() - 8 * day).toString();
9.	    var element = jQuery('Replace me');
10.	    element.differenceInWords();
11.	 
12.	    if (element.text() != "1 week ago") {
13.	        throw new Error("8 day difference expected\n'1 week ago' got\n'"+
14.	                        element.text() + "'");
15.	    }
16.	 
17.	    // Test a shorter date
18.	    var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;
19.	    dateStr = new Date(new Date() - diff).toString();
20.	    var element = jQuery('Replace me');
21.	    element.differenceInWords();
22.	 
23.	    if (element.text() != "3 days, 2 hours, 16 minutes and 10 seconds ago") {
24.	        throw new Error("Small date difference expected\n" +
25.	                        "'3 days, 2 hours, 16 minutes and 10 seconds ago' " +
26.	                        "got\n'" + element.text() + "'");
27.	    }
28.	 
29.	    alert("All tests OK!");
30.	} catch (e) {
31.	    alert("Assertion failed: " + e.message);
32.	}

上面的測試用例處理具有已知具有時間屬性的元素,並在得到的人性化的結果字符串不是我們期望的結果時拋出異常。該代碼可以保存到獨立的文件或在加載該插件的頁面中包含。在一個瀏覽器中運行會立即讓我得到“所有測試正常”或一個指示什麼錯了的消息。

用這種方法調試你的代碼好像很笨拙。我們不僅要寫記錄語句來幫助我們監測代碼,而且我們還不得不用程序創建元素和通過插件運行它們來驗證產生的文本。但這種方法有相當多的好處:

  •  該測試可以在任何時間,任何瀏覽器上重複運行。
  •  無論什麼時間當我們改變代碼,我們都要記得運行該測試,它可以極大的保證同樣的缺陷不會重新回來。
  •  適當的清理,這些測試提供了代碼的文檔。
  •  測試是自我檢查的。無論我們添加了多少測試,我們仍然只有一個頁面來驗證是否有錯誤。
  •  測試和產品代碼沒有衝突,因此不會在作爲產品代碼的部分發布時帶入內部alert和console.log調用的風險。

寫該測試帶來稍多的初始化效果,但我們只寫一次,我們很快的會在下次需要調試同樣的代碼時節省時間。

使用單元測試框架

剛纔我們寫的測試包含相當多的套路。幸運的是,已經有很多的測試框架來幫助我們。使用測試框架讓我們減少不得不嵌入到測試中的測試邏輯的數量,它進而也減少測試自身的缺陷。框架也可以給我們更多的自動測試和顯示結果的選項。


斷言

斷言是一個特殊的方法,它執行對它的參數給定的驗證,或標識一個錯誤(通常拋出一個類似AssertionError 異常),或什麼也不做。最簡單的斷言是它期望參數是“真”。斷言通常也可接受一個在失敗時用於顯示的消息。

  1. 1.  assert("Small date difference expected\n '3 days, 2 hours, 16 minutes and " +  
  2. 2.         "10 seconds ago' got\n'" + element.text() + "'",  
  3. 3.         element.text() == "3 days, 2 hours, 16 minutes and 10 seconds ago");  
1.	assert("Small date difference expected\n '3 days, 2 hours, 16 minutes and " +
2.	       "10 seconds ago' got\n'" + element.text() + "'",
3.	       element.text() == "3 days, 2 hours, 16 minutes and 10 seconds ago");

斷言以第一個參數爲消息。這個主意是要首先說明你的預期,而斷言像是用消息來說明(原文:The idea is thattesting is about stating your expectations upfront, and the assertion resemblesa specification with the leading message.)。
你的全部需要通常像上面那個簡單斷言就能滿足,大多的測試框架都附帶選擇自定義斷言的機會。上面我們真正做的就是驗證計算值與預期值的對比。絕大多數的測試框架都針對這種情況提供多種重載的assertEquals。(Most testframeworks have something along the lines of assertEquals for thisspecific use case.

  1. 1. assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago", element.text());  
1. assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago", element.text());

注意我們不再指定一個說明。assertEquals 知道我們期望是第二個計算的值和第一個值相等,所以它可以爲我們生成一個適當的消息。

測試用例,setUp  tearDown

在我們手工的單元測試中,我們有兩個獨立的測試。當使用測試框架時,通常在一個測試用例中指定爲獨立的函數。一個測試用例是一組測試相關功能的測試的集合。爲了使測試報告更容易查看,測試用例通常都有一個名字。下面的例子使用JsTestDriver測試用例來組織前面我們手工的單元測試。
  1. 1.  var second = 1000;  
  2. 2.  var minute = 60 * second;  
  3. 3.  var hour = 60 * minute;  
  4. 4.  var day = 24 * hour;  
  5. 5.     
  6. 6.  TestCase("TimeDifferenceInWordsTest", {  
  7. 7.      "test 8 day difference should result in '1 week ago'"function () {  
  8. 8.          var dateStr = new Date(new Date() - 8 * day).toString();  
  9. 9.          var element = jQuery('Replace me');  
  10. 10.         element.differenceInWords();  
  11. 11.    
  12. 12.         assertEquals("1 week ago", element.text());  
  13. 13.     },  
  14. 14.    
  15. 15.     "test should display difference with days, hours, minutes and seconds"function () {  
  16. 16.         var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;  
  17. 17.         dateStr = new Date(new Date() - diff).toString();  
  18. 18.         var element = jQuery('Replace me');  
  19. 19.         element.differenceInWords();  
  20. 20.    
  21. 21.         assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago", element.text());  
  22. 22.     }  
  23. 23. });  
1.	var second = 1000;
2.	var minute = 60 * second;
3.	var hour = 60 * minute;
4.	var day = 24 * hour;
5.	 
6.	TestCase("TimeDifferenceInWordsTest", {
7.	    "test 8 day difference should result in '1 week ago'": function () {
8.	        var dateStr = new Date(new Date() - 8 * day).toString();
9.	        var element = jQuery('Replace me');
10.	        element.differenceInWords();
11.	 
12.	        assertEquals("1 week ago", element.text());
13.	    },
14.	 
15.	    "test should display difference with days, hours, minutes and seconds": function () {
16.	        var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;
17.	        dateStr = new Date(new Date() - diff).toString();
18.	        var element = jQuery('Replace me');
19.	        element.differenceInWords();
20.	 
21.	        assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago", element.text());
22.	    }
23.	});
每個測試之前的註釋都轉換爲測試函數的名稱,比較轉換爲斷言。我們甚至可以通過把創建日期對象提取到一個特定的setUp方法調用中來使每個測試更整潔,setUp會在每個測試函數執行之前調用。
  1. 1.  TestCase("TimeDifferenceInWordsTest", {  
  2. 2.      setUp: function () {  
  3. 3.          this.date8DaysAgo = new Date(new Date() - 8 * day);  
  4. 4.          var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;  
  5. 5.          this.date3DaysAgo = new Date(new Date() - diff);  
  6. 6.      },  
  7. 7.     
  8. 8.      "test 8 day difference should result in '1 week ago'"function () {  
  9. 9.          var element = jQuery('Replace me');  
  10. 10.         element.differenceInWords();  
  11. 11.    
  12. 12.         assertEquals("1 week ago", element.text());  
  13. 13.     },  
  14. 14.    
  15. 15.     "test should display difference with days, hours, minutes and seconds"function () {  
  16. 16.         var element = jQuery('Replace me');  
  17. 17.         element.differenceInWords();  
  18. 18.    
  19. 19.         assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago", element.text());  
  20. 20.     }  
  21. 21. });  
1.	TestCase("TimeDifferenceInWordsTest", {
2.	    setUp: function () {
3.	        this.date8DaysAgo = new Date(new Date() - 8 * day);
4.	        var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;
5.	        this.date3DaysAgo = new Date(new Date() - diff);
6.	    },
7.	 
8.	    "test 8 day difference should result in '1 week ago'": function () {
9.	        var element = jQuery('Replace me');
10.	        element.differenceInWords();
11.	 
12.	        assertEquals("1 week ago", element.text());
13.	    },
14.	 
15.	    "test should display difference with days, hours, minutes and seconds": function () {
16.	        var element = jQuery('Replace me');
17.	        element.differenceInWords();
18.	 
19.	        assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago", element.text());
20.	    }
21.	});

setUp 方法還有一個對應的tearDown 方法,在每個測試之後執行。這個例子不需要tearDown 方法,但你可以在任何你需要在每個測試之後執行清理時創建一個tearDown 。假想你測試使用localStorage實現緩存一些數據的代碼。爲了防止測試相互之間干涉,你可能想在每個測試之後清除寫進localStorage 中的所有數據。

另外,對代碼和測試,你需要指定某種實際運行測試方法。大多的JavaScript單元測試框架需要一個簡單的HTML文件來按正確的順序加載正確的文件(包括測試框架自身)。這個HTML文件然後可以加載到瀏覽器中。通常所有的測試通過爲綠色,有失敗的測試時轉爲有威脅的紅色。

自動化,自動化,自動化

通過把基於日誌的調試工作轉到單元測試,我們確信我們的經驗是重複的和自驗證的。這樣做可節省花費大量的手工勞動,但還有改善的餘地。在瀏覽器中運行包含測試的HTML文件是相當無關痛癢的,但如你注意到的,今天web開發不能在一個瀏覽器中簡單測試就完事。依據你的環境,你可能不得不在至少在3個以上平臺的5個以上的瀏覽器的2個以上最新版本上測試。突然,運行那個HTML文件也是有一點工作量的。

如前所述,上面的測試用例對象是爲JsTestDriver寫的,一個從谷歌出來的Javascript測試框架和測試運行器。把JsTestDriver從(產品)包中分離出來纔是運行測試的正路。相比於標準的HTML文件加載源和測試,JsTestDriver運行一個可以幫你同時立即在多個瀏覽器上執行測試的服務器。理解它是如何工作的最有效的辦法就是看清的行動。

假設該jQuery插件在src/difference_in_words.jquery.js,測試用例在test/difference_in_words_test.js。爲了運行這個測試,我們在項目的根目錄添加一個配置文件jsTestDriver.conf。它包括下面的內容:

  1. 1.  server: http://localhost:4224  
  2. 2.     
  3. 3.  load:  
  4. 4.    - src/*.js  
  5. 5.    - test/*.js  
1.	server: http://localhost:4224
2.	 
3.	load:
4.	  - src/*.js
5.	  - test/*.js
現在下載JsTestDriver.jar的最新版。同時需要安裝Java。然後在命令行中執行如下命令(如果是Windows,就是cmd.exe):

1.      java -jar JsTestDriver-1.2.2.jar --port 4224

現在你就已經在你機器上打開了一個JsTestDriver服務器。下一步是打開一個鏈接爲http://localhost:4224/capture瀏覽器,它讓瀏覽器轉入懶測試運行從屬(which will turn the browser into an idle test runningslave)。在你所有能用的瀏覽器上做同樣的事。然後打開一個命令行,cd進入項目目錄並鍵入:

java -jar JsTestDriver-1.2.2.jar --tests all

很快你應該能夠看到一些輸出:JsTestDriver在所有可用瀏覽器上運行的兩個測試,並顯示是否通過。恭喜你,你已經在多個瀏覽器上自動測試了!如果你的機器可以通過網絡使用其他設備訪問,你也可以使用這個服務器測試其他平臺(OS X, Windows,Linux),你的iPhone, Android電話和其他移動設備。並且你只要在一個命令行就可以全部驗證它們。多麼令人激動呀!

JsTestDriver不是你自動化測試的唯一選擇。如果你不喜歡它的斷言框架,你也可以運行用QUnitYUI Test  Jasmine寫的測試。另外,雅虎YETI,一個只對YUI的類似的工具, Nicholas Zakas最近發佈了YUI TestStandalone,包括了基於SeleniumWeb Driver的類似的運行器。

可測試性:用測試改善你的代碼

現在,你可能希望開始實現大量節省時間的單元測試就可以了,特別是對通常預期在多個環境運行得很好的JavaScript。單元測試不僅相比手工調試和猴子補丁(monkey patching)節省大量的時間,而且可以提高你的信心、快樂和生產力。

現在已經決定開始寫單元測試,你可能想知道如何開始。明顯的答案是爲現有的代碼寫一些測試。不幸的是,那結果往往是現實很困難。部分原因是寫測試需要實踐,而且前幾個(測試)通常很難正確,甚至只是輸入(正確)。然而,爲什麼爲現有的代碼寫測試很困難常常還有另外一個原因:代碼不是和測試思想一起寫的,通常不是很測試友好的。

可測試性的例子:計算時間差

“可測試性”是特定接口的測試友好方面的度量。一個測試友好的接口使所有對它關注的部分能方便的從外部存取,不需要爲測試任何一個給定部分的API而建立無關的狀態。換句話說,可測試性是和良好設計有關的,鬆耦合、高內聚的,這只是花哨方法說對象不應該依賴於太多其他對象並且每個對象/函數只做好一件事。

作爲一個可測試性的例子,我們再來看我們的jQuery插件。在前兩個單元測試中,我們希望確保對8天前的日期使用插件,結果是字符串“1 weekago”,並且另一個日期的結果是一個更詳細的字符串表示。注意,這兩個測試沒有任何DOM元素操作,雖然我們不得不創建一個對象以測試日期差計算和人類友好的描述字符串。

這個jQuery插件明顯比它本來難以測試,主要原因是它做了不止一件事情:計算日期差,生成兩個日期差的人類易讀的描述,並且從DOM節點的innerHTML抓取日期和更新它 

要解決這個問題,考慮下面的代碼,它是同樣的插件的另一種實現:

  1. 1.  var dateUtil = {};  
  2. 2.     
  3. 3.  (function () {  
  4. 4.      var units = {  
  5. 5.          second: 1000,  
  6. 6.          minute: 1000 * 60,  
  7. 7.            hour: 1000 * 60 * 60,  
  8. 8.             day: 1000 * 60 * 60 * 24,  
  9. 9.            week: 1000 * 60 * 60 * 24 * 7,  
  10. 10.          month: 1000 * 60 * 60 * 24 * 30  
  11. 11.     };  
  12. 12.    
  13. 13.     function format(num, type) {  
  14. 14.         return num + " " + type + (num > 1 ? "s" : "");  
  15. 15.     }  
  16. 16.    
  17. 17.     dateUtil.differenceInWords = function (date) {  
  18. 18.         // return correct string   
  19. 19.     };  
  20. 20.    
  21. 21.     jQuery.fn.differenceInWords = function () {  
  22. 22.         this.each(function () {  
  23. 23.             var datetime = this.getAttribute("datetime");  
  24. 24.             this.innerHTML = dateUtil.differenceInWords(new Date(datetime));  
  25. 25.         });  
  26. 26.     };  
  27. 27. }());  
1.	var dateUtil = {};
2.	 
3.	(function () {
4.	    var units = {
5.	        second: 1000,
6.	        minute: 1000 * 60,
7.	          hour: 1000 * 60 * 60,
8.	           day: 1000 * 60 * 60 * 24,
9.	          week: 1000 * 60 * 60 * 24 * 7,
10.	         month: 1000 * 60 * 60 * 24 * 30
11.	    };
12.	 
13.	    function format(num, type) {
14.	        return num + " " + type + (num > 1 ? "s" : "");
15.	    }
16.	 
17.	    dateUtil.differenceInWords = function (date) {
18.	        // return correct string
19.	    };
20.	 
21.	    jQuery.fn.differenceInWords = function () {
22.	        this.each(function () {
23.	            var datetime = this.getAttribute("datetime");
24.	            this.innerHTML = dateUtil.differenceInWords(new Date(datetime));
25.	        });
26.	    };
27.	}());
和前面的代碼相同,只是重新整理了。現在有兩個公開函數:jQuery插件和新的接受一個日期並返回一個人類可讀的描述多長時間之前的一個字符串的dateUtil.differenceInWords。還不完美,但我們已經把它分成了兩個關注點。現在jQuery插件負責用人性化的字符串替換元素的innerHTML ,而新函數負責計算成正確的字符串。雖然舊的測試仍然能通過,但測試應該針對新接口簡化。
  1. 1.  TestCase("TimeDifferenceInWordsTest", {  
  2. 2.      setUp: function () {  
  3. 3.          this.date8DaysAgo = new Date(new Date() - 8 * day);  
  4. 4.          var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;  
  5. 5.          this.date3DaysAgo = new Date(new Date() - diff);  
  6. 6.      },  
  7. 7.     
  8. 8.      "test 8 day difference should result in '1 week ago'"function () {  
  9. 9.          assertEquals("1 week ago", dateUtil.differenceInWords(this.date8DaysAgo));  
  10. 10.     },  
  11. 11.    
  12. 12.     "test should display difference with days, hours, minutes and seconds"function () {  
  13. 13.         assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago",  
  14. 14.                      dateUtil.differenceInWords(this.date3DaysAgo));  
  15. 15.     }  
  16. 16. });  
1.	TestCase("TimeDifferenceInWordsTest", {
2.	    setUp: function () {
3.	        this.date8DaysAgo = new Date(new Date() - 8 * day);
4.	        var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;
5.	        this.date3DaysAgo = new Date(new Date() - diff);
6.	    },
7.	 
8.	    "test 8 day difference should result in '1 week ago'": function () {
9.	        assertEquals("1 week ago", dateUtil.differenceInWords(this.date8DaysAgo));
10.	    },
11.	 
12.	    "test should display difference with days, hours, minutes and seconds": function () {
13.	        assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago",
14.	                     dateUtil.differenceInWords(this.date3DaysAgo));
15.	    }
16.	});

現在,在我們的測試中沒有了DOM元素,而我們能更有效的測試生成正確字符串的邏輯。同樣的,測試這個jQuery插件的問題是確信文本內容被替換。

爲什麼爲測試而修改代碼?

每次我向別人介紹測試和解釋可測試性的概念,總是聽到關於“難道你不僅讓我用更多的時間寫這些測試,而且我還得爲了測試改變我的代碼嗎?”的說詞。

來看我們剛纔爲人性化時間差而做的改變。改變是爲了方便測試的目的,但你能說只有測試受益嗎?恰恰相反,改變使代碼更易於分離無關行爲。現在,如果我們晚點決定執行如Twitter反饋到我們的頁面,我們能直接使用時間戳調用differenceInWords 函數,而不是通過DOM元素和jQuery插件的笨拙的路線(Now, if we later decide to implement e.g. aTwitter feed on our pages, we can use the differenceInWords functiondirectly with the timestamp rather than going the clumsy route via a DOMelement and the jQuery plugin.)。可測試性是良好設計的固有特性。當然,你可以有可測試性和不好的設計,但你不能有一個良好的設計而不具有可測試性。考慮作爲一個小例子的情況的測試你的代碼的例子如果測試很困難,也就意味着使用代碼很困難。

先寫測試:測試驅動開發

當你在現有的代碼中使用單元測試時,最大的挑戰是可測試性問題。爲了持續提高我們的工作流程,我們能做什麼?這引出了一個讓可測試性直接進入產品代碼靈魂的萬無一失的方法是先寫測試

測試驅動開發(TDD)是一個開發過程,它由一些小迭代組成,並且每個迭代通常由測試開始。直到有一個失敗的單元測試需要,否則不寫產品代碼。TDD使你關注行爲,而不是你下一步需要什麼代碼。

比方說,我們被告知那個計算時間差的jQuery插件需要計算任意兩個時間的差,而不只是和當前時間的差值。你如何使用TDD解決這個問題?好了,第一個擴展是提供用於比較的第二個日期參數:

  1. 1.  "test should accept date to compare to"function () {  
  2. 2.      var compareTo = new Date(2010, 1, 3);  
  3. 3.      var date = new Date(compareTo.getTime() - 24 * 60 * 60 * 1000);  
  4. 4.     
  5. 5.      assertEquals("24 hours ago", dateUtil.differenceInWords(date, compareTo));  
  6. 6.  }  
1.	"test should accept date to compare to": function () {
2.	    var compareTo = new Date(2010, 1, 3);
3.	    var date = new Date(compareTo.getTime() - 24 * 60 * 60 * 1000);
4.	 
5.	    assertEquals("24 hours ago", dateUtil.differenceInWords(date, compareTo));
6.	}

這個測試假想該方法已經接受兩個參數,並預期當比較兩個傳過去日期恰好有24小時的差別時,結果字符串爲"24 hours ago"。運行該測試不出所料的提示它不能工作。爲讓測試通過,我們不得不爲該函數添加第二個可選參數,同時確保沒有改變函數使現有的測試失敗。下面是一個實現的方法:

  1. 1.  dateUtil.differenceInWords = function (date, compareTo) {  
  2. 2.      compareTo = compareTo || new Date();  
  3. 3.      var diff = compareTo - date;  
  4. 4.     
  5. 5.      // ...   
  6. 6.  };  
1.	dateUtil.differenceInWords = function (date, compareTo) {
2.	    compareTo = compareTo || new Date();
3.	    var diff = compareTo - date;
4.	 
5.	    // ...
6.	};

所有的測試都通過了,說明新的和原來的需求都得到滿足了。

現在我們接受兩個日期,我們可能希望方法能描述的時間差是過去或將來。我們先用另一個測試來描述這個行爲:

  1. 1.  "test should humanize differences into the future"function () {  
  2. 2.      var compareTo = new Date();  
  3. 3.      var date = new Date(compareTo.getTime() + 24 * 60 * 60 * 1000);  
  4. 4.     
  5. 5.      assertEquals("in 24 hours", dateUtil.differenceInWords(date, compareTo));  
  6. 6.  }  
1.	"test should humanize differences into the future": function () {
2.	    var compareTo = new Date();
3.	    var date = new Date(compareTo.getTime() + 24 * 60 * 60 * 1000);
4.	 
5.	    assertEquals("in 24 hours", dateUtil.differenceInWords(date, compareTo));
6.	}
讓這個測試通過需要一些工作量。幸運的是,我們的測試已經覆蓋(部分)我們之前的要求。(兩個單元測試很難構成良好的覆蓋,但假想我們已經有針對該方法的完整的測試套件)。一個強大的測試套件讓我們不害怕改變代碼,如果我們打破它了,我們知道會得到告警。我的最終實現是這樣的:
  1. 1.  dateUtil.differenceInWords = function (date, compareTo) {  
  2. 2.      compareTo = compareTo || new Date();  
  3. 3.      var diff = compareTo - date;  
  4. 4.      var future = diff < 0;  
  5. 5.      diff = Math.abs(diff);  
  6. 6.      var humanized;  
  7. 7.     
  8. 8.      if (diff > units.month) {  
  9. 9.          humanized = "more than a month";  
  10. 10.     } else if (diff > units.week) {  
  11. 11.         humanized = format(Math.floor(diff / units.week), "week");  
  12. 12.     } else {  
  13. 13.         var pieces = [], num, consider = ["day""hour""minute""second"], measure;  
  14. 14.    
  15. 15.         for (var i = 0, l = consider.length; i < l; ++i) {  
  16. 16.             measure = units[consider[i]];  
  17. 17.    
  18. 18.             if (diff > measure) {  
  19. 19.                 num = Math.floor(diff / measure);  
  20. 20.                 diff = diff - (num * measure);  
  21. 21.                 pieces.push(format(num, consider[i]));  
  22. 22.             }  
  23. 23.         }  
  24. 24.    
  25. 25.         humanized = (pieces.length == 1 ? pieces[0] :  
  26. 26.                      pieces.slice(0, pieces.length - 1).join(", ") + " and " +  
  27. 27.                      pieces[pieces.length - 1]);  
  28. 28.     }  
  29. 29.    
  30. 30.     return future ? "in " + humanized : humanized + " ago";  
  31. 31. };  
1.	dateUtil.differenceInWords = function (date, compareTo) {
2.	    compareTo = compareTo || new Date();
3.	    var diff = compareTo - date;
4.	    var future = diff < 0;
5.	    diff = Math.abs(diff);
6.	    var humanized;
7.	 
8.	    if (diff > units.month) {
9.	        humanized = "more than a month";
10.	    } else if (diff > units.week) {
11.	        humanized = format(Math.floor(diff / units.week), "week");
12.	    } else {
13.	        var pieces = [], num, consider = ["day", "hour", "minute", "second"], measure;
14.	 
15.	        for (var i = 0, l = consider.length; i < l; ++i) {
16.	            measure = units[consider[i]];
17.	 
18.	            if (diff > measure) {
19.	                num = Math.floor(diff / measure);
20.	                diff = diff - (num * measure);
21.	                pieces.push(format(num, consider[i]));
22.	            }
23.	        }
24.	 
25.	        humanized = (pieces.length == 1 ? pieces[0] :
26.	                     pieces.slice(0, pieces.length - 1).join(", ") + " and " +
27.	                     pieces[pieces.length - 1]);
28.	    }
29.	 
30.	    return future ? "in " + humanized : humanized + " ago";
31.	};
注意,我沒有碰jQuery插件。因爲我們分離了無關的部分,我可以完全自由的修改和提升人性化字符串的方法,而不改變我的網站中jQuery使用人性化字符串的方法。

持續集成

TDD實踐中,我們需要及時的反饋。反饋來自我們的測試,這意味着測試需要運行的輕鬆快速。JsTestDriver已經使測試運行的容易而快速,但總有侷限性。限制來自多瀏覽器的形式。JsTestDriver能如你所願在多個瀏覽器上容易的運行測試,因以下兩個原因,這對TDD工作流這樣做是不便的:

  •  每一次從多個瀏覽器得到測試報告,使它更難看到發生了什麼,並失去了TDD給你帶來的便利。
  •  一些較弱的瀏覽器,而通常是重要的測試對象,是緩慢的。我的意思是慢的足以毀滅TDD流程。(And I mean slow.Slow ruins the TDD flow.

解決這個問題的一個方案是持續集成。持續集成是自動和經常進行產品質量控制的實踐。這時應該包含進來一些工具,如JsLint,而它當然應該包含運行測試。

一個持續集成(CI)服務器可以確保所有開發者的工作可以正確的組合,並且負責在指定的多個瀏覽器是執行測試。一個構建的CI服務器通常由版本控制系統觸發,如GitSubversion,並且一般提供當發現問題時給項目成員發送郵件的功能。

我最近寫了爲JsTestDriver創建Hudson CI服務器指南。使用HudsonJsTestDriver,很容易創建一個高效高質量的工作流程。對我自己而言,我基本是做什麼都是TDD,通常我在本機的Firefox上運行測試,它是我發現具有最好錯誤信息和跟蹤信息的瀏覽器。每次我完成一個功能,通常很小,我把它放到代碼庫中。這時,Hudson檢出我剛提交的變化並在廣泛的瀏覽器上運行所有的單元測試。如果有測試失敗,我會收到一個說明發生了什麼的郵件。此外,我可以隨時訪問Hudson服務器查看項目構建視圖,看個人的控制檯輸出等等。

結論:爲什麼我要關心

如果,在閱讀完這篇文章之後,你還不確信單元測試是一個很值得做的實踐,讓我們再重述一下一些常見誤解。

我使用一個庫,如jQuery,它確保我的代碼正確的工作。

Ajax庫,如jQuery,在幫助你處理跨瀏覽器問題上走了很遠。實際上,在很多情況下,這些庫完全抽象掉了所有這些討厭的DOM缺陷,甚至是核心JavaScript的差異。然而,這些庫沒有,而且不能,保護你的錯誤的應用邏輯,而單元測試可以。

測試是對專業人員的高級實踐,不適合我

我的立場是無論你認爲你寫代碼的過程是哪種方式,你都在測試它,例如,通過刷新瀏覽器來驗證是不是按它應該的方式工作。你簡單的選擇了不參與自動化和提高你的測試過程,並且在長時間運行(或不那麼長時間的運行)中,你花費時間在猛擊你的瀏覽器刷新按鈕,而我花費時間寫測試,然後我可以今天、明天或明年愉快的運行它。

像任何新技術一樣,測試需要實踐,但不需要一個“忍者”去做。測試由大量簡單的語句組成,他們使用你的代碼並對它做假設(真不好表達,原文:ests consistlargely of dirt simple statements that exercise your code and make assumptionsabout it.)。困難的部分是良好設計的代碼並確保它是可測試的。換句話說,困難的部分是提高你的編程技巧並寫之前思考你的代碼。無論是專業人員或初學者,任何人沒有原因不想提高

測試太花時間了,我只想寫產品代碼

手工和自動化測試都花時間。但是,不用花一兩個小時“評估”單元測試和/TDD,然後決定它是在浪費時間。單元測試和TDD需要的是實踐,像其他任何科目一樣。沒有辦法幾個小時內做到擅長良好的自動化測試。你需要練習,而一旦掌握,你就會認識到我這裏說的好處,並且認識到手工測試是多麼的浪費。此外,如果你寫了單元測試,並花一些時間嚴厲測試你的代碼,你會選擇什麼呢?失敗的真快,或能成功嗎?

調整你的需求

從這篇文章中你可能得到這樣的印象,我覺得各個人都應該採用我的工作方式。我沒有那種感覺。但我感覺認真對待應用的質量和正確性是很重要的,並且我認爲單元測試是實現的完整部分。(I do think thatunit testing is an integral part of that equation.)

這裏TDD更多是一個可選部分,但我的經驗告訴我,TDD極大的簡化單元測試。在我實現功能之前,它幫助我提升代碼設計,幫助我只實現那些必須實現的代碼。當然你可以採用其他的方式也很好的實現這個目標,但是對我來說,TDD是個完美的方案。

現在開始實踐吧!


About the Author

Originallya student in informatics, mathematics, and digital signal processing, ChristianJohansen has spent his professional career specializing in web and front-enddevelopment with technologies such as JavaScript, CSS, and HTML using agilepractices. A frequent open source contributor, he blogs about JavaScript, Ruby,and web development at cjohansen.no. Christian works at Gitorious.org, an open source Git hosting service.

Find Christianon:

§ Twitter - @cjno

§ Christian'sBlog

§ Christian's Book - Test-Driven JavaScriptDevelopment

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章