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

網(wǎng)上看過一句話,單元測試就像早睡早起鳍徽,每個人都說好资锰,但是很少有人做到。從這么多年的項目經(jīng)歷親身證明阶祭,是真的绷杜。
這次借著項目內(nèi)實施單元測試的機會直秆,記錄實施的過程和一些總結(jié)經(jīng)驗。

項目情況

首先是背景鞭盟,項目是一個較大型的項目圾结,多個團隊協(xié)作開發(fā),采用的是SpringCloud作為基礎(chǔ)微服務(wù)的架構(gòu)懊缺,中間件涉及Redis疫稿,MySQL培他,MQ等等鹃两。新的起點開始起步,團隊中討論期望能夠利用單元測試來提高代碼質(zhì)量舀凛。單元測試的優(yōu)點很多俊扳,但是我覺得最終最終的目標就是質(zhì)量,單元測試代碼如果最終沒有能夠提高項目質(zhì)量猛遍,說明過程是有問題或者團隊沒有真正接納方法馋记,不如放棄來節(jié)省大家的開發(fā)時間。
一說到單元測試大家肯定會先想起TDD懊烤。TDD(Test Dirven Development梯醒,測試驅(qū)動開發(fā))是以單元測試來驅(qū)動開發(fā)的方法論。

  1. 開發(fā)一個新功能前腌紧,首先編寫單元測試用例
  2. 運行單元測試茸习,全部失敗(紅色)
  3. 編寫業(yè)務(wù)代碼壁肋,并且使對應(yīng)的單元測試能夠通過(綠色)
  4. 時刻維護你的單元測試号胚,使其始終可運行

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

我們的項目基礎(chǔ)技術(shù)架構(gòu)是基于SpringCloud氢卡,做了一些基礎(chǔ)的底層封裝锈至。項目之間的調(diào)用都是基于Feign,各個項目都是規(guī)范要提供各自的Feign接口以及Hystrix的FallbackFactory译秦。我們將對于外部的調(diào)用都是封裝在底層的service中峡捡。

單元測試范圍

一個項目需要實施單元測試击碗,首先要界定(或者說澄清)單元測試負責的范圍。最常見的疑惑就是與外部系統(tǒng)或者其他中間件的關(guān)聯(lián)们拙,單元測試是否要實際的調(diào)用其他中間件/外部系統(tǒng)稍途。
我們先來看看單元測試的定義:

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.

單元測試首先應(yīng)當是自動化的,由開發(fā)者編寫砚婆,為了保證代碼片段(最小單元)是按照預(yù)期設(shè)計實現(xiàn)的械拍。我們理解就是說單元測試要保障的是項目(代碼片段邏輯)自身按照設(shè)計意圖正確執(zhí)行,所以確認了單元測試的范圍僅限于單個項目內(nèi)部装盯,因此要盡量屏蔽所有的外部系統(tǒng)或中間件坷虑。代碼的業(yè)務(wù)邏輯覆蓋80%-90%,其他部分(工具類等)不做要求埂奈。
我們項目涉及到了一些中間件(Mysql迄损,Redis,MQ等)账磺,但是更多涉及到的內(nèi)部其他支撐系統(tǒng)芹敌。用項目內(nèi)的實際情況我們當前定義的單元測試覆蓋的范圍就是,單元測試從controller作為入口垮抗,盡量覆蓋到controller和service所有的方法與邏輯氏捞,所有的外部接口調(diào)用全部mock,中間件盡量使用內(nèi)存中間件進行mock冒版。

單元測試基礎(chǔ)框架

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

另外趁耗,Mockito不支持static的的方法的mock沉唠,要使用PowerMock來模擬。但是PowerMock似乎現(xiàn)在還不支持junit5苛败,我們沒有使用满葛。

單元測試實施

基本框架搭建完畢径簿,基本就進入了編碼階段。第一期的編碼嘀韧,我們實際上還是先寫了業(yè)務(wù)代碼篇亭,然后再寫單元測試。接下來就詳細介紹一下單元測試類的結(jié)構(gòu)锄贷。這里給的示例僅僅是我們在實踐過程中有使用到的译蒂,并非junit5的完整注解或者使用講解,具體需要了解大家可以參考官網(wǎng)谊却。

單元測試基本結(jié)構(gòu)

先看一下頭部的幾個注解柔昼,這些都是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;
    }

}

這些都是比較基礎(chǔ)的注解,基本也和junit4一一對應(yīng)因惭。這里沒有太多可說的岳锁,可以看到我們在初始化方法中加載了虛擬的redis服務(wù)器绩衷,在前置方法中設(shè)置了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提供的曹货,并且結(jié)合下面的Mockito.when方法咆繁。
接下來看方法體,我將方法主體分為三部分:

  1. Mock數(shù)據(jù)與方法
    使用Mock攔截底層的外部接口方法顶籽,并且返回隨機的Mock數(shù)據(jù)(大部分數(shù)據(jù)可以使用DataMocker生成玩般,有一些特殊有限制的,可以手動生成)礼饱。
  2. 測試方法執(zhí)行
    執(zhí)行目標測試方法(基本都是一行坏为,直接調(diào)用目標方法并且返回結(jié)果)
  3. 結(jié)果斷言
    根據(jù)業(yè)務(wù)邏輯預(yù)期進行斷言的編寫(這部分基本上沒有自動化的方式,因為斷言的條件和業(yè)務(wù)邏輯相關(guān)只能手動編寫)

這樣寫下來是基本邏輯的驗證镊绪,還有內(nèi)部有分支邏輯匀伏,如何驗證?
代碼當中實際上也提到了蝴韭,就是junit5提供的@ParameterizedTest注解够颠,配合@ValueSource, @CsvSource來使用榄鉴,分別可以設(shè)置指定類型或者復雜類型到單元測試中履磨,使用方法的參數(shù)接受核行,定義測試不同的分支。

單元測試的執(zhí)行

單元測試的執(zhí)行實際上分成2部分:

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

IDE可以直接指定測試框架蹬耘,我們選擇junit5直接生成單元測試代碼芝雪,可以直接在測試包或者類上右鍵執(zhí)行單元測試。這個方法可以作為我們開發(fā)過程中驗證待遇測試有效性的手段综苔。但是真正要能在生產(chǎn)開發(fā)流程中更好的體現(xiàn)單元測試的價值惩系,還是需要持續(xù)集成的支持慨丐,我們項目使用的是jenkins趴泌。依賴是Maven,以及maven-surefire-plugin插件复局。要特別注意一點杨刨,由于junit5還比較新晤柄,所以maven-surefire-plugin插件支持junit5還是稍微有點特殊的,參考官網(wǎng)說明妖胀。我們需要引入插件:

        <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構(gòu)建時就會執(zhí)行單元測試芥颈,如果單元測試失敗,不會觸發(fā)構(gòu)建后操作(Post Steps)赚抡。

總結(jié)

目前我們的項目中爬坑,單元測試的應(yīng)用還在第一期,但是投入在上面的時間和精力涂臣,實際上到實際開發(fā)時間的2-3倍盾计。因為涉及到基礎(chǔ)框架的搭建,新框架的引入整合赁遗,底層開發(fā)編寫測試代碼的審核署辉,團隊的培訓等等。我預(yù)計在后期岩四,成熟的框架和流程支持下哭尝,覆蓋核心業(yè)務(wù)代碼的單元測試耗時應(yīng)該能到實際開發(fā)工時的50%-80%左右。但是這部分的投入是能夠減少測試以及線上的問題發(fā)生的概率炫乓,節(jié)省了修復的時間刚夺。
團隊目前還不能完全習慣單元測試的節(jié)奏,目前帶來的直接益處還不夠明顯末捣,但是一個好的習慣的養(yǎng)成侠姑,還是需要管理者投入精力同時從上而下的推動的。
后期應(yīng)該對于單元測試的執(zhí)行還有一些調(diào)整或改進箩做,而且對其概念莽红、流程等方面應(yīng)該也會有更深入和實際的理解。屆時還會再次整理,并且分享給大家安吁。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末醉蚁,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子鬼店,更是在濱河造成了極大的恐慌网棍,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件妇智,死亡現(xiàn)場離奇詭異滥玷,居然都是意外死亡,警方通過查閱死者的電腦和手機巍棱,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進店門惑畴,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人航徙,你說我怎么就攤上這事如贷。” “怎么了到踏?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵杠袱,是天一觀的道長。 經(jīng)常有香客問我夭禽,道長霞掺,這世上最難降的妖魔是什么谊路? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任讹躯,我火速辦了婚禮,結(jié)果婚禮上缠劝,老公的妹妹穿的比我還像新娘潮梯。我一直安慰自己,他們只是感情好惨恭,可當我...
    茶點故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布秉馏。 她就那樣靜靜地躺著,像睡著了一般脱羡。 火紅的嫁衣襯著肌膚如雪萝究。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天锉罐,我揣著相機與錄音帆竹,去河邊找鬼。 笑死脓规,一個胖子當著我的面吹牛栽连,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼秒紧,長吁一口氣:“原來是場噩夢啊……” “哼绢陌!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起熔恢,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤脐湾,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后叙淌,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體沥割,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年凿菩,在試婚紗的時候發(fā)現(xiàn)自己被綠了机杜。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,100評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡衅谷,死狀恐怖椒拗,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情获黔,我是刑警寧澤蚀苛,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站玷氏,受9級特大地震影響堵未,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜盏触,卻給世界環(huán)境...
    茶點故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一渗蟹、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧赞辩,春花似錦雌芽、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至糟需,卻和暖如春屉佳,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背洲押。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工武花, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人诅诱。 一個月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓髓堪,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子干旁,可洞房花燭夜當晚...
    茶點故事閱讀 42,834評論 2 345

推薦閱讀更多精彩內(nèi)容