1. 程式人生 > 程式設計 >基於mockito做有效的單元測試

基於mockito做有效的單元測試

概述


本文講解的主要是有效和單元的思想,並不是說如何編寫單元測試,用於改善和提高開發效率、編碼風格、編碼可讀性和單測效率,不盲目追求覆蓋率。

背景


  • 現在很多單元測試只是利用@Test註解把程式碼或者整個請求介面內的business做測試

  • 單測的過程就很多查資料庫的方法,但是沒必要每次都測sql,因為sql測一遍都應該是正確的。

  • 未明確單元測試由開發負責。單元測試是用於維護程式碼邏輯不被修改或者,修改了也不出錯,不是測試的事情。

  • 單測程式碼啟動速度、效率太低

  • 沒有在各個環境整個工程單元測試通過

  • 方法寫的很大,行數很多,邊update邊做邏輯

  • 很多公司盲目追求覆蓋率

目的

  • 單元測試啟動效率提升
  • 脫離環境,在每個環境都放心執行,不要考慮測試環境、生產環境有沒有這條資料
  • 維護方法核心邏輯在後續的迭代中不被錯誤地修改
  • 用於改善和提高開發效率、編碼質量、編碼可讀性、減少冗長的程式碼行

什麼是有效的單元測試


主要關鍵字: 有效、單元

  • 有效
  • 單元
  • 覆蓋場景
  • 可重複執行
  • 斷言
  • 不追求覆蓋率

1.有效的定義

  • 只測試核心的業務邏輯,如計算邏輯類(指標計算)、使用者支付下單組裝資料庫物件和子表(檢驗金額)、建立採買計劃表和detail表的物件(校驗指標個數、金額)
  • 不測使用者互動的,如匯出資料excel的底色、查詢資料庫記錄
  • 不測邏輯的前置校驗
  • 不要測試明顯有用的東西。避免測試來自第三方供應商的類,特別是提供編寫程式碼的框架的核心API的類。例如,不要測試向供應商的Hashtable類新增項、redis鎖等第三方庫
  • 不測環境相關的,儘量脫離環境,能讓單測程式碼在每個環境都能正確執行。

2.單元的定義

  • “單元測試”中的“單元”的意思是,將每個單元設為原子和儘可能獨立。
  • 目的:方法小,則可能元件化地再次利用,維護邏輯在以後的迭代中不被錯誤的修改。若被錯誤修改,只要每次迭代都走全工程的單測時將會報錯而被發現。
  • 使得編寫程式碼時方法職責單一,方法功能要小,方法邏輯小。也不要寫多行數方法,以多個小方法組成一個長邏輯。
  • 單測時不要寫大測試
  • 邏輯程式碼方法儘量不互相依賴。沒有關於測試執行順序的假設。
  • 邏輯程式碼方法適當的小,則能容易安裝/拆卸
  • 單測時沒必要把全流程都測一遍,也就是不必從controller遇到的第一個business入口測。只測核心邏輯,也就是核心的每個小方法的測試。

樣例:

    public void updateUser(String userName,Integer age) {
        // 校驗
        if (StringUtils.isEmpty(userName)) {
            throw new RuntimeException();
        }
        // redis分散式鎖
        getRedis();
        // 查詢主表
        selectUser();
        // 查詢出採買計劃Detail
        selectUserDetail();
        // 對資料進行處理
        calculateUserDetailInfo();
        //  更新主表
        updatePuchasePlanDb();
        //  更新detail表
        updatePuchasePlanDbDetail();
    }
複製程式碼

假設上述updateUser是一個Controller下的第一個business邏輯入口,用於更新使用者資訊,很多人的單元測試就會從updateUser這個方法開始做單元測試。但是這樣就不符合有效、單元的理念。

無意義測試

  • 因為如redis這些第三方庫,我們是相信他是正確的,而且走redis是需要連線真實網路,所以就會依賴環境,萬一網路不通,單元測試這段程式碼就無法通過。
  • 前置校驗也不用測,因為錯誤率和以後改動的機會比較少
  • 查詢資料庫主表、detail表、更新也不用測試,因為都是SQL,單元測試是為了維護邏輯不變,這些sql寫好一遍能正確,以後的迭代中都是正確的。

有意義測試

  • 所以我們單測的時候只測資料處理的邏輯,假設資料處理邏輯如下,則對下面的3個方法每個做一個單元測試。
  • 我們會認為元件式的方法能正確,組合起來就大概率正確。因為方法足夠的單元,則邏輯可插拔。
  • 若從頭測到尾,以後迭代中邏輯改動的概率很大,斷言錯誤概率大,這樣單元測試維護的意義就很小。
    private void calculateUserDetailInfo() {
        // 更改公司與關聯上下級關係
        changeCompany();
        // 更改組與關聯上下級關係
        changeGroup();
        // 更改部門與關聯上下級關係
        changeDeptment();

    }
複製程式碼

錯誤編碼案例:

private void calculateBrand() {
        for (int i = 0; i < new ArrayList<>().size(); i++) {

            for (int i1 = 0; i1 < new ArrayList<>().size(); i1++) {
                if (new Integer(1)==1) {
                    
                }  else {
                    
                }
            }
        }
        
    }
複製程式碼

這樣的程式碼就不符合單元的概念,至少把第二個for迴圈寫在另外一個方法裡,因為單元測試中,認為測試迴圈中一次是正確的,就斷言迴圈中每次都大概率正確。

3.覆蓋場景

  • 除了測試正常流程,還要測異常流程
  • 覆蓋方法正常執行,為其建立一個單測
  • 覆蓋if else,為同一個方法建立第二個單測

案例一 異常流程

    public Integer logic2(Integer num) throws Exception {
        try {
            num = this.purchasePlanDetailBusinessImpl.method(num);
        } catch (RuntimeException e) {
           throw new Exception();
        }
        return ++num;
    }
複製程式碼

則對應編寫:

    /**
     * 測試異常場景
     */
    @Test(expected = Exception.class)
    public void testMethodOnException() {
        Integer method = this.purchasePlanBusinessImpl.logic(null);
    }
複製程式碼

4.斷言

很多人的單元測試都是最後print一下,在console裡面看日誌或者debug看看是否正確,但是這樣不足夠。

  • 應有對方法返回值、物件裡面的成員屬性做判斷
  • 判斷是否為空或者判斷金額數值或者判斷異常是否符合預期
  • 只測試一件事一次。只要1個斷言測試一或者多個特性/行為

如下,而不是直接判斷是否為空或輸出到終端

    @Test
    public void testMethodOnNormal() {
        Integer method = this.purchasePlanBusinessImpl.method(1);
        
        Assert.assertTrue(method == 2);
    }
複製程式碼

5.可重複執行

  • 目前很多操作資料庫,或者記憶體資料庫
  • 操作真實資料庫,有可能下次就不能被斷言成功了,因為資料有可能被update
  • 記憶體資料庫雖然可以下次啟動依然恢復預設的資料,但是有可能被其他人的單測操作過,導致自己的資料被錯誤修改

6.不盲目追求覆蓋率

  • 很多公司盲目追求覆蓋率,說美國google覆蓋多高,但是別人工資高,6點準時下班,有足夠時間開發,開發前uml設計好每個方法的入參出參,在中國是沒有這麼多時間,都是追求快速
  • 在中國大廠中,雖然有覆蓋率要求,但是覆蓋率只能證明你有沒有寫單元測試,但是單元測試寫的好不好,就是另外一回事。因為有些人為了提高覆蓋率,把一些無關的程式碼也去單元測試,如測試controller層、測試entity建構函式、utils工具類等。
  • 螞蟻金服作為樣例,螞蟻是根據專案分覆蓋級別,也不是所有的專案都要覆蓋率多高,一般50%就很高了,不必都過50%,本文主要推崇的是有效、單元。
  • 按照本文的有效、單元的做法,是會犧牲覆蓋率的,因為我們只測核心邏輯
  • 不浪費時間,因為中國網際網路的迭代時間非常短,所以不必盲目為覆蓋率而寫單元測試,單元測試的目的是提供程式碼質量

操作工具


  • mockito:一般都能適用
  • powermock:在mockito的基礎上,能測試private方法,還能mock static靜態方法
  • @InjectMocks 用於框架new出物件,不用手動new 主要測試類
  • @Mock:被設定假物件返回的呼叫類
  • Assert.assertTrue等斷言
  • JSONObejct、JSONString:有時自己mock物件需要自己new ,但是這個時候我們可以寫好json體轉換成bean會比較方便。
    <dependencies>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-test</artifactId>
			<version>4.3.7.RELEASE</version><!--$NO-MVN-MAN-VER$ -->
			<scope>test</scope>
		</dependency>
		<!-- https://mvnrepository.com/artifact/org.mockito/mockito-all -->
		<dependency>
			<groupId>org.mockito</groupId>
			<artifactId>mockito-all</artifactId>
			<version>1.10.19</version>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.mockito</groupId>
			<artifactId>mockito-core</artifactId>
			<version>2.7.12</version>
			<scope>test</scope>
		</dependency>
		<!-- https://mvnrepository.com/artifact/junit/junit -->
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.12</version>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-context</artifactId>
			<version>4.3.7.RELEASE</version><!--$NO-MVN-MAN-VER$ -->
		</dependency>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-core</artifactId>
			<version>4.3.7.RELEASE</version><!--$NO-MVN-MAN-VER$ -->
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.18.8</version>
			<scope>provided</scope>
		</dependency>
	</dependencies>
複製程式碼
@RunWith(MockitoJUnitRunner.class)
public class PurchasePlanBusinessImplTest {
    
    @InjectMocks
    private DoSomeBusinessImpl purchasePlanBusinessImpl;
    @Mock
    private DoSomeDetailBusinessImpl purchasePlanDetailBusinessImpl;

    /**
     * 正常流程
     */
    @Test
    public void testMethodOnNormal() {
        Integer method = this.purchasePlanBusinessImpl.method(1);
        
        Assert.assertTrue(method == 2);
    }

    /**
     * 在某些場景
     */
    @Test
    public void testMethodOnXX() {
        when(this.purchasePlanDetailBusinessImpl.method(anyInt())).thenReturn(1);
        Integer method = this.purchasePlanBusinessImpl.logic(1);
        
        Assert.assertTrue(method == 3);
    }
複製程式碼

影響力


效率提升

  • 目前測試要麼啟動服務、要麼單測啟動spring,其實都需要時間,無論是10幾秒還是幾分鐘都是比較久的
  • 如果用mock方法,都是不需要基於sring容器,不需要自動注入就不需要解析bean關係,也不需要連線zk等環境問題。啟動時間只需要1秒
  • 不需要在真實資料庫造資料、不需要在記憶體資料庫寫sql
  • 如果測試整個工程的所有單測時,每一個類單元測試都會載入一次spring、記憶體資料庫,導致跑整個工程都很久(實際上測試環境、迴歸環境都需要跑單測)

脫離環境

  • 其實每個環境,無論開發聯調、測試、回顧、甚至生產環境都在構建時執行單元測試
  • 但是因為使用spring容器啟動的方式,每個類的單測都需要啟動spring,導致執行時間過長
  • 也或者因為環境出現造資料問題,導致執行不成功
  • mock出來的資料都是在程式碼實現,執行於記憶體中,所以不依賴中介軟體

方法小、行數少、職責小

  • 職責小:因單元測試的規則,讓寫每個方法時,都有意識地寫的少依賴,這樣的方法就提示被其他邏輯複用的概覽,而不是大方法,導致要用差不多的邏輯時,其他同事就會去複製一份程式碼。(例如不會在一個方法又做查詢資料庫、校驗、計算)
  • 提高方法被其他邏輯利用的概率,因為大方法很難重複利用

規範

  • 單元測試類以被測類的實現類為基礎加Test,如xxxBusinessImpTest
  • 單元測試類需要放在test目錄下,並且包名與被測類的路徑一致,防止idea、sonar跑覆蓋率校驗時,沒覆蓋到對應的方法。
  • 方法命名:test開頭,加真正的方法名,加場景,如testMethodOnNormal、testMethodOnException、testMethodOnLackOfMoney
  • 方法一般情況下都帶返回值,即使沒有返回值也寫boolean。
  • 若是流程性方法等,可以為void,如責任鏈設計模式時
  • 必須斷言,而不是print。斷言值、正確與否、預期異常
  • 儘可能把查資料的邏輯寫在被測方法之前,被測方法只做業務邏輯處理,不做查資料,這樣單測時需要mock的方法會比較少。
  • 公司技術體系不一定會預留單元測試時間,很少公司願意花時間在質量和維護上。都是趕著完成任務。
  • 例如3天開發,1天單元測試,1天sonar+review,1天聯調,不一定有時間做得完善。