深入探究單元測試編寫


單元測試是確保軟件質量、抵擋BUG槍林彈雨的最基本而有效的第一道防線和盾牌。那么,如何更好地編寫單測來確保代碼質量呢?

單測覆蓋范圍###

數據訪問層dao測試####

對於使用了 ORM 或 Semi-ORM 來直接訪問數據庫的應用來說,DAO 測試是必要的,用來驗收數據訪問框架與SQL語句的聯合正確性(通過聯合正確性通常也意味着數據訪問框架及SQL語句都是OK的)。嚴格來說,由於依賴外部環境,DAO測試不能算是單測。不過考慮到數據訪問層是應用的基石,從實踐意義上最好將其歸類為必不可少的單測組成之一。

獨立方法的測試####

比如參數校驗、不依賴外部服務的業務判斷邏輯、工具方法等;獨立方法的測試采用“輸入-輸出模型”,即給定一組輸入測試數據集,調用指定方法應當生成指定的輸出數據集。 可以通過工具自動生成獨立方法的單測類文件,然后填充參數和斷言修改成真正有效的單測。

含外部服務依賴的測試####

通常會采用 Mock 框架來做相關單測。有一種技巧可以避免使用 Mock, 即:將含外部服務依賴的方法拆分為三段式: 構建參數子方法 buildParams, 調用服務子方法 callService, 構建返回結果子方法 buildResult 。 其中 buildParams,buildResult 通常是不含依賴服務的方法,可以按照獨立方法的測試來進行,而且很可能是可復用的方法,不需要寫和測試; 而 callService 是單行調用,用接口測試覆蓋更好一些。這樣就將 (3) 轉化為 (2) 。當然,這種拆分粒度很細,對於喜歡寫長方法的筒鞋可能不太適應。

另一種技巧是,可以通過函數接口來隔離外部調用依賴,通過 lambda 表達式來模擬外部依賴的返回值。詳情可參閱:“使用Java函數接口及lambda表達式隔離和模擬外部依賴更容易滴單測”

不含條件和循環分支的純順序邏輯####

通過接口用例測試來確保是正確的,不通過單測來檢驗。

單測與測試效率###

短鏈測試與長鏈測試####

如果存在一種方法,能夠不費事地很快檢測出系統中的代碼問題,那么何樂而不為呢?單測正是這樣一種測試方法。

測試可分為“短鏈測試”和“長鏈測試”。其中短鏈測試不需要依賴外部服務,僅對短小的一段代碼進行測試,寫完后幾乎不需要准備就能立即運行完成,而且當運行出錯時,幾乎可以很快定位出問題的位置;長鏈測試則在寫完后需要花費時間來准備數據、環境和外部服務,運行時間更長,而且運行出錯時,往往要花更多時間來進行調試。

單測是一種短鏈測試,而接口測試則是一種長鏈測試。顯然,如果單測覆蓋得越密集,那么就能更快速有效地覆蓋到更多代碼; 因此, 優先推薦編寫單測而非接口測試。

單測與接口測試的平衡####

過猶不及。並不是越多單測效果就越好。當單測密集度達到一定程度時,所起的質量檢測效果就會趨於平緩。這是由於: (1) 依賴外部服務或組件的完整流程是單測難以覆蓋的; (2) 性能和累積性問題是單測無法覆蓋的; (3) 有些情況下,一個接口測試可以起到十個單測的作用。 因此,適當地使用接口測試作為單測的補充,是非常重要的。

一般來說,單測用於密集覆蓋條件分支和循環分支,而接口測試用於覆蓋順序流程和完整流程。在時間比較緊迫的情形下,單測覆蓋核心方法和主要流程,其它可暫用接口測試來覆蓋。

單測與動態語言####

可以選擇動態語言能夠更加便捷地編寫測試。動態語言更好地支持 Map, List 等常見容器創建和遍歷的簡潔語法,更容易地訪問對象及其屬性與方法,更方便地操控字符串、文件、SQL等。

與Java集成比較友好的是Groovy語言。Groovy 語法類似 Python 的簡潔,又可以幾乎無縫訪問Java類庫,避免了不必要的適配和遷移工作。詳情可參閱: “使用Groovy+Spock輕松寫出更簡潔的單測”

單測框架####

JUnit 和 TestNg 是 Java 陣營的兩個不分伯仲的單測框架。不過,我所接觸的開發同學似乎更傾向於使用 TestNg , 更傾向於后起之秀。后起之秀通常有着新的設計理念和做法,比如 TestNg 推出時基於注解的測試(去掉不必要的規則)、參數化測試、 根據配置文件對測試集合分類運行、依賴測試、支持並發運行等, 對大型測試套件更友好的支持和靈活性,恐怕當時讓老牌勁旅 JUnit 大驚失色吧!

當選擇單測框架時,除了考慮實用性和新穎性,更多也要入鄉隨俗,考慮團隊成員的意願。比如使用 JUnit 編寫了一個很好的業務測試框架, 可是團隊成員都傾向於使用 TestNg , 那么, 當推廣這個業務測試框架時,就會受到不必要的阻力; 而如果使用 TestNg 開發,首先從心理上就得到了認同。

當然,也要關注更優秀的測試框架。 比如 TestNg 本來已經讓 JUnit “大驚失色”了,可是殺出個基於JUnit 框架的 Spock , 能夠用更簡潔的方式來編寫單測,那么,也可以“回歸一下”的! 事情就是這樣曲折螺旋上升式地發展的啊!

能夠將一項新技術推廣到團隊,是技術人員引以為傲的一件事兒; 即使由於種種原因未能推廣,也能從中學到新的思想和做法。嘗試新技術,是技術人員的優秀品質之一。

單測技巧###

空邊界與異常####

空、邊界、異常是三種常見的單測點,是BUG多發地。因此編寫代碼和單測時一定要注意覆蓋這三種情況;當對象為空會怎樣?涉及容器操作時,算法是否會超過邊界? 是否拋出了指定異常?

值得注意的是, 在檢測方法調用拋出異常時,在調用方法時,要加一行 fail(NOT_THROW_EXCEPTION)。 這是由於, 當方法拋出異常時,會被捕獲,然后 assert 異常的返回碼和返回消息,這是正確的一方面; 當方法應該拋出異常卻沒有拋出異常時,就應該讓單測失敗; 如果沒有 fail(NOT_THROW_EXCEPTION) 這一行, 單測就會通過。

配置式編程####

編寫單測時,最好能夠批量進行測試,將測試數據集與測試代碼分離。當新增測試用例時,只要新增測試數據集即可,測試主體代碼完全不用動。Spock 的 expect-where 子句就實現了測試用例的配置化。expect 子句是測試代碼,where 子句是測試用例集合。同樣,TestNg 的 DataProvider 也是為了實現測試用例集合與測試代碼分離。

編寫更易測試的短小方法####

前面談到,單測屬於短鏈測試,其特色就是“短、平、快”。 如果單測也要寫婆婆媽媽的一堆Mock,那么編寫單測很快就會令人厭煩、費時不討好。因此,盡可能識別和分離每個微小業務點,形成短小方法,然后再進行單測。

編寫短小方法也可以增進可擴展能力的提升。當持續不止地識別和分離每個微小業務點,就會對業務中的可變與不變更加敏感,而軟件可擴展性的本質就是預見、識別和分離可變與不變。

分離易測試的部分####

開發同學寫代碼時常常將通用性的技術邏輯與定制的業務邏輯混在一起,將獨立方法和外部依賴混在一起,這增加了編寫單測的很多困難。

如下代碼所示:


/**
* 發送消息
*/
public void sendMsg(String msg) {

   int count = 3;
   while (count > 0) {
       LogUtils.info(log, "push msg : {}", msg);
       try {
           producer.publish(msg.getBytes(IOUtil.DEFAULT_CHARSET), topicProduce);
           break;
       } catch (Exception ex) {
           LogUtils.error(log, "failed to push msg : {} count={} Reason={}", msg, count, ex.getMessage());
           LogUtils.error(log, msg, ex);
           count--;
           try {
               TimeUnit.MILLISECONDS.sleep(100);
           } catch (InterruptedException e) {
           }
       }
   }
}

明明就是把一件事重復三次(通用技術邏輯),只不過這件事正好是推送消息給中間件(定制業務邏輯),偏偏中間多了個外部依賴服務的調用,導致單測需要Mock. 我討厭寫一堆無聊的Mock語句!有沒有辦法呢?有的。只要將定制業務方法抽離出來即可。


public void sendMsg2(String msg) {

   repeat(
           (msgTosend, topicProduce) -> {
               try {
                   producer.publish(msgTosend.getBytes(IOUtil.DEFAULT_CHARSET), topicProduce);
               } catch (NSQException e) {
                   throw new RuntimeException(e);
               }
           },
           msg, topicProduce, 3
   );
}


/**
* 重復做一件事指定次數
*/
public void repeat(BiConsumer<String, String> consumer, String msg, String topicProduce, int count) {

   while (count > 0) {
       LogUtils.info(log, "call {}({}, {})", consumer.getClass().getSimpleName(), msg, topicProduce);
       try {
           consumer.accept(msg, topicProduce);
           break;
       } catch (Exception ex) {
           LogUtils.error(log, "failed to do : {} count={} Reason={}", msg, count, ex.getMessage());
           LogUtils.error(log, msg, ex);
           count--;
           try {
               TimeUnit.MILLISECONDS.sleep(100);
           } catch (InterruptedException e) {
           }
       }
   }
}

這樣, repeat 方法是可測試的; 而 sendMsg2 只是個單行調用,可以在整體流程中驗證(也可以通過Mock producer 服務做單測)。repeat 的單測如下:


@Test
public void testRepeat() {
   FunctionUtils.repeat(
           (msg, topicProducer) -> {
               throw new RuntimeException("NSQ exception msg=" + msg + " topicProducer=" + topicProducer);
           },
           "haha", "you are pretty", 3
   );
}

使用 mockito 對 sendMsg2 編寫單測得到:


import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;

/**
* Created by shuqin on 17/2/17.
*/
public class SimpleMsgPublisherTest extends BaseTester {

   SimpleMsgPublisher msgPublisher = new SimpleMsgPublisher();

   @Test
   public void testSendMsg2() throws NSQException {
       Producer mock = mock(Producer.class);
       msgPublisher.setProducer(mock);
       String msg = "biz message";
       doThrow(new NSQException("nsq exception")).
               when(mock).publish(msg.getBytes(IOUtil.DEFAULT_CHARSET), "biz_topic");
       msgPublisher.sendMsg2(msg);

       doThrow(new NSQException("nsq exception")).
               when(mock).publish(msg.getBytes(IOUtil.DEFAULT_CHARSET), null);
       msgPublisher.sendMsg2(msg);
   }

}


私有方法的單測####

一些私有方法無法直接調用,只有通過反射的方法來調用生成單測。


import java.lang.reflect.Method;

/**
* Created by shuqin on 16/11/2.
*/
public class ReflectionUtil {

   public static Object invokePrivateMethod(Class c, String methodName, Object... args) {
       Method targetMethod = getTargetMethod(c, methodName);
       return invokeMethod(c, targetMethod, null, args);
   }

   public static Object invokePrivateMethod(Object inst, String methodName, Object... args) {
       Method targetMethod = getTargetMethod(inst.getClass(), methodName);
       return invokeMethod(inst.getClass(), targetMethod, inst, args);
   }

   private static Method getTargetMethod(Class c, String methodName) {
       Method targetMethod = null;
       for (Method m: c.getDeclaredMethods()) {
           if (m.getName().equals(methodName)) {
               targetMethod = m;
           }
       }
       return targetMethod;
   }

   private static Object invokeMethod(Class c, Method targetMethod, Object inst, Object... args) {
       try {
           Object instance = inst;
           if (inst == null) {
               instance = c.newInstance();
           }
           targetMethod.setAccessible(true);
           Object result = targetMethod.invoke(instance, args);
           targetMethod.setAccessible(false);
           return result;
       } catch (Exception ex) {
           return null;
       }
   }
}

使用方法:


public void testGetParamListStr1S1861() {
   String string1 = (String)ReflectionUtil.invokePrivateMethod(methodGenerateUtils, "getParamListStr", new Object[] {  });
   Assert.assertEquals(null, string1);
}


並發的單測####

為了提高性能,應用中常常會使用線程池來並發完成一些數據拉取工作。比如


/*
* 將訂單號列表分成2000一組, 並發拉取報表數據
*/
private <T> List<T> getReportItems(List<String> rowkeyList) {
   List<String> parts = TaskUtil.divide(rowkeyList.size(), 2000);
   ExecutorService executor = Executors.newFixedThreadPool(parts.size());
   CompletionService<List<T>> completionService = new ExecutorCompletionService<List<T>>(executor);
   for (String part: parts) {
       int start = Integer.parseInt(part.split(":")[0]);
       int end = Integer.parseInt(part.split(":")[1]);
       if (end > rowkeyList.size()) {
           end = rowkeyList.size();
       }
       List<String> tmpRowkeyList = rowkeyList.subList(start, end);
       completionService.submit(new GetResultJob(tmpRowkeyList));
   }

   // 這里是先完成先加入, 不保證結果順序
   List<T> result = new ArrayList<>();
   for (int i=0; i< parts.size(); i++) {
       try {
           result.addAll(completionService.take().get());
       } catch (Exception e) {
           logger.error("error get result ", e);
       }
   }
   executor.shutdown();
   return result;
}

可以看到,這個將通用流程(並發處理)與定制業務(生成報表的任務)耦合在一起。需要先將通用流程抽取出來:


public static <T> List<T> handleConcurrently(List<String> rowkeyList, Function<List<String>, Callable> getJobFunc, int divideNumber) {
   List<String> parts = TaskUtil.divide(rowkeyList.size(), divideNumber);
   ExecutorService executor = Executors.newFixedThreadPool(parts.size());
   CompletionService<List<T>> completionService = new ExecutorCompletionService<List<T>>(executor);
   for (String part: parts) {
       int start = Integer.parseInt(part.split(":")[0]);
       int end = Integer.parseInt(part.split(":")[1]);
       if (end > rowkeyList.size()) {
           end = rowkeyList.size();
       }
       List<String> tmpRowkeyList = rowkeyList.subList(start, end);
       completionService.submit(getJobFunc.apply(tmpRowkeyList));
   }

   // 這里是先完成先加入, 不保證結果順序
   List<T> result = new ArrayList<>();
   for (int i=0; i< parts.size(); i++) {
       try {
           result.addAll(completionService.take().get());
       } catch (Exception e) {
           logger.error("error get result ", e);
       }
   }
   executor.shutdown();
   return result;
}

private <T> List<T> getReportItems2(List<String> rowkeyList) {
   return TaskUtil.handleConcurrently( rowkeyList,
           (keylist) -> new GetResultJob(keylist), 2000
   );
}

這樣,就可以針對 handleConcurrently 方法進行單測。

需要注意的是,由於並發方法執行的不確定性,並發方法存在不穩定性的可能。在編寫單測的時候,最好能提取必定能成立的契約反復執行多次驗證每次都是OK的。如下所示:


@Test
public void testHandleConcurrently() {
   for (int count=0; count < 50; count++) {
       List<String> orderNoList = new ArrayList<>();
       for (int i=0; i< 100; i++) {
           orderNoList.add("E00" + i);
       }
       List<String> result = TaskUtil.handleConcurrently(orderNoList,
               (keylist) -> new Callable() {
                   @Override
                   public Object call() throws Exception {
                       TimeUnit.MILLISECONDS.sleep(100);
                       return keylist.stream().map(orderNo -> "HAHA"+orderNo).collect(Collectors.toList());
                   }
               }, 5);
       Collections.sort(orderNoList);
       Collections.sort(result);
       Assert.assertEquals(orderNoList.size(), result.size());
       for (int i=0; i < 100; i++) {
           Assert.assertEquals("HAHA"+orderNoList.get(i), result.get(i));
       }
   }
}


自動生成單測###

對於不含外部服務依賴的獨立方法的測試,可以通過程序自動生成單測類模板,然后填入測試數據即可。自動生成單測程序通過反射機制獲取待測試類與方法的參數類型、方法簽名、返回類型,根據含有占位符的單測類模板文件自動生成可執行的最終的單測類模板源代碼。

可參閱:“輸入輸出無依賴型函數的GroovySpock單測模板的自動生成工具(上)”

小結###

單測是保障軟件質量的第一道重要防線。本文梳理和探討了單元測試的多個方面:

  • 單測的覆蓋范圍:DAO方法測試、獨立方法的測試、含外部依賴的測試;
  • 單測的測試效率:長鏈測試與鍛煉測試、單測與接口測試的平衡、單測的動態語言、單測框架選用;
  • 單測的編寫技巧:空邊界與異常處理、配置式編程、編寫短小方法、分離易測試的通用邏輯、私有方法的單測、並發的單測;
  • 自動生成單測。


免責聲明!

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



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