Spring Boot-如何优雅的写单元测试

Spring Boot-如何优雅的写单元测试

什么是单元测试

  • 当一个测试满足下面任意一点时,测试就不是单元测试
    • 与数据库交流
    • 与网络交流
    • 与文件系统交流
    • 不能与其他单元测试在同一时间运行
    • 不得不为运行它而作一些特别的事
  • 如果一个测试做了上面的任何一条,那么它就是集成测试

Mockito 介绍

  • 单元测试就是对一个系统中的某个最小单元的逻辑正确性的测试,通常是对一个方法来进行测试,因为只测试逻辑正确性,所以这个测试是独立的,不与任何外界环境相关,比如不需要连接数据库,不访问网络和文件系统,不依赖其他单元测试
  • Mockito 是一个用来在单元测试中快速模拟那些需要与外界环境沟通的对象,以便我们快速的、方便的进行单元测试而不用启动整个系统
  • 采用 Mock 框架,我们可以 虚拟 出一个 外部依赖,只注重代码的 流程与结果,真正地实现测试目的
  • Mock测试框架的好处:
    1. 可以很简单的虚拟出一个复杂对象(比如虚拟出一个接口的实现类);
    2. 可以配置 mock 对象的行为;
    3. 可以使测试用例只注重测试流程与结果;
    4. 减少外部类、系统和依赖给单元测试带来的耦合
  • Mockito的局限
    1. 不能mock静态方法
    2. 不能mock私有方法
    3. 不能mock final class
  • Mock原理
    • mock 模拟的对象可以理解为真实方法的一个代理,每次对方法的调用其实都是调用了代理方法,这个代理方法是一个空方法,不会做任何事情
  • Mock两种使用方法
    • 直接代码mock一个对象
    • 用@Mock造一个对象

Mockito使用

  • 方法1:调用完方法后指定返回值

    //格式
    Mockito.when(调用的类.方法).thenReturn(指定的返回值);
    // 示例:
    Mockito.when(service01.sendMail(Mockito.anyString(), Mockito.anyString()
    
  • 如果是 @Mock 标注的对象方法,这样设置后不会进去方法执行,直接返回指定值。

  • 如果是 @Spy 标注的对象方法,这样设置后会进去执行方法,但是返回指定的返回值。

  • 指定方法的返回值时,入参的设置可以使用 matcher 做匹配,其中的 any 方法匹配任意值

      // 示例1:当使用任何整数值调用 userService 的 getUser() 方法时,就回传一个自定义 User 对象
      Mockito.when(userService.getUserById(Mockito.anyInt())).thenReturn(new User(3, "I'm mock"));
      User user1 = userService.getUserById(3); // 回传的user的名字为I'm mock
      User user2 = userService.getUserById(200); // 回传的user的名字也为I'm mock
    
      // 示例2:限制只有当参数的数字是 3 时,才会回传名字为 I'm mock 3 的 user 对象
      Mockito.when(userService.getUserById(3)).thenReturn(new User(3, "I'm mock"));
      User user1 = userService.getUserById(3); // 回传的user的名字为I'm mock
      User user2 = userService.getUserById(200); // 回传的user为null
    
      // 示例3:当调用 userService 的 insertUser() 方法时,不管传进来的 user 是什么,都回传 100
      Mockito.when(userService.insertUser(Mockito.any(User.class))).thenReturn(100);
      Integer i = userService.insertUser(new User()); //会返回100
    
    • 有多个入参的方法,一个入参使用了 matcher 做匹配,那么其他入参也要用 matcher 匹配。例如下面用了 any 方法,那么第二入参就不能写死了,可以用 eq 方法来做匹配
      // 错误的写法:
      Mockito.when(service01.sendMail(Mockito.anyString(), "王五")).thenReturn(true);
      // 正确的写法:
      Mockito.when(service01.sendMail(Mockito.anyString(), Mockito.eq("王五"))).thenReturn(true);
    
    
    • 方法2: 直接返回指定值
      // 格式:
      Mockito.doReturn(方法返回值).when(spy标注的对象).调用的方法;
      // 示例:
      Mockito.doReturn(false).when(service01).sendMail(Mockito.anyString(), Mockito.anyString());
      
    • 方式3: 设置抛出异常
          // 格式:
          Mockito.when(调用方法).thenThrow(抛出的异常类);
      
          // 示例:
          Mockito.when(service01.sendMail(Mockito.anyString(),Mockito.anyString())).thenThrow(RuntimeException.class);
      

@Spy 的使用

  • Spy 跟 Mock 不同之处在于,它是会真正执行方法逻辑的。相同之处是它可以指定方法的返回值。

InjectMocks 的使用

  • @InjectMocks 用来给标注的成员变量填充带有 @Mock 和 @Spy 标签的 bean,可以理解为它会吸取所有 @Mock 和 @Spy 标注的bean 为自己所用;
  • 如果是嵌套的 bean 可以用 ReflectionTestUtils.setFileld() 绑定成员变量。

@MockBean 的使用

  • @MockBean 是 SpringBoot 中增加的,用来支持容器中的 mock 测试。它跟 mock 的使用逻辑是一样,只是它修饰的对象是容器中的对象,也就是 bean 对象。

@SpyBean 的使用

  • @SpyBean 也是 SpringBoot 增加的一个注解,用来支持 Spring 容器的单元测试,它与 Spy 的逻辑基本一致,不同之处就在于它标注的对象是容器对象。具体使用可以参考上面 @MockBean 的使用方法。

方法的校验和断言

  • 通常写单元测试就是要断言方法的执行是否符合预期,除了 junit 提供的 Assert 类中的方法外,Mockito 也提供了几种校验方法
  • 方法1:Mockito.verify() 方法断言方法是否被调用过
    // 格式:
    Mockito.verify(对象).对象的方法;

    // 示例1:校验list对象是否调用了add(“22”)方法
    Mockito.verify(list).add(“22”);
    // 示例2:检查调用 userService 的 getUserById()、且参数为3的次数是否为1次
    Mockito.verify(userService, Mockito.times(1)).getUserById(Mockito.eq(3)) ;
    // 示例3:验证调用顺序,验证 userService 是否先调用 getUserById() 两次,并且第一次的参数是 3、第二次的参数是 5,然后才调用insertUser() 方法
    InOrder inOrder = Mockito.inOrder(userService);
    inOrder.verify(userService).getUserById(3);
    inOrder.verify(userService).getUserById(5);
    inOrder.verify(userService).insertUser(Mockito.any(User.class));
  • 方法2:断言异常
       @Before
    public void init(){
        MockitoAnnotations.initMocks(this);
        // 让方法抛出异常
 		Mockito.when(service01.sendMail(Mockito.anyString(),Mockito.anyString()))
     		.thenThrow(RuntimeException.class);
    }

    // 必须抛出指定的异常才会通过测试
    @Test(expected=RuntimeException.class)
    public void testThrowException(){
        service01.sendMail("张三","李四");
    }
  • 方法3:Assert 类中的断言方法

测试Controller

  • 方式1:使用 @AutoConfigureMockMvc(推荐)
  • @AutoConfigureMockMvc 会自动注入 MockMvc,可以方便的指定入参或者是 header
    @RunWith(SpringRunner.class)
    @SpringBootTest
    @AutoConfigureMockMvc
    public class BookControllerTest2 {
        @Autowired
        public MockMvc mockMvc;

        @Test
        public void testGetBookInfo() throws Exception {
            MvcResult result = mockMvc.perform(
                    MockMvcRequestBuilders.post("/getBookInfo2").param("id","123").header("user","xiaoming"))
                    .andExpect(MockMvcResultMatchers.status().isOk())
                    .andReturn();
            System.out.println(result.getResponse().getContentAsString());
        }
    }


    @RestController
    public class BookController2 {
        @PostMapping("/getBookInfo2")
        public String getBookById(@RequestParam String id, @RequestHeader String user){
            System.out.println(user + "查询书籍信息,bookId=" + id);
            return "《java语言》";
        }
    }

  • 方式2:使用 TestRestTemplate 模板
  @RunWith(SpringRunner.class)
  //指定web环境,随机端口
  @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
  public class BookControllerTest {
      //这个对象是运行在web环境的时候加载到spring容器中
      @Autowired
      private TestRestTemplate testRestTemplate;

      @Test
      public void testGetBookInfo(){
          String result = testRestTemplate.getForObject("/getBookInfo?id=123456", String.class);
          System.out.println(result);
      }
  }


  @RestController
  public class BookController2 {
      @GetMapping("/getBookInfo")
      public String getBookById(@RequestParam String id){
          System.out.println("查询书籍信息,bookId=" + id);
          return "《java语言》";
      }
  }

RunWith使用

  • @RunWith(SpringRunner.class) 或者 @RunWith(SpringJUnit4ClassRunner.class):代表在 Spring 容器中运行单元测试。如果配合 @SpringBootTest 就是在 SpringBoot 容器中运行单元测试。

  • 注:SpringRunner 就是 SpringJUnit4ClassRunner 的别名,它们作用是一样的。

    @SpringBootTest(classes = MyApplication.class)	// classes 加载启动类
    @RunWith(SpringRunner.class)
    public class BaseTest {
        public void runUnitTest(){
        }
    }
    
  • @RunWith(MockitoJUnitRunner.class)
    可以理解为使用 Mockito工作运行单元测试,它会初始化 @Mock 和 @Spy 标注的成员变量

  • @RunWith(Suite.class):代表是一个集合测试类,一般是如下用法,也就是其可一次性测试多个用例

        @RunWith(Suite.class)
        @Suite.SuiteClasses({ServiceTest.class, A.class})
        public class AllTest {
        }
        public class ServiceTest{
            @Test
            public void test01(){ }
    
            @Test
            public void test02(){}
        }
    

加速Spring Boot 单元测试的执行速度

  1. 如果单元测试不涉及到Controller接口调用,可以配置webEnvironment = SpringBootTest.WebEnvironment.NONE不启动web容器
  2. 可以通过classes = {...}手动指定需要注册到容器中的类,如果不设置该属性,默认会注册应用中所有类
     @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,classes = {UserService.class, UserMapper.class})
     public class UserServiceTest {
       @Autowired
       private UserService userService;
    
       @Test
       public void testUserService() {assertEquals("user list", userService.queryUsers());}
     }
    
  3. webEnvironment
    • MOCK 启动一个模拟的Servlet环境 默认值
    • RANDOM_PORT 启动一个Tomact容器,并监听一个随机端口
    • DEFINED_PORT 启动一个Tomcat容器,并监听配置文件中定义的端口(未定义则默认监听8080)
    • NONE 不启动Tomcat容器
  4. @Before @BeforeClass 有啥区别?
    • @Before 和@BeforeClass 都是JUnit测试框架注解
    • @Before 这个注解应用于一个方法上,这个方法会在每一个测试方法执行之前被调用。对应执行一些每个测试都需要准备工作,如初始化变量 打开数据库连接
    • @BeforeClass 这个注解应用于一个静态方法上,这个方法会在测试类中的所有测试方法执行之前被调用一次,而且只会被调用一次。对于执行一些只需要在开始时执行一次的准备工作,如加载配置文件,设置环境变量等,非常有用。
  5. Springboot 和Junit版本关系?
  • SpringBoot2.2.x 之前使用的是Junit4,之后就使用的是Junit5
  1. 参考项目中的一个示例
       package com.aicloud.controller.voice;
       import cn.hutool.json.JSONUtil;
       import com.aicloud.EdasCoreServerApplication;
       import com.aicloud.bean.RestResult;
       import com.aicloud.secret.AppSecretService;
       import com.aicloud.utils.HttpHeaderUtils;
       import com.aicloud.utils.JsonUtil;
       import com.aicloud.voice.model.OutputRouteExtendsModel;
       import com.aicloud.voice.params.RouteAddBatchParams;
       import com.aicloud.voice.params.RouteAddParams;
       import com.aicloud.voice.params.RouteSearchParams;
       import org.apache.commons.compress.utils.Lists;
       import org.junit.jupiter.api.*;
       import org.slf4j.Logger;
       import org.slf4j.LoggerFactory;
       import org.springframework.beans.factory.annotation.Autowired;
       import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
       import org.springframework.boot.test.context.SpringBootTest;
       import org.springframework.context.annotation.FilterType;
       import org.springframework.http.MediaType;
       import org.springframework.mock.web.MockHttpServletResponse;
       import org.springframework.test.context.ActiveProfiles;
       import org.springframework.test.context.TestPropertySource;
       import org.springframework.test.web.servlet.MockMvc;
       import org.springframework.test.web.servlet.MvcResult;
       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.transaction.annotation.Transactional;
    
       import java.util.List;
       import java.util.concurrent.TimeUnit;
       import static org.junit.jupiter.api.Assertions.assertTrue;
       import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
    
       @SpringBootTest(classes = EdasCoreServerApplication.class)
       @AutoConfigureMockMvc(addFilters = false)
       @ActiveProfiles("test")
       @Transactional
       @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
       public class VoiceRouteInfoControllerTest {
           private static final Logger logger = LoggerFactory.getLogger(VoiceRouteInfoControllerTest.class);
    
    
    
           private static final String orgCode = "100001";
    
           private static String secret = null;
    
           @BeforeAll
           public static void before(@Autowired AppSecretService appSecretService) {
               secret = appSecretService.queryAppSecret(orgCode);
           }
    
           @Test
           @Timeout(value = 1000, unit = TimeUnit.MILLISECONDS)
           @DisplayName("录音随路-批量上传随路")
           @Order(1)
           public void batchUploadRouteInfo(@Autowired MockMvc mvc) throws Exception {
               RouteAddBatchParams params = new RouteAddBatchParams();
               RouteAddParams routeAddParams = new RouteAddParams();
               routeAddParams.setRouteUid("MTAwMDAx_5_q8OEWRGMvQudfV9LfT2g");
               routeAddParams.setSourceType(1);
               routeAddParams.setAgentId("hyf");
               routeAddParams.setRecordId("10B-24-04-10-001");
               routeAddParams.setRecordUrl("MjAyNC8wMy8wNS8xNi80OTUwNzE2N2ZlZmM0MWFlYTBiY2Y1NDNiNjU1MGQ4MQ.mp3");
               routeAddParams.setSource(1);
               routeAddParams.setAudioSource("file_server");
               routeAddParams.setCallNumber("15735655109");
               routeAddParams.setCallTime("2024/09/12 22:08:00");
               routeAddParams.setRecordForm("mp3");
               routeAddParams.setSamplingRate("8k");
               routeAddParams.setEncoding("16bit");
               routeAddParams.setSoundChannel(1);
               routeAddParams.setAgentChannel(1);
               routeAddParams.setBusinessType("中国电信");
               routeAddParams.setCallbackUrl("http://10.0.3.128:19000/api/aicc-znzj/routeinfo/api/callback/voice");
               routeAddParams.setAudioProp("{\"speed\":10.0,\"silence\":800.0,\"volume\":600.0,\"forceTalk\":500.0}");
               OutputRouteExtendsModel outputRouteExtendsModel = new OutputRouteExtendsModel();
               outputRouteExtendsModel.setCode("field214");
               outputRouteExtendsModel.setDataType("string");
               outputRouteExtendsModel.setValue("测试字符串");
               List<OutputRouteExtendsModel> extendFields = Lists.newArrayList();
               extendFields.add(outputRouteExtendsModel);
               routeAddParams.setExtendFields(extendFields);
               List<RouteAddParams> routeParamList = Lists.newArrayList();
               routeParamList.add(routeAddParams);
               params.setRouteParamList(routeParamList);
               //发起请求
               MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/edas/service/route/upload/batch");
               requestBuilder.contentType(MediaType.APPLICATION_JSON);
               requestBuilder.content(JsonUtil.encodeToString(params));
               requestBuilder.headers(HttpHeaderUtils.getHeaders(orgCode, secret));
               ResultActions resultActions = mvc.perform(requestBuilder).andExpect(status().isOk());
    
               //获取响应结果 打印
               MvcResult mvcResult = resultActions.andReturn();
               MockHttpServletResponse response = mvcResult.getResponse();
               response.setCharacterEncoding("utf8");
               logger.info("content={}", response.getContentAsString());
               RestResult restResult = JsonUtil.decode(response.getContentAsString(), RestResult.class);
               assertTrue(restResult.isSuccess());
           }
    
           @Test
           @Timeout(value = 1000, unit = TimeUnit.MILLISECONDS)
           @DisplayName("录音随路-根据录音转写编号查询录音随路信息")
           @Order(2)
           public void queryRouteByRouteUid(@Autowired MockMvc mvc) throws Exception {
               //参数
               RouteSearchParams.RouteUidParams params = new RouteSearchParams.RouteUidParams();
               params.setRouteUid("MTAwMDAx_5_q8OEWRGMvQudfV9LfT1g");
               params.setCreateTimeStart("2024/04/01 00:00:00");
               params.setCreateTimeEnd("2024/04/30 23:59:59");
    
    
               //发起请求
               MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/edas/service/route/routeUid");
               requestBuilder.contentType(MediaType.APPLICATION_JSON);
               requestBuilder.content(JsonUtil.encodeToString(params));
               requestBuilder.headers(HttpHeaderUtils.getHeaders(orgCode, secret));
               ResultActions resultActions = mvc.perform(requestBuilder).andExpect(status().isOk());
    
               //获取响应结果 打印
               MvcResult mvcResult = resultActions.andReturn();
               MockHttpServletResponse response = mvcResult.getResponse();
               response.setCharacterEncoding("utf8");
               logger.info("content={}", response.getContentAsString());
               RestResult restResult = JsonUtil.decode(response.getContentAsString(), RestResult.class);
               assertTrue(restResult.isSuccess());
           }
    
           @Test
           @Timeout(value = 1000, unit = TimeUnit.MILLISECONDS)
           @DisplayName("录音随路-分页查询录音随路信息")
           @Order(3)
           public void queryRouteInfoPage(@Autowired MockMvc mvc) throws Exception {
               //参数
               RouteSearchParams params = new RouteSearchParams();
               params.setCreateTimeStart("2024/04/01 00:00:00");
               params.setCreateTimeEnd("2024/04/30 23:59:59");
               //发起请求
               MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/edas/service/route/query");
               requestBuilder.contentType(MediaType.APPLICATION_JSON);
               requestBuilder.content(JsonUtil.encodeToString(params));
               requestBuilder.headers(HttpHeaderUtils.getHeaders(orgCode, secret));
               ResultActions resultActions = mvc.perform(requestBuilder).andExpect(status().isOk());
    
               //获取响应结果 打印
               MvcResult mvcResult = resultActions.andReturn();
               MockHttpServletResponse response = mvcResult.getResponse();
               response.setCharacterEncoding("utf8");
               logger.info("content={}", response.getContentAsString());
               RestResult restResult = JsonUtil.decode(response.getContentAsString(), RestResult.class);
               assertTrue(restResult.isSuccess());
           }
       }
    
posted @ 2024-04-10 12:02  贺艳峰  阅读(1180)  评论(0编辑  收藏  举报