最佳單元測試實踐


title: 最佳單元測試實踐
date: 2021/09/08 15:11


注:本文使用的是 SpringBootTest2.x + Junit4 + Mockito,本文的前提是你已經(jīng)會使用這些工具了。

引言:常見單元測試方法

@RunWith(SpringRunner.class)    
@SpringBootTest(classes = Application.class)    // 1
@Transactional  // 2
@Rollback
public class HelloServiceTest {

    @Autowired
    private HelloService helloService;

    @Test
    public void sayHello() {
        helloService.sayHello("zhangsan");  // 3
    }
}

這樣的寫法不符合規(guī)范的地方如下:

  1. 使用 @SpringBootTest注解將主啟動類 Application 的 BeanDefintion 加入到了 Spring 容器中,從而加載了所有的 Bean,導(dǎo)致大量的時間耗費在于容器啟動上。而且,如果被 @Component 注解的類里有多線程方法,那么在你執(zhí)行單元測試的時候,由于多線程任務(wù)的影響,就可能對你的數(shù)據(jù)庫造成了數(shù)據(jù)修改,即使你使用了事務(wù)回滾注解 @Transactional。
    • eg. 在我運行單元測試的時候,代碼里的其他類的多線程中不停接收activeMQ消息,然后更新數(shù)據(jù)庫中對應(yīng)的數(shù)據(jù)。跟單元測試的執(zhí)行過程交叉重疊,導(dǎo)致單元測試失敗。其他組員在操作數(shù)據(jù)庫的時候,也因為我無意中帶起的多線程更改了數(shù)據(jù)庫,造成了開發(fā)上的困難。
  2. 單元測試應(yīng)與數(shù)據(jù)庫完全隔離(不應(yīng)受到外界環(huán)境的影響,違反了可重復(fù)的原則),數(shù)據(jù)庫相關(guān)操作應(yīng)使用 mock 代替。
  3. 沒有使用斷言(assert),無法實現(xiàn)自動化判斷

一、單元測試的原則

1.1 AIR 原則

  • Automatic(自動化的):自動通過一系列的斷言給出執(zhí)行結(jié)果,而不需要人為去判斷,在幾十上百的測試用例下很難人為的去判斷。
  • Independent(獨立的):測試用例之間不能相互依賴影響,是獨立的
  • Repeatable(可重復(fù)的):單元測試是可以重復(fù)執(zhí)行的,不能受到外界環(huán)境的影響,如數(shù)據(jù)庫、遠程調(diào)用、中間件等外部依賴不能影響測試用例的執(zhí)行。

1.2 BCDE 原則

保證被測試模塊的交付質(zhì)量。

  • B:Border,邊界值測試,包括循環(huán)邊界、特殊取值、特殊時間點、數(shù)據(jù)順序等。
  • C:Correct,正確的輸入,并得到預(yù)期的結(jié)果。
  • D:Design,與設(shè)計文檔相結(jié)合,來編寫單元測試。
  • E:Error,強制錯誤信息輸入(如:非法數(shù)據(jù)、異常流程、業(yè)務(wù)允許外等),并得到預(yù)期的結(jié)果。

1.3 使用 Mock 對象

  1. Mock 可以用來解除外部服務(wù)依賴,從而保證了測試用例的獨立性

  2. Mock 可以減少全鏈路測試數(shù)據(jù)準備,從而提高了編寫測試用例的速度

    傳統(tǒng)的集成測試,需要準備全鏈路的測試數(shù)據(jù),可能某些環(huán)節(jié)并不是你所熟悉的。最后,耗費了大量的時間和經(jīng)歷,并不一定得到你想要的結(jié)果。現(xiàn)在的單元測試,只需要模擬上游的輸入數(shù)據(jù),并驗證給下游的輸出數(shù)據(jù),編寫測試用例并進行測試的速度可以提高很多倍。

  3. Mock可以模擬一些非正常的流程,從而保證了測試用例的代碼覆蓋率

    根據(jù)單元測試的BCDE原則,需要進行邊界值測試(Border)和強制錯誤信息輸入(Error),這樣有助于覆蓋整個代碼邏輯。在實際系統(tǒng)中,很難去構(gòu)造這些邊界值,也能難去觸發(fā)這些錯誤信息。而 Mock 從根本上解決了這個問題:想要什么樣的邊界值,只需要進行Mock;想要什么樣的錯誤信息,也只需要進行Mock。

  4. Mock可以不用加載項目環(huán)境配置,從而保證了測試用例的執(zhí)行速度
    在進行集成測試時,我們需要加載項目的所有環(huán)境配置,啟動項目依賴的所有服務(wù)接口。往往執(zhí)行一個測試用例,需要幾分鐘乃至幾十分鐘。采用Mock實現(xiàn)的測試用例,不用加載項目環(huán)境配置,也不依賴其它服務(wù)接口,執(zhí)行速度往往在幾秒之內(nèi),大大地提高了單元測試的執(zhí)行速度。

什么是集成測試?

集成測試是指在單元測試的基礎(chǔ)上,將所有模塊(或單元)按照設(shè)計要求(如根據(jù)結(jié)構(gòu)圖)組裝成為子系統(tǒng)或系統(tǒng),進行集成測試。

實踐表明,一些模塊雖然能夠單獨地工作,但并不能保證連接起來也能正常的工作。 一些局部反映不出來的問題,在全局上很可能暴露出來。

由于集成測試的單位是一整個系統(tǒng),一般有專業(yè)的測試人員來進行,本文只做簡單介紹,不繼續(xù)探討。

二、最佳實踐

寫好單元測試,以下兩點尤為重要:

  1. 使用 Mock 脫離數(shù)據(jù)庫
  2. 不使用@SpringBootTest注解加載全部 BeanDefinition,轉(zhuǎn)而使用@ContextConfiguration注解加載需要的配置類

2.1 使用 Mock 代替數(shù)據(jù)庫

mockito 為 Junit 提供了MockitoJUnitRunner用于解析單元類中 mockito 相關(guān)注解。當然使用 SpringRunner 也行,因為他內(nèi)置了 MockitoTestExecutionListener 來處理 mockito 的注解。

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.Arrays;
import java.util.List;

@RunWith(MockitoJUnitRunner.class)
// 使用 SpringRunner 也行,因為他內(nèi)置了 MockitoTestExecutionListener 來處理 mockito 的注解
// @RunWith(SpringRunner.class)
public class MockitoTest {

    /**
     * 如 UserService 是接口,則需 new 出他的實現(xiàn)類,如下:
     *
     * <pre> {@code
     * @InjectMocks
     * private UserService userService = new UserServiceImpl();
     * }</pre>
     * 
     * 否則使用 @InjectMocks 注解無法注入
     */
    @InjectMocks
    private UserService userService;

    @Mock
    private UserRepository userRepository;

    @Test
    public void test() {

        // 模擬依賴方法
        Mockito.when(userRepository.findAll())
                .thenReturn(Arrays.asList(new User(1, "zs"), new User(2, "ls")));

        // 調(diào)用被測方法
        List<User> users = userService.listAll();

        // 斷言方法結(jié)果
        Assert.assertEquals(2, users.size());

        // 驗證依賴方法
        // 是否只調(diào)用了一次 findAll() 方法
        Mockito.verify(userRepository).findAll();
        // 是否與 userRepository 對象再無交互
        Mockito.verifyNoMoreInteractions(userRepository);
    }
}

注:如對象較大,則可在類路徑下存放 json 文件,通過 Json 工具將其序列化成對象。

Junit5 版,注意 Test 注解的包名與上面不同

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Arrays;
import java.util.List;

@ExtendWith(MockitoExtension.class)
//@ExtendWith(SpringExtension.class)
public class MockitoTest {

    @InjectMocks
    private UserService userService;

    @Mock
    private UserRepository userRepository;

    @Test
    public void test() {
        
        // 模擬依賴方法
        Mockito.when(userRepository.findAll())
                .thenReturn(Arrays.asList(new User(1, "zs"), new User(2, "ls")));

        // 調(diào)用被測方法
        List<User> users = userService.listAll();
        
        // 斷言方法結(jié)果
        Assertions.assertEquals(2, users.size());

        // 驗證依賴方法
        // 是否只調(diào)用了一次 findAll() 方法
        Mockito.verify(userRepository).findAll();
        // 是否與 userRepository 對象再無交互
        Mockito.verifyNoMoreInteractions(userRepository);
    }
}

如果一個方法的調(diào)用鏈路如下:Controller -> Service -> Repo,那么應(yīng)該將其拆分成兩個單元來測試:

  1. TestController + mockService
  2. TestService -> mockRepo

如果在測試Controller 的時 mock 了Repo(TestController + @Autowired Service + mockRepo),這樣這就不能叫做單元測試了。

單元測試只保證每個單元能夠單獨地工作,但并不能保證連接起來也能正常的工作;上面這種多個跨了多個單元的應(yīng)該使用集成測試。

2.2 只加載需要的 Bean

上面的寫法只適用于不使用 Spring 給我們提供的功能情況下,但往往有的時候我們需要他們給我們提供的功能,就比如通過@Async注解啟動異步任務(wù)。那么這種情況我們要怎樣做單元測試呢?讓我們回到?jīng)]有 SpringBoot 的時代,看看 Spring Test 是怎樣進行單元測試的。

Spring Test 為我們提供了@ContextConfiguration注解,該注解可以加載 Spring 的 xml 配置文件和配置類,使用方式如下:

被測試 bean:

@Component
public class AsyncService {

    private static final Logger log = LoggerFactory.getLogger(AsyncService.class);

    @Autowired
    private FeginClient feginClient;

    @Async
    public Future<Integer> startTask(String taskInstanceId) {
        log.info("taskInstanceId:「{}」", taskInstanceId);
        return new AsyncResult<>(feginClient.calc(taskInstanceId));
    }
}

單元測試:

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.concurrent.ExecutionException;

@RunWith(SpringRunner.class)
// 只引入需要的 bean
@ContextConfiguration(classes = {
        // 被測 bean
        AsyncService.class,
        // 開啟異步注解解析
        SpringTest2.AsyncTestConfig.class,
        // 線程池默認的自動配置類,如果自定義了則替換成自定義的配置類
        TaskExecutionAutoConfiguration.class
})
public class SpringTest2 {

    /**
     * 由于需要使用 Spring 提供的異步功能,故需要使用 Spring 提供的 Mock 注解
     */
    @MockBean
    private FeginClient feginClient;

    /**
     * AsyncService 中依賴了 FeginClient,會將上面 mock 的對象進行 DI
     */
    @Autowired
    private AsyncService asyncService;

    @Test
    public void testCalc() throws ExecutionException, InterruptedException {
        // 模擬依賴方法
        Mockito.when(feginClient.calc(ArgumentMatchers.anyString())).thenReturn(1);

        // 調(diào)用被測方法
        Integer result = asyncService.startTask("1").get();

        // 斷言方法結(jié)果
        Assert.assertEquals(1, result.intValue());

        // 驗證依賴方法
        Mockito.verify(feginClient).calc(ArgumentMatchers.anyString());
        Mockito.verifyNoMoreInteractions(feginClient);
    }

    @EnableAsync
    @Configuration
    public static class AsyncTestConfig {
    }
}

都寫的挺好的,按照順序看一下:
https://www.cnblogs.com/myitnews/p/12330297.html
https://fanlychie.github.io/post/spring-boot-testing.html
https://blog.51cto.com/codewalker/4375122

單元測試:

  • spring test & junit
  • @ContextConfiguration、@RunWith(SpringRunner.class)、@Test

切面測試(啟動一部分組件):

  • spring-boot-test-autoconfig
  • @* Test 系列注解
@DataRedisTest:該注解用于測試對Redis操作,自動掃描被@RedisHash描述的類,并配置Spring Data Redis的庫。該注解會啟動一個內(nèi)存中的Redis服務(wù)器,并使用隨機端口進行監(jiān)聽,同時自動配置RedisTemplate和StringRedisTemplate等bean,以便我們可以輕松地執(zhí)行Redis操作。

@DataJpaTest:該注解用于測試基于JPA的數(shù)據(jù)庫操作,同時提供了TestEntityManager替代JPA的EntityManager。該注解會用嵌入式的數(shù)據(jù)庫(例如H2)創(chuàng)建一個測試環(huán)境,同時自動配置EntityManagerFactory、DataSource、TransactionManager等bean,以便我們可以輕松地測試JPA實體和Repository層代碼。

@DataJdbcTest:該注解用于測試基于Spring Data JDBC的數(shù)據(jù)庫操作。該注解會用嵌入式的數(shù)據(jù)庫(例如H2)創(chuàng)建一個測試環(huán)境,并自動配置JdbcTemplate、NamedParameterJdbcTemplate等bean,以便我們可以輕松地測試JDBC操作。

@JsonTest:該注解用于測試JSON的序列化和反序列化。該注解會自動配置Jackson ObjectMapper bean,并提供了一些輔助方法,方便我們進行JSON序列化和反序列化操作。

@WebMvcTest:該注解用于測試Spring MVC中的controllers。該注解會自動配置MockMvc bean,并提供了一些輔助方法,方便我們進行controller層的測試操作。

@WebFluxTest:該注解用于測試Spring WebFlux中的controllers。該注解會自動配置WebTestClient bean,并提供了一些輔助方法,方便我們進行WebFlux相關(guān)的測試操作。

@RestClientTest:該注解用于測試對REST客戶端的操作。該注解會自動配置RestTemplate bean,并提供了一些輔助方法,方便我們進行REST客戶端相關(guān)的測試操作。

@DataLdapTest:該注解用于測試對LDAP的操作。該注解會使用嵌入式的LDAP服務(wù)器創(chuàng)建一個測試環(huán)境,并自動配置LdapTemplate bean和EmbeddedLdap bean,以便我們可以輕松地測試LDAP操作。

@DataMongoTest:該注解用于測試對MongoDB的操作。該注解會用嵌入式的MongoDB服務(wù)器創(chuàng)建一個測試環(huán)境,并自動配置MongoTemplate、MongoClient等bean,以便我們可以輕松地測試MongoDB操作。

@DataNeo4jTest:該注解用于測試對Neo4j的操作。該注解會使用嵌入式的Neo4j服務(wù)器創(chuàng)建一個測試環(huán)境,并自動配置Neo4jTemplate、Neo4jClient等bean,以便我們可以輕松地測試Neo4j操作。

功能測試(啟動全部容器):@SpringBootTest

契約測試:todo

import com.dist.xdata.dgpm.dao.TopicCatalogRepo;
import com.dist.xdata.dgpm.entity.TopicCatalog;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import javax.persistence.EntityManager;
import java.util.List;

/**
 * @author yujx
 * @since 2023/6/13 4:53 PM
 */
@DataJpaTest
@ExtendWith(SpringExtension.class)
public class TestDataJpa {

    @Autowired
    private EntityManager entityManager;

    @Autowired
    private TestEntityManager testEntityManager;

    @Autowired
    private TopicCatalogRepo repo;

    @Test
    public void test() {
        System.out.println("entityManager = " + entityManager);
        System.out.println("testEntityManager = " + testEntityManager);
        
        TopicCatalog save = repo.save(RandomUtil.nextObject(TopicCatalog.class));
        System.out.println("save = " + save);
        List<TopicCatalog> all = repo.findAll();
        System.out.println("all = " + all);
    }
}

https://raw.githubusercontent.com/x54256/pic_home/master/img/202306131738520.png

三、切片測試

測試一個普通接口

@WebMvcTest(value = {XWebTestController.class})   // 被測類
public class XWebTestControllerMvcTest {

    @Autowired
    private MockMvc mockMvc;

    /**
     * 測試 /rest/xweb/result_wrapper/dto 端點
     * 驗證返回的HTTP狀態(tài)碼為200 OK
     * 驗證響應(yīng)內(nèi)容是一個空的DemoDTO對象(JSON格式)
     */
    @Test
    public void testResultDtoWrapper() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/rest/xweb/dto"))
                .andExpect(status().isOk())
                .andExpect(content().contentType("application/json"))
                .andExpect(jsonPath("$.id").hasJsonPath())
                .andExpect(jsonPath("$.name").isEmpty())
                .andExpect(jsonPath("$.createTime").doesNotExist())
                .andReturn();
        String contentAsString = mvcResult.getResponse().getContentAsString();
        System.out.println("contentAsString = " + contentAsString);
    }
}

結(jié)果:

{"id":null,"name":null,"createTime":null}

我們發(fā)現(xiàn)結(jié)果沒有進行包裝(ResultWrapper),怎么辦呢?有兩種方法:

  1. 通過 spring-test 的 org.springframework.test.context.ContextConfiguration 注解引入所有相關(guān)的類(普通類、配置類、自動配置類)【推薦】

    注意:@WebMvcTest 注解中的 value 字段會被 @ContextConfiguration 注解覆蓋掉(忘記為啥了,反正我有印象)

@WebMvcTest
@ContextConfiguration(classes = {
        XWebTestController.class,   // 被測類
        XWebAutoConfiguration.class,    // xdata-web 模塊的自動配置類
        XDataInfrastructureAutoConfiguration.class, // xdata-web 模塊依賴的基礎(chǔ)配置類
        XRbacAutoConfiguration.class    // xdata-web 模塊依賴的用戶信息配置類
})
public class XWebTestControllerMvcTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testResultDtoWrapper() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/rest/xweb/dto"))
                .andExpect(status().isOk())
                .andExpect(content().contentType("application/json"))
                .andExpect(jsonPath("$.data.id").hasJsonPath())
                .andExpect(jsonPath("$.data.name").isEmpty())
                .andExpect(jsonPath("$.data.createTime").doesNotExist())
                .andReturn();
        String contentAsString = mvcResult.getResponse().getContentAsString();
        System.out.println("contentAsString = " + contentAsString);
    }
}
  1. 通過 org.springframework.boot.autoconfigure.ImportAutoConfiguration 引入自動配置類(不能用來引入普通類、配置類)【不推薦】
@WebMvcTest(value = {XWebTestController.class})   // 被測類
@ImportAutoConfiguration(value = {
        XWebAutoConfiguration.class,    // xdata-web 模塊的自動配置類
        XDataInfrastructureAutoConfiguration.class, // xdata-web 模塊依賴的基礎(chǔ)配置類
        XRbacAutoConfiguration.class    // xdata-web 模塊依賴的用戶信息配置類
})
public class XWebTestControllerMvcTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testResultDtoWrapper() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/rest/xweb/dto"))
                .andExpect(status().isOk())
                .andExpect(content().contentType("application/json"))
                .andExpect(jsonPath("$.data.id").hasJsonPath())
                .andExpect(jsonPath("$.data.name").isEmpty())
                .andExpect(jsonPath("$.data.createTime").doesNotExist())
                .andReturn();
        String contentAsString = mvcResult.getResponse().getContentAsString();
        System.out.println("contentAsString = " + contentAsString);
    }
}

返回結(jié)果:

{
  "app": "product",
  "version": "unknown",
  "buildTime": "unknown",
  "status": 200,
  "code": 1,
  "respCode": "00000",
  "message": "success",
  "data": {
    "id": null,
    "name": null,
    "createTime": null
  },
  "detailMessage": null,
  "detailStackTraces": null
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容