1. 程式人生 > 實用技巧 >mock測試及jacoco覆蓋率

mock測試及jacoco覆蓋率

單元測試是保證專案程式碼質量的有力武器,但是有些業務場景,依賴的第三方沒有測試環境,這時候該怎麼做Unit Test呢,總不能直接生產環境硬來吧?

可以藉助一些mock測試工具,比如下面要講的mockito,廢話不多說,直奔主題:

一、準備示例Demo

假設有一個訂單系統,使用者可以建立訂單,同時下單後要檢測使用者餘額(如果餘額不足,提醒使用者充值),具體來說,裡面有2個服務:OrderService、UserService,類圖如下:

示例程式碼:

package com.cnblogs.yjmyzz.springbootdemo.service.impl;

import com.cnblogs.yjmyzz.springbootdemo.service.UserService;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;

/**
 * @author 菩提樹下的楊過
 */
@Service("userService")
public class UserServiceImpl implements UserService {


    @Override
    public BigDecimal queryBalance(int userId) {
        System.out.println("queryBalance=>userId:" + userId);
        //模擬返回100元餘額
        return new BigDecimal(100);
    }
}

package com.cnblogs.yjmyzz.springbootdemo.service.impl;

import com.cnblogs.yjmyzz.springbootdemo.service.OrderService;
import com.cnblogs.yjmyzz.springbootdemo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;

@Service("orderService")
public class OrderServiceImpl implements OrderService {

    @Autowired
    private UserService userService;

    /**
     * 下訂單
     *
     * @param productName
     * @param orderNum
     * @return
     * @throws Exception
     */
    @Override
    public Long createOrder(String productName, Integer orderNum, int userId) throws Exception {
        System.out.println("createOrder=>userId:" + userId);
        if (StringUtils.isEmpty(productName)) {
            throw new Exception("productName is empty");
        }

        if (orderNum == null) {
            throw new Exception("orderNum is null!");
        }

        if (orderNum <= 0) {
            throw new Exception("orderNum must bigger than 0");
        }

        //下訂單過程略,返回1L做為訂單號
        Long orderId = 1L;

        //模擬檢測餘額
        BigDecimal balance = userService.queryBalance(userId);
        if (balance.compareTo(BigDecimal.TEN) <= 0) {
            System.out.println("餘額不足10元,請及時充值!");
        }

        return orderId;
    }
}

裡面的邏輯不是重點,隨便看看就好。關注下createOrder方法,最後幾行OrderService服務呼叫了UserService服務查詢使用者餘額,即:OrderService依賴UserService,假設UserService這個服務,就是一個第3方服務,不具備測試環境,本文就來講講如何對UserService進行mock測試。

二、pom引入mockito 及jacoco plugin

2.1引入mockito

1 <dependency>
2     <groupId>org.mockito</groupId>
3     <artifactId
>mockito-all</artifactId> 4 <version>1.9.5</version> 5 <scope>test</scope> 6 </dependency>
View Code

mockito是一個mock工具庫,馬上會講到用法。

2.2引入jacoco外掛

 1 <plugin>
 2     <groupId>org.jacoco</groupId>
 3     <artifactId>jacoco-maven-plugin</artifactId>
 4     <version>0.8.5</version>
 5     <executions>
 6         <execution>
 7             <id>prepare-agent</id>
 8             <goals>
 9                 <goal>prepare-agent</goal>
10             </goals>
11         </execution>
12         <execution>
13             <id>report</id>
14             <phase>prepare-package</phase>
15             <goals>
16                 <goal>report</goal>
17             </goals>
18         </execution>
19         <execution>
20             <id>post-unit-test</id>
21             <phase>test</phase>
22             <goals>
23                 <goal>report</goal>
24             </goals>
25             <configuration>
26                 <dataFile>target/jacoco.exec</dataFile>
27                 <outputDirectory>target/jacoco-ut</outputDirectory>
28             </configuration>
29         </execution>
30     </executions>
31 </plugin>
View Code

jacoco可以將單元測試的結果,直接生成html網頁,分析程式碼覆蓋率。注意<outputDirectory>target/jacoco-ut</outputDirectory> 這一行的配置,表示將在target/jacoco-ut目錄下生成測試報表。

三、編寫單測用例

3.1約定大於規範

以OrderServiceImpl類為例,如果要對它做單元測試,建議按以下約定:

a. 在test/java下建立一個與OrderServiceImpl同名的package名(注:這樣的好處是測試類與原類,處於同1個包,程式碼可見性相同)

b. 然後在該package下建立OrderServiceImplTest類(注意:一般測試類名的風格為 xxxxTest,在原類名後加Test)

3.2 單元測試模板

參考下面的程式碼模板:

package com.cnblogs.yjmyzz.springbootdemo.service.impl;

import com.cnblogs.yjmyzz.springbootdemo.service.UserService;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.runners.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public class OrderServiceImplTest {

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
    }
    
    /**
     * 真正要測試的類
     */
    @InjectMocks
    private OrderServiceImpl orderService;

    /**
     * 測試類依賴的其它服務
     */
    @Mock
    private UserService userService;

    /**
     * createOrder成功時的用例
     */
    @Test
    public void testCreateOrderSuccess() {
        //todo
    }

    /**
     * createOrder失敗時的用例
     */
    @Test
    public void testCreateOrderFailure() {
        //todo
    }

}

講解一下:

a. 類上的@RunWith要改成MockitoJUnitRunner.class,否則mockito不生效

b. 真正需要測試的類,要用@InjectMocks,而不是@Mock(更不能是@Autowired)

-- 原因1:@Autowired是Spring的註解,在mock環境下,根本就沒有Spring上下文,當然會注入失敗。

-- 原因2:也不能是@Mock,@Mock表示該注入的物件是“偽假”的,裡面的方法程式碼根本不會真正執行,統一返回空物件null,即:被@Mock修飾的物件,在該測試類中,其具體的程式碼永遠無法覆蓋到!這也就是失敗了單元測試的意義。而@InjectMocks修飾的物件,被測試的方法,才會真正進入執行。

另外,測試服務時,被mock注入的類,應該是具體的服務實現類,即:xxxServiceImpl,而不是服務介面,在mock環境中介面是無法例項化的。

c. 通常一個方法,會有執行成功和執行失敗二種情況,建議測試類裡,用testXXXSuccess以及testXXXFailure區分開來,看起來比較清晰。

3.3 測試覆蓋率

先來看看下單失敗的情況:下單前有很多引數校驗,先驗證下這些引數異常的場景。

    public int userId = 101;
    
    /**
     * createOrder失敗時的用例
     */
    @Test
    public void testCreateOrderWhenFail() {
        try {
            orderService.createOrder(null, 10, userId);
        } catch (Exception e) {
            Assert.assertEquals(true, true);
        }

        try {
            orderService.createOrder("book", null, userId);
        } catch (Exception e) {
            Assert.assertEquals(true, true);
        }

        try {
            orderService.createOrder("book", 0, userId);
        } catch (Exception e) {
            Assert.assertEquals(true, true);
        }

        try {
            orderService.createOrder("book", 50, userId);
        } catch (Exception e) {
            Assert.assertEquals(true, true);
        }
    }

命令列下mvn package 跑一下單元測試,全通過後,會在target/jacoco-ut 目錄下生成報表網頁

瀏覽器開啟index.html,就能看到覆蓋率

可以看到,中間那個帶部分綠色的,就是我們剛才寫過單測的pacakge,一層層點下去,可以看到最終OrderServiceImpl.createOrder方法的程式碼覆蓋情況,綠色的行表示覆蓋到了,紅色的表示未覆蓋到

講一個小技巧:有些類,比如DAO/Mytatis層自動生成的DO/Entity這些類,還有一些常量定義等,其實沒什麼測試的必要,可以排除掉,這樣不僅可以提高測試的覆蓋率,還能讓我們更關注於核心業務類的測試。

排除的方法很簡單,可jacoco外掛裡配置exclude規則即可,參考下面這樣:

<configuration>
    <dataFile>target/jacoco.exec</dataFile>
    <outputDirectory>target/jacoco-ut</outputDirectory>
    <excludes>
        <exclude>
            **/cnblogs/yjmyzz/**/aspect/**,
            **/yjmyzz/**/SampleApplication.class
        </exclude>
    </excludes>
</configuration>
View Code

這樣就把aspect包下的所有類,以及SampleApplication.class這個特定類給排除在單元測試之外,此時再跑一下mvn package ,對比下重新生成的報告

覆蓋率從剛才的26%上升到了61%

3.4 mock返回值

從覆蓋率上看,剛才createOrder方法裡,最後幾行並沒有覆蓋到,可以再寫一個用例

問題來了,UserService的queryBalance方法

    @Override
    public BigDecimal queryBalance(int userId) {
        System.out.println("queryBalance=>userId:" + userId);
        //模擬返回100元餘額
        return new BigDecimal(100);
    }

已經寫死了返回100元,同時還輸出了一行日誌,但是從測試結果來看,這個方法並沒有真正執行。這也就印證了@Mock修飾的物件,是“假”的,並不會真正執行內部的程式碼,只會返回null物件。

@Test
public void testCreateOrderSuccess() throws Exception {
    BigDecimal balance = BigDecimal.TEN;
    //表示:當userService.queryBalance(userId)執行時,將返回balance變數做為返回值
    when(userService.queryBalance(userId)).thenReturn(balance);
    long orderId = orderService.createOrder("phone", 10, userId);
    Assert.assertEquals(orderId, 1L);
}

把測試程式碼調整下,改成上面這樣,利用when(...).thenReturn(...),表示當xxx方法執行時,將模擬返回yyy物件。這樣就mock出了userService的返回值

現在測試就通過了,再看看生成的測試報告,最後幾行,也被覆蓋到了。