Spring Boot 單元測試實踐(二)

Spring Boot 單元測試實踐(二)

前言

在前文《Spring Boot 單元測試實踐》中講了在單元測試中外部依賴需要進行 Mock,從而保證測試用例的 R(可重複的) 原則。

那麼如何對依賴 MySQL,Redis、MQ 等相關操作去進行 Mock 呢?本文基於 Spring Boot 2.3、Junit 5、Mockito 來進行一個簡單的示範,來說明如何去進行 Mock 和 Stub 的,同時附帶一些 Junit5 的簡單操作.

MocK

Mock 方法是單元測試中常見的一種技術,它的主要作用是模擬一些在應用中不容易構造或者比較複雜的對象,從而把測試與測試邊界以外的對象隔離開。1

Stub

樁(Stub / Method Stub)是指用來替換一部分功能的程序段。樁程序可以用來模擬已有程序的行爲(比如一個遠端機器的過程)或是對將要開發的代碼的一種臨時替代。因此,打樁技術在程序移植、分佈式計算、通用軟件開發和測試中用處很大。2

實踐

引入依賴

Spring Boot 2.3.12.RELEASE、JPA、RabbitMQ、Redis,高版本的 Spring Boot 已經升級爲 Junit 5 (但還保留了 Junit 4 的依賴,可以 exclude 掉)

完整依賴

Junit 5

Junit 5 與 4 有一些差異,但差異並不大,同時在斷言方面提供了更全的功能(相對 Junit 4)

image.png 圖片截圖自 JUnit 5 和 JUnit 4 比較

而就 Spring 而言,最大差別就是想要使用 Spring 容器就得使用以下方式:

@RunWith(SpringRunner.class)  => @ExtendWith(SpringExtension.class)
複製代碼

image.png

@RunWith(SpringRunner.class) 能用但是無法再注入 Bean 了,包括 MockBean,下圖 debug 可以看到: image.png

image.png

準備環境

說明

業務場景爲某個活動海報的一個階段領獎操作,根據現有業務邏輯簡化改造而來

  • ActivityRepository:活動倉儲類,操作數據
  • ActivityService:此類依賴ActivityRepository以及RedisTemplate
  • ActivityService#award:本次需要進行單元測試的業務方法,此法方法依賴了數據庫和 Redis

領獎僞代碼

public void award(activityId, posterId, stageId, userId) {
    // 根據 activityId 檢查活動是否存在
    // 從 Redis 獲取階段的領獎狀態(Redis 以 Hash 結構存儲活動海報的階段領獎狀態,key 爲 posterId(一個用戶在一個活動內 posterId 唯一), field 爲 stageId)   
    if (status == null) {
        // 狀態數據不存在,查詢數據庫是否有領獎記錄
        if (exist) {
            // 同步至 redis 並返回
            return;
        }
    } else (status) {
        return;
    }
    /* 沒有領獎則進行領獎操作 */
    // 查詢階段
    // 保存至數據庫
    // 領獎狀態寫入 Redis     
}    
複製代碼

完整代碼

設計 Case

case 1,2,4 爲附加的,如何 mock 請看 case 3

Case 1:依賴基礎設施

使用 @SpringBooTest 註解需要配置相關依賴才能啓動

@Slf4j
@SpringBootTest
public class Case1Test {

    @Test
    // 此爲 Junit 5 的註解,別名
    @DisplayName("依賴基礎設施測試")
    void infrastructureRequired() {
        log.info("需要依賴基礎設施");
    }

}
複製代碼

image.png

image.png

Case 2:不依賴基礎設施

@Slf4j
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class Case2Test {
    @Resource
    private ActivityService service;
    @MockBean
    private ActivityRepository repository;
    @MockBean
    private RedisTemplate<String, Object> redisTemplate;

    @Test
    @DisplayName("無需依賴基礎設施測試")
    void noInfrastructureRequired() {
        log.info("無需基礎設施也能運行");

        Assertions.assertNotNull(service);
        Assertions.assertNotNull(repository);
        Assertions.assertNotNull(redisTemplate);
    }
}
複製代碼

image.png

可以看到並沒有,也不需要啓動 Spring 容器

case2.gif

Case 3:award() 單測

前文提到 award() 方法會查詢數據庫以及 Redis,因此需要對這一部分操作進行 Mock 和 Stub.

可以利用 Mockito 對下述代碼中 # ----- stub {num} ----- 後所跟隨語句進行 stub(完整方法見文末)

因爲存在分支控制語句,所以只演示了一條基本路徑進行單元測試,而且剛好覆蓋大部分 stub 1,2,3,5,6,7

// com.jingwu.example.service.ActivityService#award
public void award(AwardDTO dto) {
    String id = dto.getActivityId(), stageId = dto.getStageId(), userId = dto.getUserId();
    # ----- stub 1 -----
    final ActivityDO activity = repository.selectById(id);
    if (Objects.isNull(activity)) throw new RuntimeException();

    String hashKey = String.format(FISSION_POSTER_AWARD, dto.getPosterId());
    String key = String.valueOf(stageId);
    
    # ----- stub 2 -----
    Object result = redisTemplate.opsForHash().get(hashKey, key);
    if (Objects.isNull(result)) {
        # ----- stub 3 -----
        Boolean exist = repository.exist(id, stageId, userId);
        if (exist) {
            # ----- stub 4 -----
            redisTemplate.opsForHash().put(hashKey, key, true);
            redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
            return;
        }
    } else if ((Boolean) result) {
        return;
    }
    # ----- stub 5 -----
    ActivityStageDO stage = repository.selectStage(stageId, id);
    if (Objects.isNull(stage)) throw new RuntimeException();
    
    ActivityStageAwardDO entity = new ActivityStageAwardDO()
        .setActivityId(id).setStageId(stageId)
        .setUserId(userId).setStageNum(stage.getStageNum());

    # ----- stub 6 -----
    repository.saveAward(entity);
    # ----- stub 7 -----
    redisTemplate.opsForHash().put(hashKey, key, true);
    redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
}
複製代碼

Stub 0

  • 選擇 award() 方法, Ctrl+Shift+T,選擇需要進行單元測試的方法,回車創建 ActivityServiceTest

key.gif

  • 由於針對 ActivityService 進行單元測試,因此通過註解@Import({ActivityService.class}) 注入 Bean (參考 Spring Boot 單元測試實踐 @Import 章節)

  • ActivityService 中依賴 ActivityRepository  RedisTemplate ,而在此單元測試中關注的是 award() 方法的業務邏輯,並不關注兩者的 Bean 在 Spring 容器中是否真的存在或者能注入,因此可以通過 spring boot test 提供的 @MockBean 註解來 Mock 注入 依賴的 Bean(有多少 Bean 依賴就需要 Mock 多少 Bean,否則會 IllegalStateException: Failed to load ApplicationContext

@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class ActivityServiceTest {

    @Resource
    private ActivityService service;
    @MockBean
    private ActivityRepository repository;
    @MockBean
    private RedisTemplate<String, Object> redisTemplate;
    
}
複製代碼

image.png

image.png

Stub 1

award() 方法中會執行 repository.selectById(id) 語句,而 repository 會去操作數據庫, 因此需要通過 mock/stub 來替換實際的 JDBC 操作.

可以利用 doReturn().when() 或者 when().thenReturn() 進行 stub.

doReturn  thenReturn 在針對 Mock 對象是一樣的效果,僅語法存在差異,只有在使用 Spy 對象時會有所不同(參見 Case 4)

    repository.selectById(id)  
    
=>  ActivityDO activity = mockActivity();
    doReturn(activity).when(repository).selectById(ACTIVITY_ID);
//  或者 
=>  when(repository.selectById(ACTIVITY_ID)).thenReturn(activity);    
複製代碼

Stub 2

    redisTemplate.opsForHash().get(hashKey, key)
複製代碼

由於 redisTemplate.opsForHash().get(hashKey, key) 是鏈式操作,需要分步 stub,而 opsForHash 會返回一個包訪問權限的對象,即 DefaultHashOperations,此類在自己的包目錄下是無法訪問的,那麼如何去 Mock 此對象呢?

image.png

自建一個相同路徑的包,然後自定義 public 類去繼承 DefaultHashOperations(你學廢沒有?)

MyDefaultHashOperations mockOpt = mock(MyDefaultHashOperations.class);
doReturn(mockOpt).when(redisTemplate).opsForHash();
doReturn(null).when(mockOpt).get(any(), any());
複製代碼

 award() 方法中執行至 redisTemplate.opsForHash() 時,返回 mockOpt,然後 mockOpt 再調用 get() 方法時 返回 null(爲了走進 if 分支)

image.png

opsForHash.gif

any() 用法見 Mockito 操作

此外還有另一種方式,封裝輔助類來完成對 Redis 操作(將 ActivityService  RedisTeamplate 解耦),此時只需要 mock 一次輔助類即可.


@MockBean
private RedisHelper helper;

method(){
    ...
    helper.hget(key, field);
    ...
}

@Test
method(){
    ...
    doReturn(object).when(helper).hget(any(), any());
    ...
}

複製代碼

當對業務邏輯的 Mock 和 Stub 很難去進行下去時 ,有可能是代碼結構存在一些問題,此時需要及時調整,進行小範圍重構.

Stub 7

    redisTemplate.opsForHash().put(hashKey, key, true);
    redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
        
=>  doNothing().when(spyOpt).put(any(), any(), any());
    doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
複製代碼

void 方法使用 doNothing 來進行 Stub;mock 方法的傳參見 任意參數

stub 5、6 參考 stub 1 即可,stub 4 不在此測試路徑內,Stub 方式參考 stub 7

完整 Case

@Slf4j
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class ActivityServiceTest {

    @Resource
    private ActivityService service;
    @MockBean
    private ActivityRepository repository;
    @MockBean
    private RedisTemplate<String, Object> redisTemplate;

    private final Fairy fairy = Fairy.create(Locale.CHINA);

    private static final String ACTIVITY_ID = "1";
    private static final String POSTER_ID = "10";
    private static final String STAGE_ID = "100";

    @SuppressWarnings("unchecked")
    @Test
    @DisplayName("活動階段領獎測試")
    void award() {
        AwardDTO dto = new AwardDTO();
        dto.setActivityId(ACTIVITY_ID);
        dto.setStageId(STAGE_ID);
        dto.setPosterId(POSTER_ID);
        ActivityDO activity = mockActivity();

        MyDefaultHashOperations mockOpt = mock(MyDefaultHashOperations.class);
        doReturn(mockOpt).when(redisTemplate).opsForHash();
        doReturn(null).when(mockOpt).get(any(), any());
        doReturn(activity).when(repository).selectById(ACTIVITY_ID);
        doReturn(mockStage()).when(repository).selectStage(any(), any());
        doReturn(false).when(repository).exist(any(), any(), any());
        when(repository.saveAward(any())).thenReturn(true);
        doNothing().when(spyOpt).put(any(), any(), any());
        doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));

        service.award(dto);

        verify(repository, times(1)).saveAward(any());
        verify(redisTemplate, times(2)).opsForHash();
        verify(redisTemplate, times(1)).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
        verify(spyOpt, times(1)).put(any(), any(), any());
    }

}
複製代碼

case3.gif

Case 4:doReturn 與 thenReturn

在操作 mock 對象時,doReturn  thenReturn 是一樣的,操作 spy 對象時會不一樣,thenReturn 在操作 spy 對象會調用真實方法,再返回 mock 數據,而 doReturn 則直接返回,並不會調用實際方法.

public class ActivityRepository {
    public Boolean saveAward(ActivityStageAwardDO entity) {
        final boolean result = RandomUtil.randomBoolean();
        log.info("保存結果:{}", result);
        function();
        return result;
    }
    private void function() {
        log.info("拋了異常");
        throw new NullPointerException();
    }
}

public class Case4Test {
    @BeforeEach
    void setUp() {
        log.info("---- UT Start ----");
    }

    @AfterEach
    void tearDown() {
        log.info("---- UT End ----\n");
    }

    @Test
    void doReturnTest() {
        Assertions.assertDoesNotThrow(() -> {
            final ActivityRepository spy = spy(ActivityRepository.class);
            doReturn(true).when(spy).saveAward(any());
            spy.saveAward(mockStageAward());
        });
    }

    @Test
    void thenReturnTest() {
        Assertions.assertThrows(NullPointerException.class, () -> {
            final ActivityRepository spy = spy(ActivityRepository.class);
            when(spy.saveAward(any())).thenReturn(true);
            spy.saveAward(mockStageAward());
        });
    }
}
複製代碼

image.png

Mockito 操作

連續執行

# stub
// 第一次執行 返回 true, 第二次執行 返回 false. 1 和 2 等價
1. doReturn(true).doReturn(false).when(repository).exist(ACTIVITY_ID);

2. when(repository.exist(ACTIVITY_ID)).thenReturn(true).thenReturn(false);
       

method(id) {
    bool r1 = repository.exist(id); // r1 = true
    // do something()
    bool r2 = repository.exist(id); // r2 = flase
}

複製代碼

Stub 傳參

參數匹配器

 org.mockito.ArgumentMatchers

image.png

固定參數

  doReturn(activity).when(repository).selectById(ACTIVITY_ID);
複製代碼

以上語句表示當 award() 方法執行 repository.selectById() 語句參數爲 ACTIVITY_ID 則返回 mock 的 activity 對象. 如果傳入的參數不等於 ACTIVITY_ID 時,則不會進行 stub.

任意參數

  doReturn(activity).when(repository).selectById(any());
複製代碼

以上語句表示當 award() 方法執行 repository.selectById() 語句參數爲 任意值 時則返回 activity 對象.

可以使用具體參數類型的參數匹配器,如

selectById(Long id)  => anyLong()
複製代碼

image.png

多種參數

    redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
    
=>  doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
    // 或者
    doReturn(true).when(redisTemplate).expire(anyString(), eq(2L), eq(TimeUnit.HOURS));
    // 或者
    Long time = 2L;
    doReturn(true).when(redisTemplate).expire(anyString(), eq(time), eq(TimeUnit.HOURS));
   
    doReturn(true).when(redisTemplate).expire(anyString(), eq(2L), eq(TimeUnit.HOURS));   
    doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), any());
    doReturn(true).when(redisTemplate).expire(any(), anyLong(), any());
    ... 
複製代碼

當使用參數匹配器時,必須所有的參數都要用匹配器的方式,而不允許一部分參數是固定值,一部分參數使用匹配器,使用常量/固定值需要用 eq() 去包裝.

image.png

image.png

參數匹配器有很多的組合方式,比較靈活,有興趣的可以自己去嘗試嘗試.

Mockito 更多使用方式,請自行搜索吧~

總結

單元測試應當只關注當前方法的業務邏輯,其它的外部依賴都應通過 Mock 的方式完成.

@SpringBootTest 應當用於集成測試,非特別必要的單元測試不推薦使用,每次調試都需要啓動 Spring 容器,個人覺得效率太低(當然最高效的還是不寫啦 (⊙︿⊙)..)

最後,本文僅展示了一個 case 來示範如何 mock 的,但是 mock 思路基本上差不多,有機會會再輸出一些相關的測試用例來進行示範.

其它

單元測試覆蓋率

IDEA 支持覆蓋率查看,測試目錄或者測試類右鍵 Run 'xxTest' with Coverage

通過此操作能夠針對不同測試路徑來編寫不同的測試用例

image.png image.png image.png

紅色爲未覆蓋的,綠色爲已覆蓋

除此之外,在 CI/CD 利用 Jacoco 中設置質量門禁,單元測試覆蓋率低於多少的流水線會執行失敗,不允許提測、發佈、上線(照這樣,僅定個 10 % 可能大部分項目都無法發佈上線了).

Fairy(Mock 數據)

// java.util.Locale 指定區域,默認 ENGLISH
private final Fairy fairy = Fairy.create(Locale.CHINA);

@Test
void fairy() {   
    Person person = fairy.person();
    Company company = fairy.company();
    CreditCard creditCard = fairy.creditCard();
    TextProducer textProducer = fairy.textProducer();
    BaseProducer baseProducer = fairy.baseProducer();
    DateProducer dateProducer = fairy.dateProducer();
    NetworkProducer networkProducer = fairy.networkProducer();
}
複製代碼

jFairy by Codearte

完整依賴

<dependencies>
     <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.codearte.jfairy</groupId>
        <artifactId>jfairy</artifactId>
        <version>0.5.9</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.3.12.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
複製代碼

業務邏輯

/**
 * @author 菁蕪
 * @since 2021/7/22 - 20:17
 */
@Service
public class ActivityService {

    private static final String FISSION_POSTER_AWARD = "activity:poster:award:%s";
    private final ActivityRepository repository;
    private final RedisTemplate<String, Object> redisTemplate;

    public ActivityService(ActivityRepository repository, RedisTemplate<String, Object> redisTemplate) {
        this.repository = repository;
        this.redisTemplate = redisTemplate;
    }

    public void award(AwardDTO dto) {
        String id = dto.getActivityId();
        String stageId = dto.getStageId();
        String userId = dto.getUserId();

        final ActivityDO activity = repository.selectById(id);
        if (Objects.isNull(activity)) {
            throw new RuntimeException();
        }

        String hashKey = String.format(FISSION_POSTER_AWARD, dto.getPosterId());
        String key = String.valueOf(stageId);

        Object result = redisTemplate.opsForHash().get(hashKey, key);
        if (Objects.isNull(result)) {
            Boolean exist = repository.exist(id, stageId, userId);
            if (exist) {
                redisTemplate.opsForHash().put(hashKey, key, true);
                redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
                return;
            }
        } else if ((Boolean) result) {
            return;
        }

        ActivityStageDO stage = repository.selectStage(stageId, id);
        if (Objects.isNull(stage)) {
            throw new RuntimeException();
        }
        ActivityStageAwardDO entity = new ActivityStageAwardDO()
                .setActivityId(id).setStageId(stageId)
                .setUserId(userId).setStageNum(stage.getStageNum());

        repository.saveAward(entity);

        redisTemplate.opsForHash().put(hashKey, key, true);
        redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
    }

}
複製代碼

項目地址

Spring Boot UT 之 Junit5

附部分參考文章,更多內容請自行搜索 -.-

參考

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