5個編寫技巧,有效提高單元測試實踐

簡介: 結合單測的實踐,本文總結了幾點單元測試的好處與編寫技巧,希望分享給大家。

1. 什麼是單元測試

“在計算機編程中,單元測試又稱爲模塊測試,是針對程序模塊來進行正確性檢驗的測試工作。程序單元是應用的最小可測試部件。在過程化編程中,一個單元就是單個程序、函數、過程等;對於面向對象編程,最小單元就是方法,包括基類、抽象類、或者派生類中的方法。”

摘錄來自維基百科

單元測試(Unit Testing)顧名思義就是測試一個單元,這裏的單元通常指一個函數或類,區別於集成測試中的模塊和系統。集成測試的測試過程通常存在跨系統模塊的調用,是一種端到端的測試;而單元測試關注對象的顆粒度較小,用來保障一個類或者函數是否按照預期正確的執行。

2. 爲什麼要寫單元測試

作爲保障代碼質量的有效手段之一,公司也在積極的推進單元測試。結合單測的實踐,總結了以下幾點單元測試的好處,認真實踐過的同學,應該會有共鳴。

2.1 減少BUG,釋放資源

上面這張圖,旨在說明兩個問題:

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

單元測試是所有測試環節中最底層的一類測試,是第一個環節,也是最重要的一個環節。大多數缺陷是Coding階段引入,修復的成本隨着軟件生命週期進展不斷上升。日常研發中,在交付測試前我們對功能單元進行主流程、各種邊界及異常單元測試的編寫,能有效幫助我們發現代碼中的缺陷。相對於後期來自測試同學或者線上異常反饋,再來進行排查定位、修復發佈的成本來說,單元測試的性價比是極高的。單元測試可以有效地保障代碼質量,給我們帶來質量口碑的同時,也爲他人和自己減少因修復低級BUG而投入的時間,能夠將精力分配到其他更有意義的事情上。

2.2 爲代碼重構保駕護航

面對項目中歷史遺留的腐化代碼,我們都有推倒重來的衝動,但它畢竟經過了長時間的穩定性考驗,我們又擔心重構之後出現問題。這是我們經常會遇到的境況,當要重構不是非常熟悉的祖傳代碼,又沒有充足的測試資源保障的時候,重構引入缺陷的風險還是很大的。

那如何保證重構不出錯呢?Martin Fowler在《重構:改善既有代碼的設計》提到:

重構是很有價值的工具,但只有重構還不行。要正確地進行重構,前提是得有一套穩固的測試集合,以幫我發現難以避免的疏漏。即便有工具可以幫我自動完成一些重構,很多重構手法依然需要通過測試集合來保障。

除了需要對業務流程有足夠的瞭解並且熟練掌握各種設計思想、模式之外,單元測試是保證重構不出錯的有效手段。當重構完成之後,如果新的代碼仍然能通過單元測試,那就說明代碼原有正確的邏輯未被破壞,原有的外部可見行爲沒有發生改變。單元測試給了我們重構的信心與底氣。

2.3 既是編寫單測也是CodeReview

單元測試和CR是保障代碼質量行之有效的兩個手段。在研發交付過程中,通常我們提交CR的時機較爲滯後,評審同學指出待優化或修復的時間點也較晚,修復的風險和成本上都有所增加。

我們編寫編碼單元測試過程,其實也是自我CodeReview的過程。在這個過程中,我們對功能單元主流程、邊界及異常進行測試,也在自我審視代碼的規範、邏輯及設計。既提高了後續提交CR的質量與評審效率,也將問題提前暴露。

2.4 便於調試與驗證

當項目存在多個協同方時,我們只需按照約定mock出依賴項的數據,無需等所有依賴的應用接口開發部署完成後再進行調試,提高了我們協同的效率與質量。我們將功能需求進行拆解,在開發完每一個小功能點時,即可進行單元測試的編寫與驗證,這種習慣能讓我們對編碼得到快速的驗證反饋;同時,在開發完整個功能時,我們需要跑一遍項目所有的單測用例,可以清晰的感知,本次整個功能需求的改動是否對已有業務case造成影響。

如果我們能夠保障每個類、函數都能通過單元測試按照預期業務邏輯執行,那整合後的功能模塊或系統,出問題的概率都能大大降低。從這個意義上講,單元測試也對集成測試、系統測試做了有力的支撐。

2.5 驅動設計與重構

設計和編碼的時候,我們很難將所有的問題都想清楚。那我們知道,評判代碼質量重要的的標準之一就是代碼的可測性。如果對一段代碼進行單測,發現難於編寫,需要編寫的case非常多,或者當前的測試框架無法mock依賴對象,需要依賴其他具備高級特性的測試框架時,我們需要回過頭來審視代碼,是否編碼設計得不合理,導致代碼的可測性不高。這是個正反饋的過程,讓我們有針對性的進行重新設計與重構。

3. 怎樣編寫單元測試

3.1 單元測試框架的構建

3.1.1 單元測試框架JUnit

JUnit是目前Java語言應用最爲廣泛的單元測試框架,用於編寫和運行可重複的自動化測試,它包含以下特性:

  • 用於測試期望結果的斷言(Assertion)
  • 用於共享共同測試數據的測試工具
  • 用於方便的組織和運行測試的測試套件
  • 圖形和文本的測試運行器

多數Java的開發環境都已經集成了JUnit作爲單元測試的工具,開源框架對JUnit 都有相應的支持

3.1.2 單元測試Mock框架

項目中依賴關係往往往非常複雜,單元測試Mock框架做的事就是模擬被測試類的依賴項,提供預期的行爲和狀態,使得我們的單測可以聚焦在被測試類本身,而不必受到依賴項的複雜度的影響。

這裏我們討論常用的Mockito與PowerMock,兩者都是作爲單元測試模擬框架,模擬應用中複雜的依賴對象。Mockito基於動態代理的方式實現,PowerMock在Mockito基礎上增加了類加載器以及字節碼篡改技術,使其可以實現完成對private/static/final方法的Mock。

公司使用JaCoCo來做單元覆蓋率的檢測,當我們使用支持字節碼篡改的mock工具的時候,可能會造成:

  • 測試失敗,mock工具與jacoco同時修改字節碼時引入的衝突
  • 某些類的覆蓋率爲0

所以我們推薦使用Mockito來作爲我們的單元測試Mock框架,原因有二:

  1. 在版本3.4.0以後,Mockito支持靜態方法的mock。並且作爲SpringBootTest默認集成的Mock工具,所以建議大家使用高版本的Mockito,並通過它來完成靜態方法的Mock
  2. 不提倡使用PowerMock,並不是一味追求單測覆蓋率,而是當我們需要使用到具備高級特性mock工具時,我們需要審視代碼的合理性,並嘗試進行優化重構,使其具備較好的可測性

3.1.3 依賴引入

3.1.3.1 添加JUnit的maven依賴

  • Springboot項目
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
  • SpringMVC項目
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

3.1.3.2 單測Mock框架的引入

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>4.7.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <version>4.7.0</version>
    <scope>test</scope>
</dependency>

3.2 單測方法的命名

3.2.1 單元測試類的規範

  • 單元測試類需要放在工程的test目錄下,比如xxx/src/test/java
  • 單測類的命名按照規範,應以被測類名開頭,並追加Test作爲結尾,比如ContentService  ->  ContentServiceTest

3.2.2 單元測試方法規範

3.2.2.1 測試方法的命名

好的單元測試方法名,能讓我們快速知道測試的場景、意圖及驗證的預期。

建議採用should_{預期結果}_when_{被測方法}_given_{給定場景}

舉個🌰

@Test
public void should_returnFalse_when_deleteContent_given_invokeFailed() {
    ...
}

反例

@Test
public void testDeleteContent() {
    ...
}

3.2.2.2 單測方法實現分層

單測方法的實現如果分層清晰,能讓代碼便於理解,一目瞭然,同時也能提高後續的CR的效率

這裏我們建議採用given-when-then的三段落結構

舉個🌰

@Test
public void should_returnFalse_when_deleteContent_given_invokeFailed() {
    // given
    Result<Boolean> deleteDocResult = new Result<>();
    deleteDocResult.setEntity(Boolean.FALSE);
    when(docManageService.deleteContentDoc(anyLong())).thenReturn(deleteDocResult);
    when(docManageService.queryContentDoc(anyLong())).thenReturn(new DocEntity());
    // when
    Long contentId = 123L;
    Boolean result = contentService.deleteContent(contentId);
    // then
    verify(docManageService, times(1)).queryContentDoc(contentId);
    verify(docManageService, times(1)).deleteContentDoc(contentId);
    Assert.assertFalse(result);
}

3.3 單測方法的示例

3.3.1 代碼案例

public class SnsFeedsShareServiceImpl {
    private SnsFeedsShareHandler snsFeedsShareHandler;
    @Autowired
    public void setSnsFeedsShareHandler(SnsFeedsShareHandler snsFeedsShareHandler) {
        this.snsFeedsShareHandler = snsFeedsShareHandler;
    }
    public Result<Boolean> shareFeeds(Long feedsId, String platform, List<String> snsAccountList) {
        if (!validateParams(feedsId, platform, snsAccountList)) {
            return ResponseBuilder.paramError();
        }
        try {
            Result<Boolean> snsResult = snsFeedsShareHandler.batchShareFeeds(feedsId, platform, snsAccountList);
            if (Objects.isNull(snsResult) || !snsResult.isSuccess() || Objects.isNull(snsResult.getModel())) {
                return ResponseBuilder.buildError(ResponseEnum.SNS_SHARE_SERVICE_ERROR);
            }
            return ResponseBuilder.successResult(snsResult.getModel());
        } catch (Exception e) {
            LOGGER.error("shareFeeds error, feedsId:{}, platform:{}, snsAccountList:{}",
                    feedsId, platform, JSON.toJSONString(snsAccountList), e);
            return ResponseBuilder.systemError();
        }
    }
    // 省略代碼...
}

3.3.2 單元測試代碼案例

@RunWith(MockitoJUnitRunner.class)
public class SnsFeedsShareServiceImplTest {
    @Mock
    SnsFeedsShareHandler snsFeedsShareHandler;
    @InjectMocks
    SnsFeedsShareServiceImpl snsFeedsShareServiceImpl;
    @Test
    public void should_returnServiceError_when_shareFeeds_given_invokeFailed() {
        // given
        Result<Boolean> invokeResult = new Result<>();
        invokeResult.setSuccess(Boolean.FALSE);
        invokeResult.setModel(Boolean.FALSE);
        when(snsFeedsShareHandler.batchShareFeeds(anyLong(), anyString(), anyList())).thenReturn(invokeResult);
        // when
        Long feedsId = 123L;
        String platform = "TEST_SNS_PLATFORM";
        List<String> snsAccountList = Collections.singletonList("TEST_SNS_ACCOUNT");
        Result<List<String>> result = snsFeedsShareServiceImpl.shareFeeds(feedsId, platform, snsAccountList);
        // then
        verify(snsFeedsShareHandler, times(1)).batchShareFeeds(feedsId, platform, snsAccountList);
        Assert.assertNotNull(result);
        Assert.assertEquals(result.getResponseCode(), ResponseEnum.SNS_SHARE_SERVICE_ERROR.getResponseCode());
    }
    
}

3.4 單測的編碼技巧

3.4.1 Mock依賴對象

@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {
    @Mock
    DocManageService docManageService;
    @InjectMocks
    ContentService contentService;
    
    ...
}
  • MockitoJUnitRunner使Mockito的註解生效或者使用初始化方法MockitoAnnotations.initMocks(this)
  • 利用@Mock模擬各種依賴對象
  • 使用@InjectMocks將mock出的依賴對象注入到目標測試對象中。以上述代碼爲例,單測中將docManageService注入到contentService

當然我們也可以使用直接初始化或者@Spy的方式來模擬對象,然後使用Setter方法來進行模擬對象的注入,這裏介紹了較爲簡便的方式。

3.4.2 Mock返回值

3.4.2.1 Mock無返回值方法

doNothing().when(contentService.deleteContent(anyLong()));

3.4.2.2 Mock方法返回值

// given
Result<Boolean> deleteResult = new Result<>(Boolean.FALSE);
when(contentService.deleteContent(anyLong())).thenReturn(deleteResult);

3.4.2.3 執行方法的真實調用

when(contentService.deleteContent(anyLong())).thenCallRealMethod();

3.4.2.4 Mock方法調用異常

when(contentService.deleteContent(anyLong())).thenThrow(NullPointerException.class);

3.4.3 自動化驗證

3.4.3.1 驗證依賴方法的調用

// 驗證調用方法的入參,指定爲"testTagId"
verify(tagOrmService).queryByValue("testTagId");
// 驗證queryByValue方法被調用了2次
verify(tagOrmService, times(2)).queryByValue(anyString());

3.4.3.2 驗證返回值

對驗證方法的返回值或異常進行驗證
// then
Assert.assertNotNull(result);
Assert.assertEquals(result.getResponseCode(), 200);
// 其他常用的斷言函數
Assert.assertTrue(...); 
Assert.assertFalse(...);
Assert.assertSame(...);  
Assert.assertEquals(...);    
Assert.assertArrayEquals(...);

3.4.4 其他單測技巧處理

3.4.4.1 使用Mockito模擬靜態方法

MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);
tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("tag");

3.4.4.2 處理Mockito註冊靜態方法範圍

在執行mvn test時,如果有多個測試方法mock了Mockito.mockStatic(TagHandler.class),會報錯,因爲靜態方法是類級別的,會出現註冊多次的情況。可以參考下面兩種解法:

  1. 使用@BeforeClass@AfterClass

@BeforeClass註解方法:只被執行一次;運行junit測試類時第一個被執行的方法

@AfterClass註解方法:只被執行一次;運行junit測試類時最後一個被執行的方法

示例:

@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {
    @Mock
    DocManageService docManageService;
    @InjectMocks
    ContentService contentService;
    private static MockedStatic<TagHandler> tagHandlerMockedStatic = null;
    @BeforeClass
    public static void beforeTest() {
        tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);
        tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");
    }
    // 省略測試方法
    @AfterClass
    public static void afterTest() {
        tagHandlerMockedStatic.close();
    }
}

 

  1. try-with-resources構造中定義模擬
@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {
    @Mock
    DocManageService docManageService;
    @InjectMocks
    ContentService contentService;
    @Test
    public void should_returnEmptyList_when_queryContentTags_given_invokeParams() throws Exception {
        try (MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class)) {
            tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");
            // 省略單測方法具體實現
            ...
        }
    }
}

3.4.4.3 如何mock一條鏈式調用

public T select(QueryCondition queryCondition) throws Exception {
    LindormQueryParam params = queryCondition.generateQueryParams();
    if (Objects.isNull(params)) {
        LOGGER.error("Invalid query condition:{}", queryCondition.toString());
        return null;
    }
    Select select = tableService.select()
            .from(params.getTableName())
            .where(params.getCondition())
            .limit(1);
    QueryResults results = select.execute();
    return convert(results.next());
}

Mockito提供了形如tableService.select().from(params.getTableName()).where(params.getCondition()).limit(1)鏈式調用解決辦法,mock對象的時候增加參數RETURNS_DEEP_STUBS

@Test
public void should_returnNull_when_select_given_invalidQueryCondition() throws Exception {
    // when
    TableService tableService = mock(TableService.class, RETURNS_DEEP_STUBS);
    when(tableService.select().from(anyString()).where(any()).limit(anyInt())).thenReturn(null);
    Object result = lindormClient.select(new QueryCondition());
            
    // then
    Assert.isNull(result);
}

3.5 單測生成插件

IDEA有兩款比較好用的單測自動生成插件TestMeDiffblue,這裏主要介紹TestMe,如果大家有比較好的插件也可以推薦。

  1. 安裝:在IDEA設置中的Plguins插件裏搜索TestMe,下載安裝即可。
  2. 使用:在code按鈕找到入口,或者直接使用快捷鍵option+shift+Q

  1. 生成的代碼如下

自動生成插件方便初始化部分代碼,可以提升單測編寫的效率,但是也存在侷限性:單測名稱規範、具體實現等還是需要我們完善、補充後才能正常使用

4. 如何落地單元測試

4.1 清晰單測的價值認知

不難發現,公司內的項目還是外網開源項目,少有工程具備完善、高質量的單元測試。上文講了爲什麼要寫單測,這裏就不再贅述了。短期來看,單測無疑會帶來開發工作量和開發時長的增加,但是我們要從整個迭代週期來看單測的優勢。從最終的效果來看,堅持單元測試會有效的減少迭代中的缺陷數以及縮短需求的交付週期。

4.2 將單測納入流程規範

4.2.1 將單元測試納入CR標準

以往我們CR只關注核心的業務代碼,大多數情況下,我們在評審中可以指出代碼較爲明顯的缺陷或者不合理的設計,但是各種條件case、邊界及異常情況很難通過肉眼review出來。如果提交的CR中包含完善、高質量的單元測試,提交、評審雙方的的信心都會增強。

4.2.2 發佈管控

當我們提交代碼後,CI可以設置運行該分支的單元測試。在發佈流程中,添加單測相關的管控,比如單元測試通過率以及單元測試增量覆蓋率等

4.3 單測工作量評估

對於單元測試工作量的評估,沒有一個固定的標準,主要視業務邏輯複雜度而定。一般來說,如果之前沒有編寫過單元測試,在熟悉階段可以根據需求的工作量對應增加20%~30%;後期熟練掌握後,增加需求工作量的10%就足夠了。當業務需求涉及的case較多,單測需要覆蓋這些必要流程時,我們評估工作量時,可以給自己加些時間來保障高質量的單測。

5. 後記

單元測試是一件知易行難的事情,公司也在積極宣導和建設單測文化。工作方式的改變其實難度並不大,難的是能夠建立一致的共識,並從心底認可單元測試的價值,只有這樣纔能有效落地。

原文鏈接:https://click.aliyun.com/m/1000364860/

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

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