前言
對之前的項目進行重構,由於之前的項目中的單元測試大部分都是走走形式,對單元測試疏於管理,運行之后大部分是不通過,這樣的單元對項目而言毫無價值,更不要說有助於理解系統功能。這也使我有契機了解到TDD(測試驅動開發)的思想。為了在項目重構中編寫有效的單元測試,我查找了有關TDD的一些書籍,《單元測試的藝術》(Roy Osherove著)和《有效的單元測試》(科斯凱拉著)都是有關測試驅動開發的不錯的書籍,前者是使用.net語言,后者使用java語言,作為java程序員我自然選擇了后者。但實際上作者在闡述一種思想,不論哪種語言都可以讀懂,只是平時的習慣,對於熟悉的語言讀起來更順暢。這篇文章也是對書中的內容做一個總結。
一、單元測試代碼的可讀性
①使用更易懂的API,把你的代碼讀出來
示例:
//代碼一 String msg = “hello,World”; assertTrue(msg.indexOf(“World”)!=-1);
//代碼二 String msg = “hello,World”; assertThat(msg.contains(“World”),equals(true));
同樣斷言字符串中包含 World 這個單詞,代碼一中 使用indexOf 這個取得單詞索引位置的API就顯得間接許多,而且我們的大腦還需要對表達式進行判斷,進一步增加了認知的負擔,而contains 方法字面意思就是包含,更符合我們要表達的意思。所以一定要找到更適合易懂的API。同時用assertThat方法替代assertTrue方法,使的整個語句更具口語化,完全可以像讀文章一樣讀出來
//代碼一 public class PlatformTest { @Test public void platformBitLength(){ assertTrue(Platform.IS_32_BIT ^ Platform.IS_64_BIT); } }
//代碼二 public class PlatformTest { @Test public void platformBitLength() { assertTrue("Not 32 or 64-bit platform?", Platform.IS_32_BIT || Platform.IS_32_BIT); assertFalse("can't be 32 and 64-bit at the same time.",Platform.IS_32_BIT && Platform.IS_32_BIT); } }
代碼一 要檢查的是什么?位運算符結果怎么算?恐怕大部分使用高級語言的程序員很少會用到,這會增加我們的認知負擔。
位運算符可能會有效的執行一個程序,但單元測試的代碼可讀性優於性能,我們應該更好的表達我們的意圖,使用布爾運算符來替換位運算符可以更好的表達意圖,見示例二。
1 public void count(){ 2 Data data = project.getData(); 3 assertNotNull(data); 4 assertEquals(4,data.count()); 5 }
第三行代碼有些畫蛇添足,即使data為空,在沒有第三行代碼的情況下,測試案例依然會失敗,在IDE中雙擊失敗信息,可以快速跳轉到失敗行,並指出失敗原因。所以第三行代碼並沒有意義,這種防御性策略的真正優勢在於方法鏈中拋出空指針的時候。比如 assertEquals(4,data.getSummary().getTotal()),當此行代碼拋出空指針異常時,你無法判斷是data為空還是data.getSummary()為空,此時可以先進行assertNotNull(data)的斷言。
二、單元測試代碼的可維護性
1 //代碼一 2 public class TemplateTest(){ 3 @Test 4 public void emptyTemplate() throws Exception{ 5 String template=“”; 6 assertEquals(template,new Template(template).getType()); 7 } 8 @Test 9 public void plainTemplate() throws Exception{ 10 String template=“plaintext”; 11 assertEquals(template,new Template(template).getType()); 12 } 13 }
兩個測試方法,一個是測試建立一個空模板,另一個測試建立一個純文本模板,明顯可以發現存在結構性重復,對以上代碼進行改進,如下:
1 //代碼二 2 public class TemplateTest(){ 3 @Test 4 public void emptyTemplate() throws Exception{ 5 assertTemplateType(“”); 6 } 7 @Test 8 public void plainTemplate() throws Exception{ 9 assertTemplateType(“plaintext”); 10 } 11 private void assertTemplateType(String template){ 12 assertEquals(template,newTemplate(template).getType()) 13 } 14 }
雖然代碼行數沒有減少,甚至還多了一行,但是把相同的代碼提煉到一處,當它發生變動時只需修改一處,可維護性增強了。
1 //重構前 2 public class DictionaryTest{ 3 @Test 4 public void testDictionary() throws Exception{ 5 Dictionary dict = new Dictionary(); 6 dict.add(“A”,new Long(3)); 7 dict.add(“B”,”21”); 8 for(Iterator e = dict.iterator();e.hasNext()){ 9 Map.Entry entry = (Map.Entry) e.next(); 10 if(“A”.equals(entry.getKey())) 11 asserEquals(3L,entry.getValue()); 12 if(“B”.equals(entry.getKey())) 13 assertEquals(“21”),entry.getValue(); 14 } 15 } 16 }
顯然當Iterator為空時,測試並不會失敗,這並不符合我們單元測試的目的,進行重構后:
1 //重構后 2 public class DictionaryTest{ 3 @Test 4 public void testDictionary() throws Exception{ 5 Dictionary dict = new Dictionary(); 6 dict.add(“A”,new Long(3)); 7 dict.add(“B”,”21”); 8 assertContain(dict.iterator(),”A”,3L); 9 assertContain(dict.iterator(),”B”,21); 10 } 11 private void assertContain(Iterator i,Object key,Object value){ 12 while(i.hasNext()){ 13 Map.Entry entry = (Map.Entry)i.next(); 14 if(key.equals(entry.getKey())){ 15 assertEquals(value,entry.getValue()); 16 return; 17 } 18 } 19 fail("Iterator didn't contain "+ key); 20 } 21 }
counterAccessFromMultipleThreads 用來測試一個多線程計數器,開啟10個線程,每個線程調用計數器1000次,sleep(500),是為了讓主線程等待開啟的10個線程執行完畢
那么問題來了,如果在10毫秒內所有線程都執行完畢,豈不白白浪費了490毫秒?又或者在等待500毫秒后仍有線程沒有執行完畢,那該怎么辦?
1 @Test 2 public class counterAccessFromMultipleThreads{ 3 final Counter counter = new Counter(); 4 final int callsPerThread = 1000;//每個線程調用計數器1000次 5 final Set<Long> values = new HashSet<Long>(); 6 Runnable runnable = new Runnable(){ 7 public void run(){ 8 for(int i=0;i<callsPerThread;i++){ 9 values.add(counter.getAndIncrement()); 10 } 11 } 12 }; 13 int threads = 10;//開啟10個線程 14 for(int i=0;i<threads;i++){ 15 new Thread(runnable).start(); 16 } 17 Thread.sleep(500); 18 int exceptedNoOfValues = threads * callsPerThread; 19 assertEquals(exceptedNoOfValues ,values.size()); 20 }
改進后的測試方法:
1 public class counterAccessFromMultipleThreads{ 2 final Counter counter = new Counter(); 3 final int callsPerThread = 1000; 4 final int numberOfthreads = 10; 5 final CountDownLatch allThreadsComplete = new CountDownLatch(numberOfthreads); 6 final Set<Long> values = new HashSet<Long>(); 7 Runnable runnable = new Runnable(){ 8 public void run(){ 9 for(int i=0;i<callsPerThread;i++){ 10 values.add(counter.getAndIncrement()); 11 } 12 allThreadsComplete.countDown(); 13 } 14 }; 15 16 for(int i=0;i<numberOfthreads;i++){ 17 new Thread(runnable).start(); 18 } 19 allThreadsComplete.await(); 20 // allThreadsComplete.await(10,TimeUnit.SECONDS); 21 int exceptedNoOfValues = threads * callsPerThread; 22 assertEquals(exceptedNoOfValues ,values.size()); 23 }
等待所有線程結束后再繼續執行,有更好的辦法,java.util.concurrent 包中的CountDownLatch類完全可以勝任這項工作。
調用await方法開始阻塞,直到所有的線程都通知完成,然后繼續執行主線程代碼。也可以設置超時時間,allThreadsComplete.await(10,TimeUnit.SECONDS); 如果10秒鍾內子線程仍未執行結束,也會繼續執行主線程。
三、單元測試代碼的可維護性
①避免歧義注釋
1 /** 2 * 功能描述: 發送郵件<br> 3 * 〈功能詳細描述〉 4 * @return 5 * @see [相關類/方法](可選) 6 * @since [產品/模塊版本](可選) 7 */ 8
9 public void sendShortMessage() { 10 //todo
11 }
有時候有注釋,不如無注釋。 可以看到以上代碼的注釋為發送郵件, 但方法名卻為sendShortMessage ,明顯為發送短信的意思。這時候我們可能就會想這段代碼是要發送郵件還是要發送短信,為了弄清事實不得不去看方法體的內容。 造成這種歧義注釋的原因很多,可能之一就是發送短信的方法大致流程可能跟發送郵件相近,所以直接拷貝了郵件的代碼,改了方法的內容,卻沒有修改注釋。如果方法名足夠得當,可以不寫注釋。
②避免永不失敗的測試
下面的測試代碼檢查是否拋出期望的異常,這段代碼有什么問題?
@Test public void includeForMissingResourceFails() try{ new Environment().include("somethingthatdoesnotexist"); }catch(IOException e){ assertThat(e.getMesssage(),contians(“FileNotExist”)); }
上面的代碼測試結果如下:
1.如果代碼如期工作並拋出異常,異常會被catch代碼塊捕獲,測試通過。
2.如果代碼沒有如期工作,也就是沒有拋出異常,則方法返回,測試通過,我們並不會發現其中存在的問題。
改進測試方法:
1 try{ 2 new Environment().include(“FileNotEixst”); 3 fail(); 4 }catch(IOException e){ 5 assertThat(e.getMesssage(),contians(“FileNotExist”))}
添加fail()方法的調用,使測試起作用。除非拋出期望的異常,否則測試失敗。
四、優秀的單元測試的原則

測試驅動開發流程如上圖:在開發前先寫一個失敗的測試案例,然后寫出使測試代碼通過的生產代碼,重構優化生產代碼和測試代碼直至通過測試,然后再寫一個新的測試,循環上述過程。
當你的生產代碼寫的一團糟的時候,你很難,甚至是不可能按照優秀單元測試的原則去編寫測試代碼。比如一個測試方法要求只測試一件事情,而當生產代碼一個方法干了很多的事情,測試方法很難保證只測試一件事情,這時候只能重構生產代碼才能寫出優秀的測試
究其根本,測試驅動開發的本質是,當你的測試代碼符合模塊化、松耦合高內聚的特點時,生產代碼會自然的“被逼迫”遵守同樣的原則,從而產生良好的設計。