一文詳盡單元測試


前言

如果你認為單元測試會降低開發效率,那么它做的事就是讓你的開發效率少降低一點;如果你認為單元測試可以提高開發效率,那么恭喜你,它會是一份寶藏。

這是一篇涵蓋了大部分場景下需要用到的單元測試方法介紹,不管你是新手還是老鳥,都建議讀讀看。

本文並不會去傳導單元測試的重要性之類的思想,這不是本文的重點,本文只說明如何寫單元測試

案例

我們以SpringBoot構建一個簡單的demo

引入依賴:

<!-- web環境,為后面的接口測試所准備-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 測試包 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>

命名規范

測試類的命名一般要以Test為后綴,例如:XxxTest

測試方法的命名一般要以test為前綴,例如:testXxx

注意:如果你的類名不是XxxTest,那么你在執行類似maven test命令時,是不會自動測試這個類的。

這個規范是在maven-surefire-plugin插件中約定的,你也可以自定義的設置你自己的命名規范,但是不建議這樣做。

簡單測試

簡單測試只需在測試方法上加上Test注解即可

適用場景:測試一些工具類,驗證心中所想(比如忘了正則怎么寫了)

新建測試類: HelloTest, 測試方法:testHello

import org.junit.jupiter.api.Test;

public class HelloTest {
    
    @Test
    public void testHello(){
        System.out.println("Hello World!");
    }
}

接下來只需輕輕點擊測試按鈕

1、運行整個測試類,測試類中所有的測試方法(加了Test注解的)

2、運行這個測試方法(點開運行方式的界面)

3、直接運行這個測試方法

4、以debug的方式運行這個測試方法

5、以測試覆蓋率的方式運行這個測試方法

一般是點3、4這兩個

效果:

關於斷言

斷言的意思就是:... 斷言!

有時候我們測試了某個方法,在當時我們知道結果是正確的,但是很可能過了幾天:咦,這代碼是我寫的?

所以加個斷言就很有必要了,它能讓我們知道:只要測試結果通過了斷言,那么就是這個被測試的方法就是正確的。如果沒有通過,那就需要好好檢查一下代碼了!

那么斷言應該怎么寫呢?

import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.jupiter.api.Test;

public class HelloTest {

    @Test
    public void testAssert(){
        int a = 1, b =2 ;
        // 斷言
        MatcherAssert.assertThat(a + b, CoreMatchers.is(3));
    }
}

第一個參數是實際測試的結果,第二個是match函數,里面放的是期望值

你也可以用junit的Assert方法,我比較喜歡上面的

業務測試

所謂業務測試就是測試你的業務代碼,這種情況下,我們就需要用Spring環境了。

新建接口: FooService

public interface FooService {

    String hello();
}

實現類:FooServiceImpl

@Service
public class FooServiceImpl implements FooService {

    @Override
    public String hello() {
        System.out.println("foo hello");
        return "foo hello";
    }
}

測試類:FooTest

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;

    @Test
    public void testHello(){
        String hello = fooService.hello();
        MatcherAssert.assertThat(hello, CoreMatchers.is("foo hello"));
    }
}

注意:如果你的Test注解是junit4的: org.junit.Test,那么還需要在類上再加一個注解:@RunWith(SpringRunner.class)

數據測試

基本上每一個業務代碼都離不開數據庫,那么在做數據測試時,就離不開兩個問題:

1、初始數據從哪里來(比如在做查詢測試時)

2、測試產生的數據如何清除(比如在做新增測試時)

問題1:我們可以在測試方法上增加@Sql注解用於初始化數據

問題2:我們可以在測試方法上增加@Transactional@Rollback注解用於測試完畢自動回滾

案例:

假設我們要測試查詢邏輯,首先我們在test/resourcs下新建sql目錄,用於存放初始化數據sql

接着在sql目錄中新建test_foo_select.sql文件

insert into user (`name`) values ('張三');

新建測試方法:

import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;

    @Transactional
    @Rollback
    @Sql(value = "/sql/test_foo_select.sql")
    @Test
    public void testSelect(){
        // 假設該方法中調用了數據庫
        User user = fooService.selectUser("張三");
        MatcherAssert.assertThat(user.getName(), CoreMatchers.is("張三"));
    }

}

@Transactional和@Rollback注解是為了回滾初始化的測試數據

假設要測試修改數據邏輯

@Rollback
@Transactional
@Test
public void testInsert(){
  fooService.insertUser(new User("李四"));
}

通常來說,不管測試任何業務都需加上Rollback和Transactional注解

Before與After注解

如果在你的單元測試類中,所有方法都依賴於一份初始化數據文件,那么你還可以這樣寫

@Sql(value = "/sql/test_foo_select.sql")
@BeforeEach
public void init(){
	// 這里可以寫每個單元測試前需要做的事情
}

如果你用的是juint4, 那么使用的便是Before注解

同樣,還有AfterEachAfter注解,使用方式相同,這里就不再贅述。

接口測試

以上測試是在測試業務層邏輯,有時候我們還需要測試接口層邏輯,比如說參數校驗

新增測試接口:

@RequestMapping("/foo")
@RestController
public class FooController {

    @GetMapping
    public User getUser(String name){
        return new User(name);
    }
}

新增測試類:

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@AutoConfigureMockMvc
@SpringBootTest
public class FooControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testGet() throws Exception {
        // 構建請求
        MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/foo?name=張三");
        // 發起請求
        ResultActions resultActions = mockMvc.perform(builder);
        // 獲取結果
        MockHttpServletResponse response = resultActions.andReturn().getResponse();
        response.setCharacterEncoding("UTF-8");
        // 斷言http響應狀態碼是否為2xx
        resultActions.andExpect(MockMvcResultMatchers.status().is2xxSuccessful());
        // 獲取響應數據
        String result = response.getContentAsString();
        User user = JSON.parseObject(result, User.class);
        MatcherAssert.assertThat(user.getName(), CoreMatchers.is("張三"));
    }

}

測試接口雖然看起來很復雜,但是里面大多是樣板代碼,在實際開發中,可以將這些樣板代碼封裝到工具中

比如測試post請求時,代碼同樣如此

@Test
public void testPost() throws Exception {
  // 構建請求, 這里是唯一的變化,將get改為了post
  MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.post("/foo");
  // 發起請求
  ResultActions resultActions = mockMvc.perform(builder);
  // 獲取結果
  MockHttpServletResponse response = resultActions.andReturn().getResponse();
  response.setCharacterEncoding("UTF-8");
  // 斷言http響應狀態碼是否為2xx
  resultActions.andExpect(MockMvcResultMatchers.status().is2xxSuccessful());
  // 獲取響應數據
  String result = response.getContentAsString();
  MatcherAssert.assertThat(result, CoreMatchers.is("true"));
}

MockMvcRequestBuilders里面有很多方法,這里給出常用的幾個

// post請求
MockMvcRequestBuilders.post("/foo")
  						  // 請求參數
                .queryParam("key", "value")
                // header
                .header("token", "123456")
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                // 請求body
                .content(JSON.toJSONString(new User("張三")))

Mock測試

在如今分布式、微服務越來越火的情況下,一個系統總是不可避免的會與其他系統交互,但是在測試時,我們是不希望發生這種情況的,因為這樣就需要依賴外部環境了。

單元測試的准則便是:能夠獨立運行。

此時,學會mock測試就是一件非常有必要的事情。

關於Mockito

spring-boot-test中,自帶一個叫Mockito的工具,它能夠幫助我們對不想調用的方法進行攔截,並且返回我們期望的結果

比如有一個FooService調用BarService的場景

當我們在測試時不想要真正調用barService,那么我們就可以使用Mockito進行攔截

基本Mock

新增BarService

public interface BarService {

    String mock();
}
@Service
public class BarServiceImpl implements BarService {

    @Override
    public String mock() {
        System.out.println("bar mock");
        return "bar mock";
    }
}

在FooService中添加mock方法

@Override
public String mock() {
  System.out.println("foo mock");
  return barService.mock();
}

使用mocktio測試

import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;
    // 使用MockBean注解注入barService
    @MockBean
    private BarService barService;
  
    @Test
    public void testMock(){
        // 當調用barService.mock方法是返回it's mock
        Mockito.doReturn("it's mock").when(barService).mock();
        String mock = fooService.mock();
        MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
    }

}

使用參數控制mock

可能有時候會有這種奇怪的需求,當參數為1時使用mock,當參數為其他調用真實方法

@Test
public void testMockHasParam(){
  // 當參數為1時生效
  Mockito.doReturn("it's mock").when(barService).mock(Mockito.eq(1));
  String mock = fooService.mock(1);
  MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
}

如果你覺得任何參數都應該使用mock,那你可以在參數上寫:Mockito.any()

Mockito中還有很多類似的方法,如果你覺得還不滿足,mockito允許你自定義規則

@Test
public void testMockHasParam2() {
  // 當參數為1時生效
  Mockito.doReturn("it's mock")
    .when(barService)
    .mock(Mockito.intThat(arg -> {
      // 這里寫你的邏輯
      return arg.equals(1);
    }));
  String mock = fooService.mock(1);
  MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
}

注意:雖然以上案例看起來像:當參數不為1時就調用真實方法,但實際上並不是的,因為barService實際上是Mockito生成的代理類,僅僅是個代理類,它並未持有真正的barService, 所以當不滿足mock邏輯時,它永遠都是返回null

那么該如何解決這個問題呢?

部分方法Mock

參數控制mock與部分方法mock的場景是共通的:在特定的情況下需要調用真實方法

改動方式特別簡單:將原來的@MockBean注解替換為@SpyBean

SpyBean注解是真正的將Spring容器中的BarService進行代理,而不是簡單的僅僅生成代理類,所以它具備了真正調用方法的能力

比如我們在FooService中新增方法:partMock

public String partMock() {
  barService.hello();
  return barService.mock();
}

現在我們期望在barService.hello()調用實際方法,調用barService.mock()時被mockito攔截

import org.springframework.boot.test.mock.mockito.SpyBean;

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;

    @SpyBean
    private BarService barService;

    @Test
    public void testPartMock() {
        Mockito.doReturn("it's mock").when(barService).mock();
        final String mock = fooService.partMock();
        MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
    }
}

你會發現使用方式沒有任何的變化

靜態方法Mock

你可能想問,為什么Mock還要區分是不是靜態方法?這是因為靜態方法mock是Mockito所不具備的能力,我們需要另外一個組件來完成:powermock

但很可惜的是,powermock只支持junit4,而且最近的release是在2020年11月2日

不管怎樣,我們還是應該學習它,讓我們在未來能夠遇到這種問題時有解決辦法

引入依賴:

<dependency>
  <groupId>org.powermock</groupId>
  <artifactId>powermock-module-junit4</artifactId>
  <version>2.0.2</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.powermock</groupId>
  <artifactId>powermock-api-mockito2</artifactId>
  <version>2.0.2</version>
  <scope>test</scope>
</dependency>

新增方法:

@Override
public String powermock() {
  return JSON.toJSONString(new User("張三"));
}

現在,我們想要攔截JSON.toJSONString方法,並且期望它返回xxx

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.powermock.modules.junit4.PowerMockRunnerDelegate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

// 代理SpringRunner
@PowerMockRunnerDelegate(SpringRunner.class)
// 使用PowerMockRunner
@RunWith(PowerMockRunner.class)
// 延遲加載以下包中的所有類
@PowerMockIgnore(value = { "javax.management.*", "javax.net.ssl.*", "javax.net.SocketFactory", "oracle.*"})
@SpringBootTest
// 想要mock的類
@PrepareForTest(JSON.class)
public class PowermockTest {

    @Autowired
    private FooService fooService;

    @Test
    public void testPowermock(){
        // 固定寫法
        PowerMockito.mockStatic(JSON.class);
        // 以下寫法與mockito相同
        Mockito.when(JSON.toJSONString(Mockito.any())).thenReturn("xxx");
        String s = fooService.powermock();
        MatcherAssert.assertThat(s, CoreMatchers.is("xxx"));

    }
}

對於PowerMockIgnore注解筆者也不是太懂其中的原理,如果你在測試時發現哪個包報錯,並且是你看不懂的,那么你就把這個包加到這里面就好了。

在Mockito 4.x版本,在Mockito-inline子項目中對靜態方法mock有所支持。

由於該案例的springboot版本自帶的mockito為3.x版本,所以需要對依賴進行如下更改

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
  <exclusions>
    <!-- 排除低版本的mockito -->
    <exclusion>
      <artifactId>mockito-core</artifactId>
      <groupId>org.mockito</groupId>
    </exclusion>
  </exclusions>
</dependency>
<!-- 引入更高的版本 -->
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <version>4.2.0</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-inline</artifactId>
  <version>4.2.0</version>
  <scope>test</scope>
</dependency>

使用方法極其簡單:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class MockitoInlineTest {

    @Autowired
    private FooService fooService;

    @Test
    public void testMockitoInline(){
        Mockito.mockStatic(JSON.class);
        Mockito.when(JSON.toJSONString(Mockito.any())).thenReturn("xxx");
        String s = fooService.powermock();
        MatcherAssert.assertThat(s, CoreMatchers.is("xxx"));
    }

}

配置文件的划分

大部分情況下,單元測試時所使用的配置與實際在服務器上運行時所用的配置是相同的,那么我們就可以單獨在test/resources包下放入測試所用配置。

注意:測試包下的配置文件與main/resources下的配置文件是替換的關系

比如測試包下有一個application.yaml文件,里面的配置為:

abc: xxx

main/resources下也有一個application.yaml文件,里面的配置為:

def: xxx

實際運行時並非像往常一樣是合並所有配置,而是只存在

abc: xxx

利用這樣的方法,我們可以在單元測試時指定我們需要的環境,比如在微服務系統中單元測試時不需要連接注冊中心,那么我們就可以在配置文件中將它關掉。

小結

編寫單元測試是一件開頭較難的事,對於未接觸過單元測試的開發人員來說,可能編寫一個接口需要1個小時,但是在編寫單元測試的功夫上需要花費2個小時。本文的目的就在於能夠讓這樣的同學快速的學習編寫單元測試,讓寫單元測試也能快樂起來。

希望小伙伴們最終都能達到:單元測試可以提高開發效率

案例地址:https://gitee.com/lzj960515/junit-demo


如果我的文章對你有所幫助,還請幫忙點贊、關注、轉發一下,你的支持就是我更新的動力,非常感謝!

個人博客空間:https://zijiancode.cn


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM