天天看點

單元測試實踐(Spring-boot+Junbit5+Mockito)

單元測試就像胖人說減肥,每個人都說好,但是很少有人做到。從這麼多年的項目經曆親身證明,還真是的。

這次借着項目内實施單元測試的機會,記錄實施的過程和一些總結經驗。

項目情況

首先是背景,項目是一個較大型的曆史二手項目,前任團隊移交過來的項目,與多個外部服務有互動,采用的是SpringCloud作為基礎微服務的架構,中間件涉及Redis,MySQL,RabbitMQ等等。,團隊中讨論期望能夠利用單元測試來提高服務品質,減少業務投訴。

單元測試的優點很多,但是我覺得最終的目标就是服務品質,單元測試代碼如果最終沒有能夠提高項目品質,說明過程是有問題或者團隊沒有真正接納方法,為了完成任務而寫測試,以時間不足為由拖延測試用例的開發。

說到單元測試大家肯定會先想起TDD。TDD(Test Dirven Development,測試驅動開發)是以單元測試來驅動開發的方法論。

  1. 開發一個新功能前,首先編寫單元測試用例,用于模拟驗收或目标功能情況
  2. 運作單元測試,全部失敗,即目标已設定
  3. 編寫業務代碼,并且使對應的單元測試能夠通過,目标達成。
  4. 時刻維護你的單元測試,使其始終可通過運作

一開始就直接實施TDD的可能性是比較小的,需要慢慢教育訓練,滲透思想,在長期的運維與應用中讓團隊感受到測試帶來的益處。慢慢建立測試相關規範和體制。讓團隊成員愛上單元測試。

單元測試範圍

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

在靈活和DevOps的思想指導下,單元測試首先應當是自動化的,由開發者編寫,為了保證代碼片段(最小單元)是按照預期設計實作的。我們了解就是說單元測試要保障的是項目(代碼片段邏輯)自身按照設計意圖正确執行,是以确認了單元測試的範圍僅限于單個項目内部,是以要盡量屏蔽所有的外部系統或中間件以及外部系統的依賴。代碼的業務邏輯覆寫率80%-90%,其他部分(工具類等覆寫率更高)不做要求。

項目涉及到了一些中間件(Mysql,Redis,MQ等),但是更多涉及到的内部其他支撐系統。用項目内的實際情況我們目前定義的單元測試覆寫的範圍就是,單元測試從controller作為入口,盡量覆寫到controller和service所有的方法與邏輯,所有的外部接口調用全部mock,中間件盡量使用記憶體中間件進行mock。

單元測試基礎架構

既然項目是基于SpringCloud,那測試肯定會引入基礎的spring-boot-test,底層的測試架構選擇是junit5。現在最新的是junit5(JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage)。

目前,在 Java 陣營中主要的 Mock 測試工具很多。項目中選擇了Mockito,

模拟資料生成參考了jmockdata(com.github.jsonzou:jmockdata:4.1.2),

單元測試實施

基本架構選型完畢,基本就進入了編碼階段。前期的編碼,實際上還是先寫抽取核心業務進行小規模的驗證。

單元測試基本結構

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

// 替換了Junit4中的RunWith和Rule
@ExtendWith(SpringExtension.class)
//提供spring依賴注入
@SpringBootTest 
// 運作單元測試時顯示的名稱
@DisplayName("Test DebitController")
// 單元測試時基于的配置檔案
@TestPropertySource(locations = "classpath:it-bootstrap.yml")
class DebitControllerTest{
    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();
    }

    @BeforeEach
    void init() {
        request.addHeader("token", "XXXXXXXXXXXXXXXXXXXX");
    }


    @AfterEach
    void tearDown() {
    }

    @AfterAll
    static void tearDownAll() {
        server.stop();
        server = null;
    }

}
           

在前置方法中設定了Header的值用于模拟身份驗證

單元測試的主體方法

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

@Resource
    DebitController debitController;

    @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 = debitController.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 = debitController.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) {
        log.info("id={} name={} t={}"+",id,name,t);
        debitController.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-M5</version>
					<configuration>
						<argLine> -XX:-UseSplitVerifier</argLine>
						<excludes>
							<exclude>**/*IT.java</exclude>
							<exclude>**/*IntegrationTest.java</exclude>
						</excludes>
					</configuration>
				</plugin>
           

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

繼續閱讀