基於鏈路思想的SpringBoot單元測試快速寫法

簡介:本文更偏向實踐而非方法論,所提及的SpringBoot單元測試寫法亦並非官方解,僅僅是筆者自身覺得比較方便、效率較高的一種寫法。每個團隊甚至團隊內的每位開發可能都有自己的寫法習慣和風格,只要能實現單元測試的效果,就沒必要糾結於寫法的簡單抑或複雜。這裏也歡迎各位大佬們發表看法或分享自己的單測心得,幫助像筆者這樣的新人快速成長。

作者 | 桃符
來源 | 阿里技術公衆號

引言:

本文更偏向實踐而非方法論,所提及的SpringBoot單元測試寫法亦並非官方解,僅僅是筆者自身覺得比較方便、效率較高的一種寫法。每個團隊甚至團隊內的每位開發可能都有自己的寫法習慣和風格,只要能實現單元測試的效果,就沒必要糾結於寫法的簡單抑或複雜。這裏也歡迎各位大佬們發表看法或分享自己的單測心得,幫助像筆者這樣的新人快速成長。

一 爲什麼要寫單元測試?

測試是Devops上極重要的一環,但大多數開發的眼光都停留在集成測試這一環——只要能聯調成功,那麼我這次準備上線的特性一定是沒問題的。

老實承認,我曾經是這樣的可能現在也還是這樣。作爲非科班出身的筆者,研究生畢業後就立即進入了同在杭州的xx廠,先後參與了內部Devops平臺建設和xx雲Paas項目開荒,在這兩個項目中,開發 > 測試是很正常的場景,甚至部分測試也是原開發友情客串的:由於缺少專業的測試人員,開發往往需要兼顧集成測試甚至是線上測試的活兒。爲了提高效率,我將一部分常用的測試用例維護在了內部的自動化測試平臺上。即便如此,我仍能清晰地感覺到,測試所能覆蓋的場景屈指可數,以至於每次自信地上線大特性後,都會因一些奇怪的問題而定位到大半夜。幸虧後面遇到了一位資深大佬,在code review時,他直接點出我不寫單元測試的壞習慣,並用自身慘痛的線上教訓反覆強調單測的重要性。

當然上述只是我的親身經歷,勉強作爲日常閒聊的談資。如果想要深入理解單元測試的重要性,推薦Google上搜索the importance of unit test關鍵字,可以感受下不同國家、不同領域的程序員對單元測試的不同理解,想必能有更大的收穫。

二 爲什麼推薦鏈路思想?

深入接觸單元測試,開發難免會遇到以下場景:

  1. 應該如何設計測試用例?
  2. 應該如何編寫測試用例?
  3. 測試用例的質量該如何判定?

剛開始學習寫單元測試,我也曾參考並嘗試過網上五花八門的寫法。這些寫法可能用到了不同的單測框架,也可能側重了不同的代碼環節(例如特定的某個service方法)。一開始我爲自己能夠熟練使用多種單測框架而沾沾自喜,但隨着工作的推進,我逐漸意識到,單元測試中重要的並不是框架選型,而是如何設計一套優秀的用例。之所以用"一套"而不是"一個",是因爲在我們的業務代碼中,邏輯往往並非"一帆風順",有許多if-else會妝點我們的業務代碼。顯然對於這類業務代碼,"一個"測試用例無法完全滿足所有可能出現的場景。如果爲了偷懶,嘗試僅僅用"一個"用例去覆蓋主流程,無異於給自己埋了個雷——線上場景可沒"一個"用例這麼簡單!

我開始專注於測試用例的設計,從輸入輸出開始,重新審視曾經開發過的代碼。我發現,如果將某個controller方法作爲入口,那這一套業務流程可以當做一條鏈路,而上下文中所關聯的service層、dao層、api層的各方法都可以作爲鏈路上的各環節。通過繪製鏈路圖,將各環節根據是否關聯外部系統大致分成黑、白兩類,整套業務流程和各環節的潛在分支便會變得清晰,測試用例便從"一個"自然而然地變成了"一套"。此處多提一嘴,鏈路思想設計用例的基礎是結構清晰、圈複雜度可控制的代碼風格,如果開發的時候依然尊崇"論文式"、"一刀流",在單個方法內"長篇大論",那鏈路式將是一個巨大的負擔。

編寫測試用例其實不是一件費勁的事,對於深耕業務代碼的開發而言,編寫測試用例便像是做一盤小菜,舉手可爲。於我而言,如今寫測試用例所花費的時間甚至沒有設計測試用例的時間長(凸顯用例設計的重要性但也有可能是我對測試用例的設計還不夠熟練)。在測試框架選型上,我更習慣於Junit+Mockito的組合,原因僅僅是熟悉與簡單,且參考文檔比比皆是。如果各位已經有自己習慣的框架和寫法,也不必照搬本文所提及的東西,畢竟單測是爲了better code,而不是自找麻煩。

但無論測試用例如何設計或是如何編寫,我始終認爲,在不考慮測試代碼的風格和規範的前提下,衡量測試用例質量的核心指標是分支覆蓋率。這也是我推薦鏈路思想的一大原因——從入口出發,遍歷鏈路上各個環節的各個分支,遇到阻礙就Mock;相比於分別單測各個獨立方法,單測鏈路所需要的入參和出參更加清晰,更是大大節省了編寫測試代碼所需的時間成本!計算分支覆蓋率的工具有很多,例如本地的JaCoCo或是各類雲化測試工具。試想,每當看到單測完美地覆蓋了自己所提交的特性代碼時,心裏是不是放心了許多?

三 如何用鏈路思想設計/構造單測?

作爲程序員,大家更爲熟悉的鏈路概念應該是全鏈路壓測。

全鏈路壓測簡單來說,就是基於實際的生產業務場景、系統環境,模擬海量的用戶請求和數據對整個業務鏈進行壓力測試,並持續調優的過程,本質上也是性能測試的一種手段。... 通過這種方法,在生產環境上落地常態化穩定壓測體系,實現IT系統的長期性能穩定治理。

如果將完整的業務流程視作全鏈路,那作爲業務鏈上的一環,即某個後端服務,它其實也是一個微鏈路。這裏以自上而下的開發流程爲例,對於新增的功能接口,我們會習慣性地由controller開始設計,然後構建service層、dao層、api層,最後再錦上添花地加些aop。如果以鏈路思想,將複雜的流程拆成各個鏈路的各個環節,那這樣的代碼功能清晰,維護起來也相當方便。我非常認同 限制單個方法行數<=50 的代碼門禁,對於長篇大論的代碼“論文”,想必沒有哪位接手的同學臉上能露出笑容的;針對這類代碼,我認爲clean code的優先級比補充單測用例更高,連邏輯都無法理清,即便硬着頭皮寫出單測用例,後續的調試和維護工作量也是不可預料的(試想,假如後面有位A同學接手了這塊代碼,他在“論文”中加了xx行導致ut失敗了,他該如何去定位問題)。

簡單畫個圖來強調一下我的觀點。這是一張"用戶買豬"的功能邏輯圖。以鏈路思想,開發人員將整套流程拆分爲相應的鏈路環節,涵蓋了controller、service、dao、api各層;整條鏈路清晰明瞭,只要搭配完善的上下文日誌,定位線上問題亦是輕而易舉。

當然,基於鏈路思想的開發還遠遠不夠,在補充單測用例時,我們同樣也能用鏈路思想來構造測試用例。測試用例的要求很簡單,需要覆蓋controller、service等自主編寫的代碼(多分支場景也需要完全覆蓋),對於周邊關聯的系統可以採用Mock進行屏蔽,對於Dao層的SQL可以視需求決定是否Mock。秉承這個思路,我們可以對“用戶買豬”圖進行改造,將允許Mock的環節塗灰,從而變成我們在編寫單元測試用例時所需要的“虛擬用戶買豬”圖。

四 快速寫法實踐案例

1 快速寫法的核心步驟有哪些?

快速寫法的入口是controller層方法,這樣對於controller層存在的少量邏輯代碼也能做到覆蓋。

設計測試用例的輸入與預期輸出

設計測試用例的目的不僅僅是跑通主流程,而是要跑通全部可能的流程,即所謂的分支全覆蓋,因此設計用例的輸入與輸出尤爲重要。即便是新增分支的增量修改(例如加了一行if-else),也需要補充相應的輸入與預期輸出。非常不建議根據單測運行結果修改預期結果,這說明原先的代碼設計有問題。

確定鏈路上的全部Mock點

Mock點的判斷依據是鏈路上該環節是否依賴第三方服務。強烈建議在設計前畫出大概的功能流程圖(如”用戶買豬“圖),這可以大大提高確定Mock點的速度和準確性。

收集Mock點的模擬返回數據

確定Mock點後,我們就需要構造相應的模擬返回數據。Mock數據需要考慮多個因素:

a. 是否與api層對應方法的期望返回值匹配: 不能把從豬廠返回的Mock數據用牛肉替代

b. 是否與模擬輸入數據匹配:用戶需要1斤豬肉,不能返回5斤豬肉的數據

c. 是否與api層的所有分支匹配:部分api層會對返回值進行響應碼(2xx || 3xx || 4xx)校驗,這類場景便需要構造不同響應碼的Mock數據

2【開發篇】真實用戶買豬

該項目基於PandoraBoot構建,手動升級SpringBoot版本至2.5.1,使用Mybatis-plus組件簡化Dao層開發過程。下面選取了上文圖中所涉及的重要方法進行展示,僅實現了簡單的業務流程,系統框架和工程結構可以參考代碼倉。

業務對象

PorkStorage.java - 豬肉庫存的數據庫實體類
/**
 * 豬肉庫存的數據庫實體類
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName(value = "pork_storage", autoResultMap = true)
public class PorkStorage {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    private Long cnt;
}

PorkInst.java - 豬肉實例,由倉庫打包後生成

/**
 * 豬肉實例,由倉庫打包後生成
 **/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PorkInst {
    /**
     * 重量
     */
    private Long weight;

    /**
     * 附件參數,例如包裝類型,寄送地址等信息
     */
    private Map< String, Object> paramsMap;
}

業務代碼

PorkController.java
@RestController
@Slf4j
@RequestMapping("/pork")
public class PorkController {
    @Autowired
    private PorkService porkService;

    @PostMapping("/buy")
    public ResponseEntity< PorkInst> buyPork(@RequestParam("weight") Long weight,
                                            @RequestBody Map< String,Object> params) {
        if (weight == null) {
            throw new BaseBusinessException("invalid input: weight", ExceptionTypeEnum.INVALID_REQUEST_PARAM_ERROR);
        }
        return ResponseEntity.ok(porkService.getPork(weight, params));
    }
}

PorkService.java

public interface PorkService {
    /**
     * 獲取豬肉打包實例
     *
     * @param weight 重量
     * @param params 額外信息
     * @return {@link PorkInst} - 指定數量的豬肉實例
     * @throws BaseBusinessException 如果豬肉庫存不足,返回異常,同時後臺告知工廠
     */
    PorkInst getPork(Long weight, Map< String, Object> params);
}

PorkStorageDao.java

@Mapper
public interface PorkStorageDao extends BaseMapper< PorkStorage> {
    PorkStorage queryStore();
}

PorkStorageDao.xml

< ?xml version="1.0" encoding="UTF-8"?>
< !DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
< mapper namespace="com.alibaba.ut.demo.dao.PorkStorageDao">
    < sql id="columns">id, cnt< /sql>
    < sql id="table_name">pork_storage< /sql>
    < select id="queryStore" resultType="com.alibaba.ut.demo.entity.PorkStorage">
        select
        < include refid="columns"/>
        from
        < include refid="table_name"/>
        where id = 1
    < /select>
< /mapper>

FactoryApi.java

public interface FactoryApi {
    void supplyPork(Long weight);
}

FactoryApiImpl.java

@Service
@Slf4j
public class FactoryApiImpl implements FactoryApi {
    @Override
    public void supplyPork(Long weight) {
        log.info("call real factory to supply pork, weight: {}", weight);
    }
}

WareHouseApi.java

public interface WareHouseApi {
    PorkInst packagePork(Long weight, Map< String, Object> params);
}

WareHouseApiImpl.java

@Service
@Slf4j
public class WareHouseApiImpl implements WareHouseApi {
    @Override
    public PorkInst packagePork(Long weight, Map< String, Object> params) {
        log.info("call real warehouse to package, weight: {}", weight);
        return PorkInst.builder().weight(weight).paramsMap(params).build();
    }
}

3【單測篇】虛擬用戶買豬

單測依賴

對於PandoraBoot工程,可參考下文的Maven配置引入相關依賴。
對於非PandoraBoot工程,僅需引入Junit和Mockito兩個包即可。
注本章所提到的單測寫法默認Mock Dao層且無需啓動容器應用。如果不想Mock Dao層,建議在依賴中引入H2這類內存型數據庫,同時支持本地啓動容器應用。

寫法思路

在閱讀下面的內容前,強烈建議先學習Junit和Mockito的基本用法和運行原理,包括但不限於下文寫法中可能涉及的註解:
Junit原生流Method註解:@Before 、@Test、@After
Mockito原生Field註解:@Mock、@InjectMocks、@Spy

在已知待單測業務鏈路的前提下,寫法可以簡要歸納爲以下幾步:

  1. 初步設計單測用例框架。包括setup、teststep、teardown三步,setup負責處理一些全局必要的單測前置邏輯(例如Mock數據插入和環境準備),teststep承載單測用例的主體(要求以Assert類近似的斷言語句爲結尾),teardown負責處理一些全局必要的收尾邏輯(例如Mock數據刪除和環境釋放)
  2. 聲明並初始化用例所涉及的所有鏈路環節。在已知鏈路流程的前提下,所有環節都可以依據是否爲Mock點方法大致分爲兩類(參考上文中"用戶買豬"圖的灰、白點)。
  • 非Mock點方法:對於鏈路中非入口的環節(通常將controller作爲入口,其他方法即爲非入口),需要標註@Spy以聲明該對象在單測鏈路中爲監聽狀態,即需要正常走完流程。此處根據方法內是否引用Mock點方法進一步分成兩類。
  • 該方法內引用了其他Mock點方法,需要在@Spy的基礎上額外標註@InjectMocks,聲明該對象在單測鏈路中需要被注入其他Mock對象。
  • 該方法內未引用其他Mock點方法,無需進行其他操作。
  • Mock點方法:標註@Mock以聲明該對象在單測鏈路中需要被Mock,可以通過org.mockito.Mockito類內的一系列static方法手動注入Mock值(ep. when(A()).thenReturn(B))。
  1. 編寫單測用例主體。在teststep中從controller層發起方法調用,最終通過Assert斷言結果判斷用例的成功與否。除了普通的返回值校驗場景外,Junit也支持用@Test(expected = xxException.class)來聲明該用例期望發生的異常類型。最後還是建議寫完單測後能夠以註釋的形式說明該單測所支持的場景和預期結果的大致說明,方便以後自己和其他接手的同學能夠快速瞭解這個單測用例的相關信息。

這裏仍以"用戶買豬"的場景爲例,依照鏈路思想,當服務端收到用戶購買豬肉的請求時,我們可以構造出如下分支場景:

  1. controller層存在可能出口,即weight == null。據此生成測試用例A,命名爲testBuyPorkIfWeightIsNull,實際入參中weight==null,期望接口拋出異常;
  2. 按鏈路進入到PigServiceImpl中,存在可能出口,即hasStore() == false。據此生成測試用例B,命名爲testBuyPorkIfStorageIsShortage,實際入參中weight必需大於庫存值(如代碼中setup預設庫存爲10,虛擬用戶請求了20),期望接口拋出異常;
  3. 按鏈路繼續執行,發現正常出口。據此生成測試用例C,命名爲testBuyPorkIfResultIsOk,實際入參中weight必須小於庫存值(如代碼中setup預設庫存爲10,虛擬用戶請求了5),期望接口返回與入參相匹配的返回值一致,即正常返回了weight爲5的豬肉打包實例。

單測代碼

package com.alibaba.ut.demo.controller;

import com.alibaba.ut.demo.PorkController;
import com.alibaba.ut.demo.api.FactoryApi;
import com.alibaba.ut.demo.api.WareHouseApi;
import com.alibaba.ut.demo.dao.PorkStorageDao;
import com.alibaba.ut.demo.entity.PorkInst;
import com.alibaba.ut.demo.entity.PorkStorage;
import com.alibaba.ut.demo.exception.BaseBusinessException;
import com.alibaba.ut.demo.service.impl.PorkServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.mockito.stubbing.Answer;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

import static org.mockito.Matchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;

/**
 * @Author Taofu.lj
 * @Version 1.0.0
 * @Date 2021年12月02日 14:15
 */
@Slf4j
public class PorkControllerTest {
    /**
     * controller入口,由於是鏈路入口,無需用@Spy監聽
     */
    @InjectMocks
    private PorkController porkController;

    /**
     * 接口類型的鏈路環節用實現類初始化代替, @Spy需要手動初始化避免initMocks時失敗
     * 注:鏈路上每一環都必須聲明,即使測試用例中並沒有被顯性調用
     */
    @InjectMocks
    @Spy
    private PorkServiceImpl porkService = new PorkServiceImpl();

    /**
     * 待Mock的鏈路環節,下同
     */
    @Mock
    private PorkStorageDao porkStorageDao;

    @Mock
    private FactoryApi factoryApi;

    @Mock
    private WareHouseApi wareHouseApi;

    /**
     * 預置數據可直接作爲類變量聲明
     */
    private final Map< String, Object> mockParams = new HashMap< String, Object>() {{
        put("user", "system_user");
    }};

    @Before
    public void setup() {
        // 必要: 初始化該類中所聲明的Mock和InjectMock對象
        MockitoAnnotations.initMocks(this);

        // Mock預置數據並綁定相關方法(適用於有返回值的方法)
        PorkStorage mockStorage = PorkStorage.builder().id(1L).cnt(10L).build();

        // 常見Mock寫法一:僅試圖Mock返回值
        when(porkStorageDao.queryStore()).thenReturn(mockStorage);

        // 常見Mock寫法二:不僅試圖Mock返回值,還想額外打些日誌方便定位
        when(wareHouseApi.packagePork(any(), any()))
                .thenAnswer(ans -> {
                    log.info("mock log can be written here");
                    return PorkInst.builder()
                            .weight(ans.getArgumentAt(0, Long.class))
                            .paramsMap(ans.getArgumentAt(1, Map.class))
                            .build();
                });

        // Mock動作並綁定相關方法(適用於無返回值方法)
        doAnswer((Answer< Void>) invocationOnMock -> {
            log.info("mock factory api success!");
            return null;
        }).when(factoryApi).supplyPork(any());
    }

    @After
    public void teardown() {
        // TODO: 可以加入Mock數據清理或資源釋放
    }

    /**
     * 當傳入參數爲null時,拋出業務異常
     *
     * @throws BaseBusinessException
     */
    @Test(expected = BaseBusinessException.class)
    public void testBuyPorkIfWeightIsNull() {
        porkController.buyPork(null, mockParams);
    }

    /**
     * 當後臺庫存不滿足需求時,拋出業務異常
     *
     * @throws BaseBusinessException
     */
    @Test(expected = BaseBusinessException.class)
    public void testBuyPorkIfStorageIsShortage() {
        porkController.buyPork(20L, mockParams);
    }

    /**
     * 正常購買時返回業務結果
     */
    @Test
    public void testBuyPorkIfResultIsOk() {
        Long expectWeight = 5L;

        ResponseEntity< PorkInst> res = porkController.buyPork(expectWeight, mockParams);
        // 此處第一次校驗接口返回狀態是否符合預期
        Assert.assertEquals(HttpStatus.OK, res.getStatusCode());

        Long actualWeight = Optional.of(res).map(HttpEntity::getBody).map(PorkInst::getWeight).orElse(-99L);
        // 此處第二次校驗接口返回值是否符合預期
        Assert.assertEquals(expectWeight, actualWeight);
    }
}

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章