使用Java函數接口及lambda表達式隔離和模擬外部依賴更容易滴單測


概述###

單測是提升軟件質量的有力手段。然而,由於編程語言上的支持不力,以及一些不好的編程習慣,導致編寫單測很困難。

最容易理解最容易編寫的單測,莫過於獨立函數的單測。所謂獨立函數,就是只依賴於傳入的參數,不修改任何外部狀態的函數。指定輸入,就能確定地輸出相應的結果。運行任意次,都是一樣的。在函數式編程中,有一個特別的術語:“引用透明性”,也就是說,可以使用函數的返回值徹底地替代函數調用本身。獨立函數常見於工具類及工具方法。

不過,現實常常沒有這么美好。應用要讀取外部配置,要依賴外部服務獲取數據進行處理等,導致應用似乎無法單純地“通過固定輸入得到固定輸出”。實際上,有兩種方法可以盡可能隔離外部依賴,使得依賴於外部環境的對象方法回歸“獨立函數”的原味。
(1) 引用外部變量的函數, 將外部變量轉化為函數參數; 修改外部變量的函數,將外部變量轉化為返回值或返回對象的屬性。
(2) 借助函數接口以及lambda表達式,隔離外部服務。

隔離依賴配置###

先看一段代碼。這段代碼通過Spring讀取已有服務器列表配置,並隨機選取一個作為上傳服務器。

public class FileService {

    // ...

    @Value("${file.server}")
    private String fileServer;

    /**
     * 隨機選取上傳服務器
     * @return 上傳服務器URL
     */
    private String pickUrl(){
        String urlStr = fileServer;
        String[] urlArr = urlStr.split(",");
        int idx = rand.nextInt(2);
        return urlArr[idx].trim();
    }
}

咋一看,這段代碼也沒什么不對。可是,當編寫單測的時候,就尷尬了。 這段代碼引用了實例類FileService的實例變量 fileServer ,而這個是從配置文件讀取的。要編寫單測,得模擬整個應用啟動,將相應的配置讀取進去。可是,這段代碼無非就是從列表隨機選取服務器而已,並不需要涉及這么復雜的過程。這就是導致編寫單測困難的原因之一:輕率地引用外部實例變量或狀態,使得本來純粹的函數或方法變得不那么“純粹”了。

要更容易地編寫單測,就要盡可能消除函數中引用的外部變量,將其轉化為函數參數。進一步地,這個方法實際上跟 FileService 沒什么瓜葛,反倒更像是隨機工具方法。應該寫在 RandomUtil 里,而不是 FileService。 以下代碼顯示了改造后的結果:

public class RandomUtil {

  private RandomUtil() {}

  private static Random rand = new Random(47);

  public static String getRandomServer(String servers) {
    if (StringUtils.isBlank(servers)) {
      throw new ExportException("No server configurated.");
    }
    String[] urlArr = servers.split(",");
    int idx = rand.nextInt(2);
    return urlArr[idx].trim();
  }

}

private String pickUrl(){
        return RandomUtil.getRandomServer(fileServer);
    }
public class RandomUtilTest {

  @Test
  public void testGetRandomServer() {
    try {
      RandomUtil.getRandomServer("");
      fail("Not Throw Exception");
    } catch (ExportException ee) {
      Assert.assertEquals("No server configurated.", ee.getMessage());
    }
    String servers = "uploadServer1,uploadServer2";
    Set<String> serverSet = new HashSet<>(Arrays.asList("uploadServer1", "uploadServer2"));
    for (int i=0; i<100;i++) {
      String server = RandomUtil.getRandomServer(servers);
      Assert.assertTrue(serverSet.contains(server));
    }
  }

}

這樣的代碼並不鮮見。 引用實例類中的實例變量或狀態,是面向對象編程中的常見做法。然而,盡管面向對象是一種優秀的宏觀工程理念,在代碼處理上,卻不夠細致。而我們只要盡可能將引用實例變量的方法變成含實例變量參數的方法,就能讓單測更容易編寫。

隔離依賴服務###

一個分頁例子####

先看代碼。這是一段很常見的分頁代碼。根據一個查詢條件,獲取對象列表和總數,返回給前端。

    @RequestMapping(value = "/searchForSelect")
    @ResponseBody
    public Map<String, Object> searchForSelect(@RequestParam(value = "k", required = false) String title,
                                               @RequestParam(value = "page", defaultValue = "1") Integer page,
                                               @RequestParam(value = "rows", defaultValue = "10") Integer pageSize) {
        CreativeQuery query = new CreativeQuery();
        query.setTitle(title);
        query.setPageNum(page);
        query.setPageSize(pageSize);
        List<CreativeDO> creativeDTOs = creativeService.search(query);
        Integer total = creativeService.count(query);
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("rows", (null == creativeDTOs) ? new ArrayList<CreativeDO>() : creativeDTOs);
        map.put("total", (null == total) ? 0 : total);
        return map;
    }

要編寫這個函數的單測,你需要 mock creativeService。對,mock 的目的實際上只是為了拿到模擬的 creativeDTOs 和 total 值,然后塞入 map。 最后驗證 map 里是否有 rows 和 total 兩個 key 以及值是否正確。

我討厭 mock !引入一堆繁重的東西,mock 的代碼並不比實際的產品代碼少,而且很無聊 ! 對於懶惰的人來說,寫更多跟產品和測試“沒關系”的代碼就是懲罰!有沒有辦法呢? 實際上,可以采用函數接口來隔離這些外部依賴服務。 見如下改寫后的代碼: getListFunc 表達了如何根據 CreativeQuery 得到 CreativeDO 的列表, getTotalFunc 表達了如何根據 CreativeQuery 得到 CreativeDO 的總數。 原來的 searchForSelect 方法只要傳入兩個 lambda 表達式即可。

public Map<String, Object> searchForSelect(@RequestParam(value = "k", required = false) String title,
                                               @RequestParam(value = "page", defaultValue = "1") Integer page,
                                               @RequestParam(value = "rows", defaultValue = "10") Integer pageSize) {
        CreativeQuery query = buildCreativeQuery(title, page, pageSize);
        return searchForSelect2(query,
                               (q) -> creativeService.search(q),
                               (q) -> creativeService.count(q));
    }

    public Map<String, Object> searchForSelect2(CreativeQuery query,
                                               Function<CreativeQuery, List<CreativeDO>> getListFunc,
                                               Function<CreativeQuery, Integer> getTotalFunc) {
        List<CreativeDO> creativeDTOs = getListFunc.apply(query);
        Integer total = getTotalFunc.apply(query);
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("rows", (null == creativeDTOs) ? new ArrayList<CreativeDO>() : creativeDTOs);
        map.put("total", (null == total) ? 0 : total);
        return map;
    }


    /*
     * NOTE: can be placed in class QueryBuilder
     */
    public CreativeQuery buildCreativeQuery(String title, Integer page, Integer pageSize) {
        CreativeQuery query = new CreativeQuery();
        query.setTitle(title);
        query.setPageNum(page);
        query.setPageSize(pageSize);
        return query;
    }

現在,如何編寫單測呢? buildCreativeQuery 這個自不必說。 實際上,只需要對 searchForSelect2 做單測,因為這個承載了主要內容; 而 searchForSelect 只是流程的東西,通過聯調就可以測試。單測代碼如下:

public class CreativeControllerTest {

  CreativeController controller = new CreativeController();

  @Test
  public void testSearchForSelect2() {
    CreativeQuery creativeQuery = controller.buildCreativeQuery("haha", 1, 20);
    Map<String, Object> result = controller.searchForSelect2(creativeQuery,
                                                            (q) -> null , (q)-> 0);
    Assert.assertEquals(0, ((List)result.get("rows")).size());
    Assert.assertEquals(0, ((Integer)result.get("total")).intValue());

  }

}

注意到,這里使用了 lambda 表達式來模擬返回外部服務的返回結果,因為我們本身就用 Function 接口隔離和模擬了外部服務依賴。 細心的讀者一定發現了: lambda 表達式,簡直是單測的 Mock 神器啊!

It's Time to Say Goodbye to Mock Test Framework !

改寫業務代碼####

看一段常見的業務代碼,通過外部服務獲取訂單的物流詳情后,做一段處理,然后返回相應的結果。

private List<Integer> getOrderSentIds(long sId, String orderNo) {

    OrderParam param = ParamBuilder.buildOrderParam(sId, orderNo);
    PlainResult<List<OrderXXXDetail>> xxxDetailResult =
            orderXXXService.getOrderXXXDetailByOrderNo(param);
    if (!xxxDetailResult.isSuccess()) {
      return Lists.newArrayList();
    }
    List<OrderXXXDetail> xxxDetails = xxxDetailResult.getData();
    List<Integer> sentIds = Lists.newArrayList();
    xxxDetails.forEach(xxxDetail -> sentIds.add(xxxDetail.getId()));
    return sentIds;
  }

從第三行 if 到 return 的是一個不依賴於外部服務的獨立函數。為了便於寫單測,實際上應該將這一部分抽離出來成為單獨的函數。不過這樣對於程序猿來說,有點生硬。那么,使用函數接口如何改造呢?可以將 orderXXXService.getOrderXXXDetailByOrderNo(param) 作為函數參數的傳入。 代碼如下:

private List<Integer> getOrderSentIds2(long sId, String orderNo) {
    OrderParam param = ParamBuilder.buildOrderParam(sId, orderNo);
    return getOrderSentIds(param, (p) -> orderXXXService.getOrderXXXDetailByOrderNo(p));
  }

  public List<Integer> getOrderSentIds(OrderParam order,
                                           Function<OrderParam, PlainResult<List<OrderXXXDetail>>> getOrderXXXFunc) {
    PlainResult<List<OrderXXXDetail>> xxxDetailResult = getOrderXXXFunc.apply(order);
    if (!xxxDetailResult.isSuccess()) {
      return Lists.newArrayList();
    }
    List<OrderXXXDetail> xxxDetails = xxxDetailResult.getData();
    List<Integer> sentIds = Lists.newArrayList();
    xxxDetails.forEach(xxxDetail -> sentIds.add(xxxDetail.getId()));
    return sentIds;
  }

現在,getOrderSentIds2 只是個順序流,通過聯調可以驗證; getOrderSentIds 承載着主要內容,需要編寫單測。 而這個方法現在是不依賴於外部服務的,可以通過 lambda 表達式模擬任何外部服務傳入的數據了。單測如下:

@Test
    public void testGetOrderSentIds() {
        OrderParam orderParam = ParamBuilder.buildOrderParam(55L, "Dingdan20170530");
        PlainResult<List<OrderXXXDetail>> failed = new PlainResult<>();
        failed.setSuccess(false);
        Assert.assertArrayEquals(new Integer[0],
                                 deliverer.getOrderSentIds(orderParam, p -> failed).toArray(new Integer[0]));

        OrderXXXDetail detail1 = new OrderXXXDetail();
        detail1.setId(1);
        OrderXXXDetail detail2 = new OrderXXXDetail();
        detail2.setId(2);
        List<OrderXXXDetail> details = Arrays.asList(detail1, detail2);
        PlainResult<List<OrderXXXDetail>> result = new PlainResult<>();
        result.setData(details);
        Assert.assertArrayEquals(new Integer[] {1,2},
                                 deliverer.getOrderSentIds(orderParam, p -> result).toArray(new Integer[0]));


    }

更通用的方法####

事實上,借助於函數接口及泛型,可以編寫出更通用的方法。 如下代碼所示。 現在,可以從任意服務獲取任意符合接口的對象數據,並取出其中的ID字段了。泛型是一個強大的工具,一旦你發現一種操作可以適用於多種類型,就可以使用泛型通用化操作。

  public interface ID {
      Integer getId();
  }

public <P, T extends ID> List<Integer> getIds(P order,
                                           Function<P, PlainResult<List<T>>> getDetailFunc) {
    PlainResult<List<T>> detailResult = getDetailFunc.apply(order);
    if (!detailResult.isSuccess()) {
      return Lists.newArrayList();
    }
    List<T> details = detailResult.getData();
    return details.stream().map(T::getId).collect(Collectors.toList());
  }

外部依賴引入源###

綜上例子,一個方法的外部依賴引入源主要有:
(1) 方法所在類的實例變量,在方法里引用就如同引用了可能被隨時修改的全局變量,是非常破壞方法的純粹性的;
(2) 方法所在類注入的Service, 在方法里使用就成了方法的外部依賴,往往要寫Mock外部依賴的結果數據才能進行單測;
(3) 方法調用了依賴外部服務的下層方法,導致方法有間接依賴。

對於(1),含有業務邏輯的方法應當將實例變量作為函數參數; 對於 (2) 和 (3), 使用函數接口和lambda表達式隔離和模擬依賴服務。

不過這里有兩個問題:

(1) 如果一個方法依賴了多個 service 或 多個方法,怎么辦? 那就要傳入多個 Function 參數了。 另一種辦法是,遵循單一職責原則,盡量編寫短小的只含有至多一個Service或方法依賴的方法。每個方法只做明確的一件事。 很多調用多個Service 或多個方法的方法,就是做了太多事情了,每件事都不徹底,導致每次擴展都要在一個方法里增加很多條件分支。

(2) 大量的函數接口和lambda表達式可能像回調一樣,容易將人繞暈。因此,一個函數最多兩個函數接口為宜。 而函數接口和lambda表達式的使用,需要整體策略來控制,保持工程的可理解性和可維護性。 畢竟,可測性只是工程質量的一個屬性,不能過於追求一個屬性而破壞其他屬性。

工程的“版圖”###

一個工程里應當被划分為“兩半版圖”:版圖A是依賴於各種外部服務的調用,版圖B是不依賴於任何外部服務的獨立業務方法和工具類。版圖B中的獨立業務方法充滿着各種業務邏輯和判斷,是容易編寫單測的,而版圖A是沒有必要寫單測的,因為里面沒有邏輯。這樣,我們將工程中的外部依賴“驅逐到”版圖A,類似於第九區里的“外星人管理區”。

理想情況下,版圖B應該是占90%的領土,版圖A應該占10%的領土。不過,實際工程中正好相反,版圖A占了90%的領土,版圖B卻被驅逐到util包下,只占10%,單測還往往被忽視。 怎么改造呢? 實際上也很簡單: 一旦從A的業務方法 FA 中發現外部依賴,就抽離出一個獨立方法 FB 來隔離外部依賴,放到版圖B里,然后對 FB 進行仔細單測,而 FA 只作為一個殼或外觀模式,通過聯調來確保正確。

對外部依賴的隔離,使得更容易編寫單測,更容易獲得更高的單測覆蓋率和單測質量。

此外,導致單測編寫困難的另一個“罪魁禍首”,就是不好的編程習慣,將大量多個邏輯放在同一個方法里。這樣,為了測試一個東西,要構造大量的對象;同時,對其中的子部分則不容易測試徹底,導致隱藏的BUG。

對於增強代碼可測性的唯一建議就是: 拆解、隔離。

單測策略###

並不是所有代碼都需要寫單測的。也不是所有代碼用單測更有效率。 在我看來,如果是純順序的邏輯,可以通過接口測試來保證,尤其是對於那些依賴外部服務的單行調用,既無法寫單測也不必要寫單測。而對於具有條件分支、循環分支等的邏輯,則要盡可能隔離成獨立方法或函數,從而更容易滴更有效率地單測。

單測並不需要100%的覆蓋率,也不應當花費過度的成本去追求高的覆蓋率。 100%的覆蓋率也不代表質量杠杠滴。 在單測覆蓋率和軟件開發成本中,必須有一個平衡。更好的軟件質量,應當是較高的單測覆蓋率與適當的接口用例覆蓋的雙重護航而保障,而不是把注都押在單測上。

疑慮###

當然,使用任何一種新方式,總會有疑慮的。

高階函數不易掌握####

使用函數接口,或者說高階函數的寫法,對於很多童鞋可能還很不適應。 不過,這種寫法以后很可能會成為主流。 因為它便捷、安全,而且很容易產生通用化的方法。通過高階框架函數以及許多自定義業務函數的反復組合,構建起整個軟件。

事實上,高階函數並不陌生。在 C 語言時代,就已經通過函數指針支持傳入函數參數了。 因此,高階函數,只是將函數指針“對象化”了,並不是新鮮玩意。

多出的方法####

從上面的例子可以看到,每一個被改造的方法,最終會得到兩個方法: 一個隔離了外部依賴的獨立函數,一個依賴外部服務的單行調用。獨立函數便於測試,而單行調用通常通過聯調來保證OK。這對軟件測試是個福音,不過對於程序員來說,會不會是額外的負擔呢?可能取決於各自的選擇吧。至少在我看來,多一個方法,卻能夠更方便地測試,甩掉繁重的mock單測框架,是非常值得的。此外,通常還能從中挖掘出更通用的方法,消除重復的業務代碼,也是另一個好消息。

工程隱患####

在生產環境的工程中大量使用函數接口和lambda表達式,是否有隱患呢?目前還沒有確切證據。如果有了,可以不斷積累經驗,但不應當因噎廢食。一種新技術、新方式,總要踩上若干坑,才能成為成熟的技術,將軟件開發推向一個新的里程碑。

在我所負責的訂單導出工程里,已經大量使用了函數接口和lambda表達式。如果運行不穩定,那么也可以得到第一手的資料。且讓我們拭目以待。

自動生成單測###

一旦我們盡可能將依賴外部服務的函數轉化為“非依賴於外部服務的獨立函數+外部服務的單行調用”,編寫單測的工作就變成了對獨立函數的單測。而獨立函數的單測是可以自動生成的。后續會專門有一篇文章來談到Java單測類模板的自動生成。目前僅僅談及思路。

單測的編寫模板無非是:解析方法簽名; 創建對象; 設置對象值; 設置外部服務返回數據; 檢測返回結果。 解析方法簽名通過可以使用正則表達式;創建對象和設置對象屬性,可使用java反射機制; 設置外部服務返回數據, 可創建簡單的 lambda 表達式來模擬。


免責聲明!

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



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