云數(shù)據(jù)庫,誰才是領導者?
本文更偏向?qū)嵺`而非方法論,所提及的SpringBoot單元測試寫法亦并非官方解,僅僅是筆者自身覺得比較方便、效率較高的一種寫法。每個團隊甚至團隊內(nèi)的每位開發(fā)可能都有自己的寫法習慣和風格,只要能實現(xiàn)單元測試的效果,就沒必要糾結(jié)于寫法的簡單抑或復雜。這里也歡迎各位大佬們發(fā)表看法或分享自己的單測心得,幫助像筆者這樣的新人快速成長。
一 為什么要寫單元測試?
老實承認,我曾經(jīng)是這樣的可能現(xiàn)在也還是這樣。作為非科班出身的筆者,研究生畢業(yè)后就立即進入了同在杭州的xx廠,先后參與了內(nèi)部Devops平臺建設和xx云Paas項目開荒,在這兩個項目中,開發(fā) > 測試是很正常的場景,甚至部分測試也是原開發(fā)友情客串的:由于缺少專業(yè)的測試人員,開發(fā)往往需要兼顧集成測試甚至是線上測試的活兒。為了提高效率,我將一部分常用的測試用例維護在了內(nèi)部的自動化測試平臺上。即便如此,我仍能清晰地感覺到,測試所能覆蓋的場景屈指可數(shù),以至于每次自信地上線大特性后,都會因一些奇怪的問題而定位到大半夜。幸虧后面遇到了一位資深大佬,在code review時,他直接點出我不寫單元測試的壞習慣,并用自身慘痛的線上教訓反復強調(diào)單測的重要性。
當然上述只是我的親身經(jīng)歷,勉強作為日常閑聊的談資。如果想要深入理解單元測試的重要性,推薦Google上搜索the importance of unit test關鍵字,可以感受下不同國家、不同領域的程序員對單元測試的不同理解,想必能有更大的收獲。
二 為什么推薦鏈路思想?
- 應該如何設計測試用例?
- 應該如何編寫測試用例?
- 測試用例的質(zhì)量該如何判定?
剛開始學習寫單元測試,我也曾參考并嘗試過網(wǎng)上五花八門的寫法。這些寫法可能用到了不同的單測框架,也可能側(cè)重了不同的代碼環(huán)節(jié)(例如特定的某個service方法)。一開始我為自己能夠熟練使用多種單測框架而沾沾自喜,但隨著工作的推進,我逐漸意識到,單元測試中重要的并不是框架選型,而是如何設計一套優(yōu)秀的用例。之所以用"一套"而不是"一個",是因為在我們的業(yè)務代碼中,邏輯往往并非"一帆風順",有許多if-else會妝點我們的業(yè)務代碼。顯然對于這類業(yè)務代碼,"一個"測試用例無法完全滿足所有可能出現(xiàn)的場景。如果為了偷懶,嘗試僅僅用"一個"用例去覆蓋主流程,無異于給自己埋了個雷——線上場景可沒"一個"用例這么簡單!
我開始專注于測試用例的設計,從輸入輸出開始,重新審視曾經(jīng)開發(fā)過的代碼。我發(fā)現(xiàn),如果將某個controller方法作為入口,那這一套業(yè)務流程可以當做一條鏈路,而上下文中所關聯(lián)的service層、dao層、api層的各方法都可以作為鏈路上的各環(huán)節(jié)。通過繪制鏈路圖,將各環(huán)節(jié)根據(jù)是否關聯(lián)外部系統(tǒng)大致分成黑、白兩類,整套業(yè)務流程和各環(huán)節(jié)的潛在分支便會變得清晰,測試用例便從"一個"自然而然地變成了"一套"。此處多提一嘴,鏈路思想設計用例的基礎是結(jié)構清晰、圈復雜度可控制的代碼風格,如果開發(fā)的時候依然尊崇"論文式"、"一刀流",在單個方法內(nèi)"長篇大論",那鏈路式將是一個巨大的負擔。
編寫測試用例其實不是一件費勁的事,對于深耕業(yè)務代碼的開發(fā)而言,編寫測試用例便像是做一盤小菜,舉手可為。于我而言,如今寫測試用例所花費的時間甚至沒有設計測試用例的時間長(凸顯用例設計的重要性但也有可能是我對測試用例的設計還不夠熟練)。在測試框架選型上,我更習慣于Junit+Mockito的組合,原因僅僅是熟悉與簡單,且參考文檔比比皆是。如果各位已經(jīng)有自己習慣的框架和寫法,也不必照搬本文所提及的東西,畢竟單測是為了better code,而不是自找麻煩。
但無論測試用例如何設計或是如何編寫,我始終認為,在不考慮測試代碼的風格和規(guī)范的前提下,衡量測試用例質(zhì)量的核心指標是分支覆蓋率。這也是我推薦鏈路思想的一大原因——從入口出發(fā),遍歷鏈路上各個環(huán)節(jié)的各個分支,遇到阻礙就Mock;相比于分別單測各個獨立方法,單測鏈路所需要的入?yún)⒑统鰠⒏忧逦?,更是大大?jié)省了編寫測試代碼所需的時間成本!計算分支覆蓋率的工具有很多,例如本地的JaCoCo或是各類云化測試工具。試想,每當看到單測完美地覆蓋了自己所提交的特性代碼時,心里是不是放心了許多?
.
三 如何用鏈路思想設計/構造單測?
全鏈路壓測簡單來說,就是基于實際的生產(chǎn)業(yè)務場景、系統(tǒng)環(huán)境,模擬海量的用戶請求和數(shù)據(jù)對整個業(yè)務鏈進行壓力測試,并持續(xù)調(diào)優(yōu)的過程,本質(zhì)上也是性能測試的一種手段。... 通過這種方法,在生產(chǎn)環(huán)境上落地常態(tài)化穩(wěn)定壓測體系,實現(xiàn)IT系統(tǒng)的長期性能穩(wěn)定治理。
如果將完整的業(yè)務流程視作全鏈路,那作為業(yè)務鏈上的一環(huán),即某個后端服務,它其實也是一個微鏈路。這里以自上而下的開發(fā)流程為例,對于新增的功能接口,我們會習慣性地由controller開始設計,然后構建service層、dao層、api層,最后再錦上添花地加些aop。如果以鏈路思想,將復雜的流程拆成各個鏈路的各個環(huán)節(jié),那這樣的代碼功能清晰,維護起來也相當方便。我非常認同 限制單個方法行數(shù)<=50 的代碼門禁,對于長篇大論的代碼“論文”,想必沒有哪位接手的同學臉上能露出笑容的;針對這類代碼,我認為clean code的優(yōu)先級比補充單測用例更高,連邏輯都無法理清,即便硬著頭皮寫出單測用例,后續(xù)的調(diào)試和維護工作量也是不可預料的(試想,假如后面有位A同學接手了這塊代碼,他在“論文”中加了xx行導致ut失敗了,他該如何去定位問題)。
當然,基于鏈路思想的開發(fā)還遠遠不夠,在補充單測用例時,我們同樣也能用鏈路思想來構造測試用例。測試用例的要求很簡單,需要覆蓋controller、service等自主編寫的代碼(多分支場景也需要完全覆蓋),對于周邊關聯(lián)的系統(tǒng)可以采用Mock進行屏蔽,對于Dao層的SQL可以視需求決定是否Mock。秉承這個思路,我們可以對“用戶買豬”圖進行改造,將允許Mock的環(huán)節(jié)涂灰,從而變成我們在編寫單元測試用例時所需要的“虛擬用戶買豬”圖。
四 快速寫法實踐案例
1 快速寫法的核心步驟有哪些?
設計測試用例的輸入與預期輸出
確定鏈路上的全部Mock點
收集Mock點的模擬返回數(shù)據(jù)
a. 是否與api層對應方法的期望返回值匹配: 不能把從豬廠返回的Mock數(shù)據(jù)用牛肉替代
b. 是否與模擬輸入數(shù)據(jù)匹配:用戶需要1斤豬肉,不能返回5斤豬肉的數(shù)據(jù)
c. 是否與api層的所有分支匹配:部分api層會對返回值進行響應碼(2xx || 3xx || 4xx)校驗,這類場景便需要構造不同響應碼的Mock數(shù)據(jù)
2【開發(fā)篇】真實用戶買豬
該項目基于PandoraBoot構建,手動升級SpringBoot版本至2.5.1,使用Mybatis-plus組件簡化Dao層開發(fā)過程。下面選取了上文圖中所涉及的重要方法進行展示,僅實現(xiàn)了簡單的業(yè)務流程,系統(tǒng)框架和工程結(jié)構可以參考代碼倉。
業(yè)務對象
/** * 豬肉庫存的數(shù)據(jù)庫實體類 */public class PorkStorage { private Long id; private Long cnt;}
PorkInst.java - 豬肉實例,由倉庫打包后生成
/** * 豬肉實例,由倉庫打包后生成 **/public class PorkInst { /** * 重量 */ private Long weight; /** * 附件參數(shù),例如包裝類型,寄送地址等信息 */ private Map<String, Object> paramsMap;}
業(yè)務代碼
public class PorkController { private PorkService porkService; public ResponseEntity<PorkInst> buyPork( Long weight, 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} - 指定數(shù)量的豬肉實例 * @throws BaseBusinessException 如果豬肉庫存不足,返回異常,同時后臺告知工廠 */ PorkInst getPork(Long weight, Map<String, Object> params);}
PorkStorageDao.java
public interface PorkStorageDao extends BaseMapper<PorkStorage> { PorkStorage queryStore();}
PorkStorageDao.xml
"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
4jpublic class FactoryApiImpl implements FactoryApi { 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
4jpublic class WareHouseApiImpl implements WareHouseApi { 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【單測篇】虛擬用戶買豬
單測依賴
<!-- test --> <dependency> <groupId>com.taobao.pandora</groupId> <artifactId>pandora-boot-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-all</artifactId> <version>1.10.19</version> <scope>test</scope> </dependency>
寫法思路
在閱讀下面的內(nèi)容前,強烈建議先學習Junit和Mockito的基本用法和運行原理,包括但不限于下文寫法中可能涉及的注解:
Junit原生流Method注解:@Before 、@Test、@After
Mockito原生Field注解:@Mock、@InjectMocks、@Spy
在已知待單測業(yè)務鏈路的前提下,寫法可以簡要歸納為以下幾步:
1. 初步設計單測用例框架。包括setup、teststep、teardown三步,setup負責處理一些全局必要的單測前置邏輯(例如Mock數(shù)據(jù)插入和環(huán)境準備),teststep承載單測用例的主體(要求以Assert類近似的斷言語句為結(jié)尾),teardown負責處理一些全局必要的收尾邏輯(例如Mock數(shù)據(jù)刪除和環(huán)境釋放)
-
非Mock點方法:對于鏈路中非入口的環(huán)節(jié)(通常將controller作為入口,其他方法即為非入口),需要標注@Spy以聲明該對象在單測鏈路中為監(jiān)聽狀態(tài),即需要正常走完流程。此處根據(jù)方法內(nèi)是否引用Mock點方法進一步分成兩類。
-
該方法內(nèi)引用了其他Mock點方法,需要在@Spy的基礎上額外標注@InjectMocks,聲明該對象在單測鏈路中需要被注入其他Mock對象。
- 該方法內(nèi)未引用其他Mock點方法,無需進行其他操作。
-
Mock點方法:標注@Mock以聲明該對象在單測鏈路中需要被Mock,可以通過org.mockito.Mockito類內(nèi)的一系列static方法手動注入Mock值(ep. when(A()).thenReturn(B))。
3. 編寫單測用例主體。在teststep中從controller層發(fā)起方法調(diào)用,最終通過Assert斷言結(jié)果判斷用例的成功與否。除了普通的返回值校驗場景外,Junit也支持用@Test(expected = xxException.class)來聲明該用例期望發(fā)生的異常類型。最后還是建議寫完單測后能夠以注釋的形式說明該單測所支持的場景和預期結(jié)果的大致說明,方便以后自己和其他接手的同學能夠快速了解這個單測用例的相關信息。
這里仍以"用戶買豬"的場景為例,依照鏈路思想,當服務端收到用戶購買豬肉的請求時,我們可以構造出如下分支場景:
-
controller層存在可能出口,即weight == null。據(jù)此生成測試用例A,命名為testBuyPorkIfWeightIsNull,實際入?yún)⒅衱eight==null,期望接口拋出異常;
-
按鏈路進入到PigServiceImpl中,存在可能出口,即hasStore() == false。據(jù)此生成測試用例B,命名為testBuyPorkIfStorageIsShortage,實際入?yún)⒅衱eight必需大于庫存值(如代碼中setup預設庫存為10,虛擬用戶請求了20),期望接口拋出異常;
-
按鏈路繼續(xù)執(zhí)行,發(fā)現(xiàn)正常出口。據(jù)此生成測試用例C,命名為testBuyPorkIfResultIsOk,實際入?yún)⒅衱eight必須小于庫存值(如代碼中setup預設庫存為10,虛擬用戶請求了5),期望接口返回與入?yún)⑾嗥ヅ涞姆祷刂狄恢?,即正常返回了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 */@Slf4jpublic class PorkControllerTest { /** * controller入口,由于是鏈路入口,無需用@Spy監(jiān)聽 */ @InjectMocks private PorkController porkController; /** * 接口類型的鏈路環(huán)節(jié)用實現(xiàn)類初始化代替, @Spy需要手動初始化避免initMocks時失敗 * 注:鏈路上每一環(huán)都必須聲明,即使測試用例中并沒有被顯性調(diào)用 */ @InjectMocks @Spy private PorkServiceImpl porkService = new PorkServiceImpl(); /** * 待Mock的鏈路環(huán)節(jié),下同 */ @Mock private PorkStorageDao porkStorageDao; @Mock private FactoryApi factoryApi; @Mock private WareHouseApi wareHouseApi; /** * 預置數(shù)據(jù)可直接作為類變量聲明 */ private final Map<String, Object> mockParams = new HashMap<String, Object>() {{ put("user", "system_user"); }}; @Before public void setup() { // 必要: 初始化該類中所聲明的Mock和InjectMock對象 MockitoAnnotations.initMocks(this); // Mock預置數(shù)據(jù)并綁定相關方法(適用于有返回值的方法) 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數(shù)據(jù)清理或資源釋放 } /** * 當傳入?yún)?shù)為null時,拋出業(yè)務異常 * * @throws BaseBusinessException */ @Test(expected = BaseBusinessException.class) public void testBuyPorkIfWeightIsNull() { porkController.buyPork(null, mockParams); } /** * 當后臺庫存不滿足需求時,拋出業(yè)務異常 * * @throws BaseBusinessException */ @Test(expected = BaseBusinessException.class) public void testBuyPorkIfStorageIsShortage() { porkController.buyPork(20L, mockParams); } /** * 正常購買時返回業(yè)務結(jié)果 */ @Test public void testBuyPorkIfResultIsOk() { Long expectWeight = 5L; ResponseEntity<PorkInst> res = porkController.buyPork(expectWeight, mockParams); // 此處第一次校驗接口返回狀態(tài)是否符合預期 Assert.assertEquals(HttpStatus.OK, res.getStatusCode()); Long actualWeight = Optional.of(res).map(HttpEntity::getBody).map(PorkInst::getWeight).orElse(-99L); // 此處第二次校驗接口返回值是否符合預期 Assert.assertEquals(expectWeight, actualWeight); }}
推薦閱讀:
單元測試的意義是什么?:https://www.zhihu.com/question/49530527
Better code, faster: 8 reasons why you should use unit testing :https://fortegrp.com/the-importance-of-unit-testing/
阿里云云原生攜10+技術專家?guī)怼对圃c云未來的新可能》
如果說云原生代表了云計算的今天,那么云計算的未來會是什么樣?我們將本次阿里云云原生專場的技術專家們分享內(nèi)容實錄匯集本書,希望與更多的開發(fā)者共同探索“云未來,新可能”。