單元測試實踐(SpringCloud+Junit5+Mockito+DataMocker)

網上看過一句話,單元測試就像早睡早起,每個人都說好,但是很少有人做到。從這麼多年的項目經歷親身證明,是真的。
這次藉着項目內實施單元測試的機會,記錄實施的過程和一些總結經驗。

項目情況

首先是背景,項目是一個較大型的項目,多個團隊協作開發,採用的是SpringCloud作爲基礎微服務的架構,中間件涉及Redis,MySQL,MQ等等。新的起點開始起步,團隊中討論期望能夠利用單元測試來提高代碼質量。單元測試的優點很多,但是我覺得最終最終的目標就是質量,單元測試代碼如果最終沒有能夠提高項目質量,說明過程是有問題或者團隊沒有真正接納方法,不如放棄來節省大家的開發時間。
一說到單元測試大家肯定會先想起TDD。TDD(Test Dirven Development,測試驅動開發)是以單元測試來驅動開發的方法論。

  1. 開發一個新功能前,首先編寫單元測試用例
  2. 運行單元測試,全部失敗(紅色)
  3. 編寫業務代碼,並且使對應的單元測試能夠通過(綠色)
  4. 時刻維護你的單元測試,使其始終可運行

一個團隊一開始就直接實施TDD的可能性是比較小的,因爲適合團隊的研發流程、測試底層框架封裝、單元測試原則與規範都還沒有敲定或者摸索出最佳的實踐。直接一開始就完整實施,往往過程會變形,最終目標慢慢會偏離正軌,整個團隊也不願意再接受單元測試。所以建議是逐步開始,讓團隊切身能夠體會到單元測試帶來的收益再慢慢加碼。

我們的項目基礎技術架構是基於SpringCloud,做了一些基礎的底層封裝。項目之間的調用都是基於Feign,各個項目都是規範要提供各自的Feign接口以及Hystrix的FallbackFactory。我們將對於外部的調用都是封裝在底層的service中。

單元測試範圍

一個項目需要實施單元測試,首先要界定(或者說澄清)單元測試負責的範圍。最常見的疑惑就是與外部系統或者其他中間件的關聯,單元測試是否要實際的調用其他中間件/外部系統。
我們先來看看單元測試的定義:

Unit tests are typically automated tests written and run by software developers to ensure that a section of an application (known as the "unit") meets its design and behaves as intended.

單元測試首先應當是自動化的,由開發者編寫,爲了保證代碼片段(最小單元)是按照預期設計實現的。我們理解就是說單元測試要保障的是項目(代碼片段邏輯)自身按照設計意圖正確執行,所以確認了單元測試的範圍僅限於單個項目內部,因此要儘量屏蔽所有的外部系統或中間件。代碼的業務邏輯覆蓋80%-90%,其他部分(工具類等)不做要求。
我們項目涉及到了一些中間件(Mysql,Redis,MQ等),但是更多涉及到的內部其他支撐系統。用項目內的實際情況我們當前定義的單元測試覆蓋的範圍就是,單元測試從controller作爲入口,儘量覆蓋到controller和service所有的方法與邏輯,所有的外部接口調用全部mock,中間件儘量使用內存中間件進行mock。

單元測試基礎框架

既然項目是基於SpringCloud,那測試肯定會引入基礎的spring-boot-test,底層的測試框架選擇是junit。
Junit主流還是junit4(Github地址)最新版本是4.12(2014年12月5日),現在最新的是junit5(JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage)。junit5正式版本的發佈日期是2017年9月11日,目前最新的版本是5.5.2(2019年9月9日)。我們項目底層選擇了junit5。
目前,在 Java 陣營中主要的 Mock 測試工具有 Mockito,JMock,EasyMock 等。我們選擇了Mockito,這個是沒有經過特別的選型。簡單比較之後選擇了比較容易上手並且能夠滿足當前需求的一款。
redis使用了redis-mock (ai.grakn:redis-mock:0.1.6)
數據庫自然是使用h2(com.h2database:h2:1.4.192)(不過在一期項目我們主要服務編排,沒有涉及到數據庫的實例)
模擬數據生成參考了jmockdata(com.github.jsonzou:jmockdata:4.1.2),但是做了一些小小的調整增加了一些其他的類型

另外,Mockito不支持static的的方法的mock,要使用PowerMock來模擬。但是PowerMock似乎現在還不支持junit5,我們沒有使用。

單元測試實施

基本框架搭建完畢,基本就進入了編碼階段。第一期的編碼,我們實際上還是先寫了業務代碼,然後再寫單元測試。接下來就詳細介紹一下單元測試類的結構。這裏給的示例僅僅是我們在實踐過程中有使用到的,並非junit5的完整註解或者使用講解,具體需要了解大家可以參考官網

單元測試基本結構

先看一下頭部的幾個註解,這些都是Junit5的

// 替換了Junit4中的RunWith和Rule
@ExtendWith(SpringExtension.class)
//提供spring依賴注入
@SpringBootTest 
// 運行單元測試時顯示的名稱
@DisplayName("Test MerchantController")
// 單元測試時基於的配置文件
@TestPropertySource(locations = "classpath:ut-bootstrap.yml")
class MerchantControllerTest{
    private static RedisServer server = null;

    // 下面三個mock對象是由spring提供的
    @Resource
    MockHttpServletRequest request;

    @Resource
    MockHttpSession session;

    @Resource
    MockHttpServletResponse response;

    // junit4中 @BeforeClass
    @BeforeAll
    static void initAll() throws IOException {
        server = RedisServer.newRedisServer(9379); 
        server.start();
    }


    // junit4中@Before
    @BeforeEach
    void init() {
        request.addHeader("token", "test_token");
    }

    // junit4中@After
    @AfterEach
    void tearDown() {
    }

    // junit4中@AfterClass
    @AfterAll
    static void tearDownAll() {
        server.stop();
        server = null;
    }

}

這些都是比較基礎的註解,基本也和junit4一一對應。這裏沒有太多可說的,可以看到我們在初始化方法中加載了虛擬的redis服務器,在前置方法中設置了Header的值

單元測試的主體方法

我們測試的主要的就是MerchantController這個類,這個類下面還有一層service方法。先看一下大概的代碼印象。

    @Resource
    MerchantController merchantController;

    @MockBean
    private IOrderClient orderClient;

    @Test
    void getStoreInfoById() {
        MockConfig mockConfig = new MockConfig();
        mockConfig.setEnabledCircle(true);
        mockConfig.sizeRange(2, 5);
        MerchantOrderQueryVO merchantOrderQueryVO = Mock.mock(MerchantOrderQueryVO.class);
        StoreInfoDTO storeInfoDTO = Mock.mock(StoreInfoDTO.class,mockConfig);

        Mockito.when(orderClient.bizInfoV3(Mockito.any())).thenReturn(R.data(storeInfoDTO));
        Mockito.when(orderClient.getOrderCount(Mockito.any())).thenReturn(R.data(merchantOrderQueryVO));

        R<StoreInfoBizVO> r = merchantController.getStoreInfoById();

        assertEquals(r.getData().getAvailableOrderCount(), merchantOrderQueryVO.getOrderNum());
        assertEquals(r.getData().getId(), storeInfoDTO.getId());
        assertEquals(r.getData().getBranchName(), storeInfoDTO.getBranchName());
    }

    @ParameterizedTest
    @ValueSource(ints = {1, 0})
    void logoutCheck(Integer onlineValue) {
        MockConfig mockConfig = new MockConfig();
        mockConfig.setEnabledCircle(true);
        mockConfig.sizeRange(2, 5);
        MerchantOrderQueryVO merchantOrderQueryVO = Mock.mock(MerchantOrderQueryVO.class);
        StoreInfoDTO storeInfoDTO = Mock.mock(StoreInfoDTO.class,mockConfig);
        storeInfoDTO.setOnline(onlineValue);
        Mockito.when(orderClient.bizInfoV3(Mockito.any())).thenReturn(R.data(storeInfoDTO));
        Mockito.when(orderClient.getOrderCount(Mockito.any())).thenReturn(R.data(merchantOrderQueryVO));

        R r = merchantController.logoutCheck();

        if (1==onlineValue) {
            assertEquals(ResourceAccessor.getResourceMessage(
                    MerchantbizConstant.USER_LOGOUT_CHECK_ONLINE), r.getMsg());
        } else {
            assertEquals(ResourceAccessor.getResourceMessage(
                    MerchantbizConstant.USER_LOGOUT_CHECK_UNCOMPLETED), r.getMsg());
        }
    }

    @ParameterizedTest
    @CsvSource({"1,Selma,true", "2,Lisa,true", "3,Tim,false"})
    void forTest(int id,String name,boolean t) {
        System.out.println("id="+id+" name="+name+" tORf="+t);
        merchantController.forTest(null);
    }

首先看變量的部分,這裏給了兩個例子,一個註解是@Resource,這個是讓spring來注入的。另外一個是@MockBean,這就是Mockito提供的,並且結合下面的Mockito.when方法。
接下來看方法體,我將方法主體分爲三部分:

  1. Mock數據與方法
    使用Mock攔截底層的外部接口方法,並且返回隨機的Mock數據(大部分數據可以使用DataMocker生成,有一些特殊有限制的,可以手動生成)。
  2. 測試方法執行
    執行目標測試方法(基本都是一行,直接調用目標方法並且返回結果)
  3. 結果斷言
    根據業務邏輯預期進行斷言的編寫(這部分基本上沒有自動化的方式,因爲斷言的條件和業務邏輯相關只能手動編寫)

這樣寫下來是基本邏輯的驗證,還有內部有分支邏輯,如何驗證?
代碼當中實際上也提到了,就是junit5提供的@ParameterizedTest註解,配合@ValueSource, @CsvSource來使用,分別可以設置指定類型或者複雜類型到單元測試中,使用方法的參數接受,定義測試不同的分支。

單元測試的執行

單元測試的執行實際上分成2部分:

  1. IDE中我們要去驗證單元測試是否能夠成功執行
  2. CI/CD作爲執行的先決條件保障

IDE可以直接指定測試框架,我們選擇junit5直接生成單元測試代碼,可以直接在測試包或者類上右鍵執行單元測試。這個方法可以作爲我們開發過程中驗證待遇測試有效性的手段。但是真正要能在生產開發流程中更好的體現單元測試的價值,還是需要持續集成的支持,我們項目使用的是jenkins。依賴是Maven,以及maven-surefire-plugin插件。要特別注意一點,由於junit5還比較新,所以maven-surefire-plugin插件支持junit5還是稍微有點特殊的,參考官網說明。我們需要引入插件:

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.0.0-M3</version>
            <configuration>
                <excludes>
                    <exclude>some test to exclude here</exclude>
                </excludes>
            </configuration>
        </plugin>

這樣在jenkins構建時就會執行單元測試,如果單元測試失敗,不會觸發構建後操作(Post Steps)。

總結

目前我們的項目中,單元測試的應用還在第一期,但是投入在上面的時間和精力,實際上到實際開發時間的2-3倍。因爲涉及到基礎框架的搭建,新框架的引入整合,底層開發編寫測試代碼的審覈,團隊的培訓等等。我預計在後期,成熟的框架和流程支持下,覆蓋核心業務代碼的單元測試耗時應該能到實際開發工時的50%-80%左右。但是這部分的投入是能夠減少測試以及線上的問題發生的概率,節省了修復的時間。
團隊目前還不能完全習慣單元測試的節奏,目前帶來的直接益處還不夠明顯,但是一個好的習慣的養成,還是需要管理者投入精力同時從上而下的推動的。
後期應該對於單元測試的執行還有一些調整或改進,而且對其概念、流程等方面應該也會有更深入和實際的理解。屆時還會再次整理,並且分享給大家。

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