基於mockito做有效的單元測試
阿新 • • 發佈:2019-12-31
概述
本文講解的主要是有效和單元的思想,並不是說如何編寫單元測試,用於改善和提高開發效率、編碼風格、編碼可讀性和單測效率,不盲目追求覆蓋率。
背景
-
現在很多單元測試只是利用@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天聯調,不一定有時間做得完善。