談談如何使用好單元測試這把武器

前言

如《Unit Testing》書裏提到,

學習單元測試不應該僅僅停留在技術層面,比如你喜歡的測試框架,mocking 庫等等,單元測試遠遠不止「寫測試」這件事,你需要一直努力在單元測試中投入的時間回報最大化,儘量減少你在測試中投入的精力,並最大化測試提供的好處,實現這兩點並不容易。

和我們在日常開發中遇到的問題一樣,學會一門語言,掌握一種方法並不困難,困難的是把投入的時間回報最大化。unit test有很多基礎知識和框架,在google上一搜就一大堆,最佳實踐的方法論也非常多,本文不準備討論這些問題,而是結合在我們日常的工作,討論如何使用好單元測試這把武器。

單元測試的定義

什麼是單元測試?來自百度

單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證。至於【單元】的含義,一般來說,要根據實際情況判定具體含義,如Java裏單元指一個類等。

講人話,單元測試就是爲了驗證一個類的準確性的測試。區別於集成測試和系統測試。他是前置的,由開發人員主導的最小規模的測試。

一些學者們經過統計,還繪製出了下圖:

  • 85%的缺陷都在代碼設計階段產生;
  • 發現bug的階段越靠後,耗費成本就越高,呈指數級別的增長。

由此看來,單測代碼的編寫對於交付質量以及人工耗費成本都有極其重要的影響

常見的誤區

浪費時間,影響開發速度不同項目的開發測試時間曲線不同,你要綜合考慮你的代碼的生命週期,你debug的能力,你平時花多少時間review有問題的代碼。隨着項目的進行,這些時間會遞增,如果你想你所寫的代碼能夠一直用下去,不讓後來人吐槽這寫的什麼玩意,單元測試非常有必要。 測試應該是測試的工作開發是代碼的第一責任人,最熟悉代碼的人,在設計階段編輯單元測試,不但可以讓你更自信的交付,還可以減少測試問題的產生。同時你自己的全棧能力也有所提升。代碼不是我寫的,我不懂我們經常抱怨老代碼有坑難懂,或者是缺乏CR。其實在編寫單元測試的過程中,也是CR和學習的一個過程,對於代碼的主流程,邊界,異常等有了深入的理解。同時也是自我審視代碼規範、邏輯、設計的過程。我建議在重構中寫單測,在寫單測中重構,相輔相成。

如何寫出好的單測

方法論上,有AIR 原則 ,像空氣一樣不會被感受到即 Automatic(自動化)、Independent(獨立性)、Repeatable(可重複)。我個人的理解就是1、自動運行,通過CI集成的方式,保證單測能夠自動運行,通過assert保證單元測試的驗證結果,而不是print輸出。確保單元測試能夠自動化運行,不需要人工介入測試。 2、單元測試必須獨立,不能互相調用,也不能有依賴的順序。每個測試用例之間包保證獨立。3、不可以受運行的環境、數據庫、中間件等影響。在編寫單測的時候,需要把外部的依賴mock掉。從覆蓋率的規範上來講,不管是阿里內部還是業界,都有很多標準。

語句覆蓋率達到70%;核心模塊的語句覆蓋率和分支覆蓋率都要達到100%。 --- 《阿里巴巴Java開發手冊》
單測覆蓋度分級參考Level1:正常流程可用,即一個函數在輸入正確的參數時,會有正確的輸出Level2:異常流程可拋出邏輯異常,即輸入參數有誤時,不能拋出系統異常,而是用自己定義的邏輯異常通知上層調用代碼其錯誤之處Level3:極端情況和邊界數據可用,對輸入參數的邊界情況也要單獨測試,確保輸出是正確有效的Level4:所有分支、循環的邏輯走通,不能有任何流程是測試不到的Level5:輸出數據的所有字段驗證,對有複雜數據結構的輸出,確保每個字段都是正確的

從上面的摘錄看,語句覆蓋率和分支覆蓋率都有數值上和方法論上的要求,那在實際工作中,實踐情況如何呢?筆者曾在一個季度,工作中提交的代碼綜合增量覆蓋率幾乎達到了100%。我可以談談我的經驗和實踐。60%左右的單測覆蓋率可以非常輕鬆達到,但達到95%以上的覆蓋率,需要覆蓋各種代碼分支和異常情況等,甚至是配置和bean的初始化方法,所投入的時間非常巨大,但邊際效應遞減。我想測試toString, getter/setter這樣的方法也沒有意義。多少合適,我認爲沒有一個固定的標準。高代碼覆蓋率百分比不表示成功,也不意味着高代碼質量。該捨棄測試的部分就大膽的ignore掉。

最佳實踐

這個標題未免有些標題黨。單元測試相關的書籍、ata文章,數不勝數,我的所謂“最佳實踐”是在實際阿里工作中的一些自己踩過的坑,或者我個人認爲一些重要的點,班門弄斧,如有錯誤,歡迎討論。

1、隱藏的測試邊界值

public ApiResponse<List<Long>> getInitingSolution() {    List<Long> solutionIdList = new ArrayList<>();    SolutionListParam solutionListParam = new SolutionListParam();    solutionListParam.setSolutionType(SolutionType.GRAPH);    solutionListParam.setStatus(SolutionStatus.INIT_PENDING);    solutionListParam.setStartId(0L);    solutionListParam.setPageSize(100);    List<OperatingPlan> operatingPlanList =  operatingPlanMapper.queryOperatingPlanListByType(solutionListParam);    for(; !CollectionUtils.isEmpty(operatingPlanList);){        /*            do something            */        solutionListParam.setStartId(operatingPlanList.get(operatingPlanList.size() - 1).getId());        operatingPlanList =  operatingPlanMapper.queryOperatingPlanListByType(solutionListParam);    }    return ResponsePackUtils.packSuccessResult(solutionIdList);}

上面這段代碼,如何寫單元測試?

很自然的,我們寫單測的時候會mock掉數據庫查詢,並且查出信息。但是如果查詢的內容超過100,由於for循環進入一次,無法通過jacoco的自動覆蓋率發現。實際上沒有覆蓋這個邊界case,只能通過開發者的習慣來處理這些邊界情況。如何處理這些隱藏的邊界值,開發者不能依賴集成測試或者代碼CR,必須要在自己寫單元測試的時候考慮到這一情況,能避免後來維護的人掉坑。

2、不要在springboot測試中使用@Transactional以及操作真實數據庫

單元測試的上下文應該是乾淨的,設計transactional的初衷是爲了集成測試(如spring官網介紹):

雖然直接操作DB能更容易驗證DAO層的正確性,但是也容易被線下數據庫的髒數據污染,導致單測無法通過的問題。筆者以前遇到直連數據庫的單測代碼,經常改個5分鐘代碼,數據庫裏髒數據清一個小時。第二就是集成測試需要啓動整個應用的容器,違背了提高效率的初衷。如果實在要測DAO層的正確性,可以整合H2嵌入式數據庫。這個網上教程非常多,不再贅述。

3、單測裏時間相關的內容

筆者曾經在工作中遇到過一個極端case,一個CI平時都正常運行,有一次深夜發佈, CI跑不過,後來經過第二天check才發現有前人在單測中取了當前時間,在業務邏輯中含有夜間邏輯(夜間消息不發),導致了CI無法通過。那麼時間在單測中要如何處理呢?在使用Mockito時,可以使用mock(Date.class)來模擬日期對象,然後使用when(date.getTime()).thenReturn(time)來設置日期對象的時間。如果你使用了calendar.getInstance(),如何獲取當前時間?Calendar.getInstance()是static方法,無法通過Mockito進行mock。需要引入powerMock,或者升級到mockito 4.x才能支持:

@RunWith(PowerMockRunner.class) @PrepareForTest({Calendar.class, ImpServiceTest.class})    public class ImpServiceTest {
    @InjectMocks    private ImpService impService = new ImpServiceImpl();
    @Before    public void setup(){        MockitoAnnotations.initMocks(this);
        Calendar now = Calendar.getInstance();        now.set(2022, Calendar.JULY, 2 ,0,0,0);
        PowerMockito.mockStatic(Calendar.class);        PowerMockito.when(Calendar.getInstance()).thenReturn(now);    }}

4、final類,static類等的單元測試

如第3點提到的calendar的例子,static類的mock需要mockito4.x的版本。否則就要引入powermock,powermock不兼容mockito3.x版本,不兼容mockito 4.x版本。由於老的應用引入了非常多的mockito3.x的版本,直接使用mockito4.x對final和static類進行mock需要排包。實踐中看,JUnit、Mockito、Powermock三者之間的版本號有兼容性問題,可能會出現java.lang.NoSuchMethodError,需要根據實際的情況選擇版本進行mock。但是在新項目立項的時候,要確定好使用的mockito和junit版本,是否引入powermock等框架,確保環境穩定可用。老項目建議不要大規模改動mockito和powermock的版本,容易排包排到懷疑人生。

5、應用啓動報 Can not load this fake sdk class 的異常

這是因爲阿里的tair,metaq基於pandora容器的,fake-sdk默認是pandora模塊類加載加載的。具體原理可以參考下圖:

解決方案1,引入pandoraboot環境。@RunWith(PandoraBootRunner.class)這樣其實減慢了單測的運行速度,是違背了高效性原理的。但是相比較運行整個容器,運行pandora容器的時間大概在10s左右,還是能夠容許的。那麼有沒有不讓pandoraboot起來,純mock的方法。我個人認爲mock要比ut更優先 ,特別是有些外部依賴,經常遷移或者下線,可能改了1行代碼,需要修1個小時測試用例。tair,lindorm等中間件也沒有辦法本地起環境進行mock,直接依賴外部資源非常不優雅。解決方案2,直接mock以tair爲例:

@RunWith(PowerMockRunner.class)@PrepareForTest({DataEntry.class})public class MockTair {    @Mock    private DataEntry dataEntry;
    @Before    public void hack() throws Exception {        //solve it should be loaded by Pandora Container. Can not load this fake sdk class. please refer to http://gitlab.alibaba-inc.com/middleware-container/pandora-boot/wikis/faq for the solution        PowerMockito.whenNew(DataEntry.class).withNoArguments().thenReturn(dataEntry);    }        @Test    public void mock() throws Exception {        String value = "value";        PowerMockito.when(dataEntry.getValue()).thenReturn(value);        DataEntry tairEntry = new DataEntry();        //值相等        Assert.assertEquals(value.equals(tairEntry.getValue()));    }}

6、metaq怎麼寫單測

MessageExt的mock方法參考5,但是單測中怎麼運行一個MetaPushConsumer的bean,並調用listener方法。那就只能啓動context的上下文。託管SpringRunner的方式。

@RunWith(PandoraBootRunner.class)@DelegateTo(SpringRunner.class)public class EventProcessorTest {
    @InjectMocks    private EventProcessor eventProcessor;    @Mock    private DynamicService dynamicService;    @Mock    private MetaProducer dynamicEventProducer;
    @Test    public void dynamicDelayConsumer() throws MQClientException, RemotingException, InterruptedException, MQBrokerException {        //獲取bean        MetaPushConsumer metaPushConsumer = eventProcessor.dynamicEventConsumer();                //獲取Listener        MessageListenerConcurrently messageListener = (MessageListenerConcurrently)metaPushConsumer.getMessageListener();        List<MessageExt> list = new ArrayList<>();                //這個需要依賴PandoraBootRunner        MessageExt messageExt = new MessageExt();        list.add(messageExt);        Event event = new Event();        event.setUserType(3);        String text = JSON.toJSONString(event);        messageExt.setBody(text.getBytes());        messageExt.setMsgId(""+System.currentTimeMillis());                //測試consumeMessage方法        messageListener.consumeMessage(list, new ConsumeConcurrentlyContext(new MessageQueue()));        doThrow(new RuntimeException()).when(dynamicService).triggerEventV2(any());        messageListener.consumeMessage(list, new ConsumeConcurrentlyContext(new MessageQueue()));        messageExt.setBody(null);        messageListener.consumeMessage(list, new ConsumeConcurrentlyContext(new MessageQueue()));    }}

總結一下什麼時候使用容器:

// 1. 使用PowerMockRunner@RunWith(PowerMockRunner.class)
// 2.使用PandoraBootRunner, 啓動pandora,使用tair,metaq等 @RunWith(PandoraBootRunner.class)
// 3. springboot啓動,加入context上下文,可以直接獲取bean@SpringBootTest(classes = {TestApplication.class})

7、儘量使用ioc

使用 IOC 可以解耦對象,使得測試更加方便。經常有這樣的情況,在某個 service 中使用到某個工具類,這個工具類內的方法都是 static 的,這樣的話,測試 service 的時候就會需要連着工具類一起測試了。比如下面這段代碼:

@Servicepublic class LoginServiceImpl implements LoginService{    public Boolean login(String username, String password,String ip) {        // 校驗ip        if (!IpUtil.verify(ip)) {            return false;        }        /*          other func        */        return true;    }}

通過IpUtil校驗登錄用戶的ip信息,而如果我們這樣使用,就需要測試 IpUtil的方法, 違背了隔離性的原則。測試login方法也需要加入更多組測試數據覆蓋工具類代碼,耦合度太高。

如果稍加修改:

@Servicepublic class LoginServiceImpl implements LoginService{    public Boolean login(String username, String password,String ip) {        // 校驗ip        if (!IpUtil.verify(ip)) {            return false;        }        /*          other func        */        return true;    }}

這樣我們只需要單獨測試IpUtil類和LoginServiceImpl類就行了。測試LoginServiceImpl的時候mock掉IpUtil就可以了,這樣就隔離了IpUtil的實現。

8、不要爲了覆蓋率測沒意義的代碼

比如toString,比如getter,setter,都是機器生成的代碼,單測沒意義。如果是爲了整體測試覆蓋率的提高,那麼請在CI中排掉這部分包:

9、如何測試void方法

  • 如果void方法內部造成了數據庫的變更,比如insertPlan(Plan plan),並通過H2操作過數據庫,那麼可以驗證數據庫的條數變化等,校驗void方法的正確性。
  • 如果void方法調用了函數,可以通過verify驗證方法得到調用次數:
userService.updateName(1L,"qiushuo");verify(mockedUserRepository, times(1)).updateName(1L,"qiushuo");
  • 如果void方法可能會造成拋出異常。

可以通過dothrow來 mock方法拋出的異常:

@Test(expected = InvalidParamException.class)public void testUpdateNameThrowExceptionWhenIdNull() {   doThrow(new InvalidParamException())      .when(mockedUserRepository).updateName(null,anyString();   userService.updateName(null,"qiushuo");}

參考資料

1、https://scottming.github.io/2021/04/07/unit-testing/

2、https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#integration-testing

3、https://yuque.antfin-inc.com/fangqintao.fqt/pu2ycr/eabim6

4、https://yuque.antfin-inc.com/aone613114/en7p02/pdtwmb

點擊立即免費試用雲產品 開啓雲上實踐之旅!

原文鏈接

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

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