有效的單元測試


前言

對之前的項目進行重構,由於之前的項目中的單元測試大部分都是走走形式,對單元測試疏於管理,運行之后大部分是不通過,這樣的單元對項目而言毫無價值,更不要說有助於理解系統功能。這也使我有契機了解到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 }

雖然代碼行數沒有減少,甚至還多了一行,但是把相同的代碼提煉到一處,當它發生變動時只需修改一處,可維護性增強了。

②避免由於條件邏輯而造成的測試遺漏,存在條件邏輯時要在最后加上 fail()方法,強制測試失敗
 
 考慮一下,當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     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 }
 當沒有達到預期目的時使用 fail()方法,強制測試失敗。
 
 ③避免使用sleep方法浪費大量的測試時間

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()方法的調用,使測試起作用。除非拋出期望的異常,否則測試失敗。

四、優秀的單元測試的原則

    •少用繼承多用組合,繼承更大程度上是為了多態而非復用代碼
    •單元測試應該模塊化,每個模塊小而專注,減少反饋鏈
    •如果一個單元測試方法失敗了,那么導致它失敗的原因只有一個
    •加載外部文件時使用相對路徑而不是絕對路徑
    •對於魔法數字除了提取局部變量或常量外,可以取一個恰當的方法名,見名知義
    •好的注釋應解釋代碼現狀的緣由
  可以看出優秀的單元測試的原則跟優秀的面向對象編程的原則一致,比如少用繼承,多用組合,模塊化且模塊盡可能小,一個模塊只完成一個功能等。
 
五、BBD測試驅動開發
 
 

 

  測試驅動開發流程如上圖:在開發前先寫一個失敗的測試案例,然后寫出使測試代碼通過的生產代碼,重構優化生產代碼和測試代碼直至通過測試,然后再寫一個新的測試,循環上述過程。

  當你的生產代碼寫的一團糟的時候,你很難,甚至是不可能按照優秀單元測試的原則去編寫測試代碼。比如一個測試方法要求只測試一件事情,而當生產代碼一個方法干了很多的事情,測試方法很難保證只測試一件事情,這時候只能重構生產代碼才能寫出優秀的測試

  究其根本,測試驅動開發的本質是,當你的測試代碼符合模塊化、松耦合高內聚的特點時,生產代碼會自然的“被逼迫”遵守同樣的原則,從而產生良好的設計。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM