歡迎光臨
每天分享高質量文章

使用 Java 函式介面及 lambda 運算式隔離和模擬外部依賴方便單元測試

(點選上方公眾號,可快速關註)


來源:琴水玉 ,

www.cnblogs.com/lovesqcc/p/6917448.html

概述

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

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

不過,現實常常沒有這麼美好。應用要讀取外部配置,要依賴外部服務獲取資料進行處理等,導致應用似乎無法單純地“透過固定輸入得到固定輸出”。實際上,有兩種方法可以盡可能隔離外部依賴,使得依賴於外部環境的物件方法回歸“獨立函式”的原味。

(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 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 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 creativeDTOs = creativeService.search(query);

     Integer total = creativeService.count(query);

     Map map = new HashMap();

     map.put(“rows”, (null == creativeDTOs) ? new ArrayList() : 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 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 searchForSelect2(CreativeQuery query,

                                               Function> getListFunc,

                                               Function getTotalFunc) {

        List creativeDTOs = getListFunc.apply(query);

        Integer total = getTotalFunc.apply(query);

        Map map = new HashMap();

        map.put(“rows”, (null == creativeDTOs) ? new ArrayList() : 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 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 getOrderSentIds(long sId, String orderNo) {

 

    OrderParam param = ParamBuilder.buildOrderParam(sId, orderNo);

    PlainResult> xxxDetailResult =

            orderXXXService.getOrderXXXDetailByOrderNo(param);

    if (!xxxDetailResult.isSuccess()) {

      return Lists.newArrayList();

    }

    List xxxDetails = xxxDetailResult.getData();

    List sentIds = Lists.newArrayList();

    xxxDetails.forEach(xxxDetail -> sentIds.add(xxxDetail.getId()));

    return sentIds;

  }

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

private List getOrderSentIds2(long sId, String orderNo) {

    OrderParam param = ParamBuilder.buildOrderParam(sId, orderNo);

    return getOrderSentIds(param, (p) -> orderXXXService.getOrderXXXDetailByOrderNo(p));

  }

 

  public List getOrderSentIds(OrderParam order,

                                           Function>> getOrderXXXFunc) {

    PlainResult> xxxDetailResult = getOrderXXXFunc.apply(order);

    if (!xxxDetailResult.isSuccess()) {

      return Lists.newArrayList();

    }

    List xxxDetails = xxxDetailResult.getData();

    List 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> 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 details = Arrays.asList(detail1, detail2);

        PlainResult> 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

List getIds(P order,

                                           Function

>> getDetailFunc) {

    PlainResult> detailResult = getDetailFunc.apply(order);

    if (!detailResult.isSuccess()) {

      return Lists.newArrayList();

    }

    List details = detailResult.getData();

    return details.stream().map(T::getId).collect(Collectors.toList());

  }

隔離方法呼叫引起的間接依賴

這個例子顯示了程式碼的另一種常態:queryEsData 裡混雜外部實體變數 this.serviceUrl 以及呼叫依賴外部服務的下層方法 query ,其中充斥的條件邏輯、迴圈邏使得方法顯得更加“複雜”,導致難以進行單測。註意到,這裡已經使用了函式介面來表達如何從獲取的HTTP傳回結果中提取感興趣的資料集。

/**

   * 根據 ES 查詢物件及結果提取器提取 ES 資料集

   * @param initQuery ES 查詢物件

   * @param getData ES結果提取器

   * @return List ES資料集

   */

  public List queryEsData(QueryBuilder initQuery, Function> getData) {

      List rsList = new ArrayList();

      try {

          JSONObject result = query(initQuery.toJsonString(), this.serviceUrl + “?search_type=scan&scroll;=600000”);

          logger.info(“ES search init result: ” + result.toJSONString());

          String scrollId = result.getString(“scroll_id”);

          if (scrollId == null) {

              return rsList;

          }

 

          String scrollUrl = this.serviceUrl + “?scroll=600000”;

 

          while (true){

              DataQueryBuilder dataQuery = new DataQueryBuilder();

              dataQuery.setScroll_id(scrollId);

              JSONObject jsonResult = query(JSON.toJSONString(dataQuery), scrollUrl);

              scrollId = jsonResult.getString(“scroll_id”);

              List tmpList = getData.apply(jsonResult);

              if(tmpList.size() == 0){

                  break;

              }

              rsList.addAll(tmpList);

          }

 

      } catch (Exception e) {

          logger.error(“getESDataException”, e);

      }

      return rsList;

  }

咋一看,似乎無從下手。別急,一步步來。

提取依賴變數和依賴函式

很容易看到,這個方法兩次呼叫了 query, 可以先將 query 隔離出來,變成:

public List queryEsData(QueryBuilder initQuery, Function> getData) {

        return queryEsDataInner(initQuery, this::query, getData);

    }

不過, 外部實體變數 this.serviceUrl 還在 queryEsDataInner 裡面,會破壞 queryEsDataInner 的純粹性,因此,要把這兩個URL提取出來,放到 queryEsData 裡傳入給 queryEsDataInner. 效果應該是這樣:

public List queryEsData(QueryBuilder initQuery, Function> getData) {

        String initUrl = this.serviceUrl + “?search_type=scan&scroll;=600000”;

        String scrollUrl = this.serviceUrl + “?scroll=600000”;

        return queryEsDataInner(initQuery, initUrl, scrollUrl, this::query, getData);

    }

 

public List queryEsDataInner(QueryBuilder initQuery, String initUrl, String scrollUrl,

                                   BiFunction query,

                                   Function> getData) {

        try {

            JSONObject result = query.apply(initQuery.toJsonString(), initUrl);

            logger.info(“ES search init result: ” + result.toJSONString());

            String scrollId = result.getString(“scroll_id”);

            if (scrollId == null) {

                return new ArrayList<>();

            }

 

            List rsList = new ArrayList();

            while (true){

                DataQueryBuilder dataQuery = new DataQueryBuilder();

                dataQuery.setScroll_id(scrollId);

                JSONObject jsonResult = query.apply(JSON.toJSONString(dataQuery), scrollUrl);

                scrollId = jsonResult.getString(“scroll_id”);

                List tmpList = getData.apply(jsonResult);

                if(tmpList.size() == 0){

                    break;

                }

                rsList.addAll(tmpList);

 

            }

            return rsList;

 

        } catch (Exception e) {

            logger.error(“getESDataException”, e);

            return new ArrayList<>();

        }

    }

queryEsData 不純粹沒關係,反正它就是個殼。 僅這一步,就讓 queryEsDataInner 方法變成了獨立方法,不再依賴於任何外部變數和外部service,也不依賴於呼叫函式。不過 queryEsDataInner 會有點醜:有五個引數。 醜是醜了點,不過單測相對比較好寫了。這裡的 getData 動態生成不同的結果以便退出,因此用了點技巧,見程式碼:

@Test

    public void testQueryEsDataInner() {

 

        HttpEsClient esClient = new HttpEsClient();

        List empty = esClient.queryEsDataInner(new QueryBuilder(), “initUrl”, “”, (q, u) -> new JSONObject(), (jo) -> new ArrayList());

        Assert.assertEquals(0, empty.size());

 

        JSONObject jo = new JSONObject();

        List list = Arrays.asList(1,2,3);

        jo.put(“scroll_id”, “1”);

        jo.put(“list”, list);

 

        List result = esClient.queryEsDataInner(new QueryBuilder(), “initUrl”, “scrollUrl”, (q, u) -> jo, this::dyGetData);

        Assert.assertArrayEquals(new Integer[]{1,2,3,1,2,3}, result.toArray(new Integer[0]));

    }

 

    private static int i = 2;

 

    private List dyGetData(JSONObject jsonObject) {

        if (i == 0) { return new ArrayList<>(); }

        i–;

        return jsonObject.getJSONArray(“list”).toJavaList(Integer.class);

    }

外部依賴引入源

綜上例子,一個方法的外部依賴引入源主要有:

(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 運算式來模擬。

看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

贊(0)

分享創造快樂