• 【单元测试】测试用例编写


    在你想要了解如何编写单元测试的时候,想必对于单元测试的概念和重要性都已经有了比较充足的了解。
    本篇不讲概念、不说废话,仅展示一些单元测试编写的技巧和方法,希望能够帮助大家实际用起来。

    计划做一个单元测试的专栏,后续补充一些关于覆盖率检查、编写效率提升、重复执行、自动执行相关的文章。
    全示例代码,无需关注、无需开会员,需要的可以持续关注。

    一、引言

    单元测试,编程中熟知的有效工具,擅长预先发现错误、全面覆盖异常场景并助力代码重构。若你的项目面临复杂业务难以测试或旧代码维护难题,不妨尝试单元测试,它将为这些问题带来有效解决方案。当单元测试覆盖率达到一定指标后,将显著提升代码质量和项目的可持续发展能力。

    二、测试框架选择

    • JUnit

      优点: 提供了基础的单元测试能力, 可以通过JUnit提供的注解、断言来组织和运行测试用例。例如:@Test、@BeforeEach、@AfterEach、@BeforeAll、@AfterAll、@ExtendWith等;
      不足: 不支持对被测对象、依赖、参数、返回值的模拟能力,不能对方法调用进行验证。
    • Mockito

      优点: 提供了对测试对象、依赖对象、方法参数、方法返回值进行模拟的能力,能够对返回值、方法调用进行验证;
      不足: 需要依赖JUnit,不能对私有方法、final方法、构造方法进行测试。
    • PowerMock

      优点: 提供对私有方法、final方法、构造方法进行测试的能力;
      不足: 需要与JUnit、Mockito进行配合使用,版本太老存在不兼容问题。

    三、基本用法

    被测试代码示例

    @Service
    public class OrderService {
    
        @Autowired
        private ProductService productService;
    
        public void setProductService(ProductService productService) {
            this.productService = productService;
        }
    
        public Order getById(Long id) {
            return new Order().setId(id)
                    .setTotalAmount(BigDecimal.valueOf(15).multiply(BigDecimal.valueOf(id)))
                    .setProduct(productService.getById(id));
        }
    
        public void delete(Long id) {
            System.out.println("Order delete called.");
            productService.delete(id);
        }
    
        private String getOrderUser(Long id) {
            return "Order User: " + id;
        }
        
        public static String staticMethod() {
            return "Order static method called.";
        }
    }
    
    @Service
    public class ProductService {
    
        public Product getById(Long id) {
            return new Product().setId(id).setName("Product: " + id);
        }
    
        public void delete(Long id) {
            System.out.println("Product delete called.");
        }
    
        public static String staticMethod() {
            return "Static method called.";
        }
        
    }
    
    @Data
    @NoArgsConstructor
    @Accessors(chain = true)
    public class Order {
    
        private Long id;
    
        private BigDecimal totalAmount;
    
        private Product product;
    
        private String user;
    
        public Order(Long id) {
            this.id = id;
            this.totalAmount = BigDecimal.valueOf(10).multiply(BigDecimal.valueOf(id));
        }
    }
    
    @Data
    @Accessors(chain = true)
    public class Product {
    
        private Long id;
    
        private String name;
    
    }
    
    // 使用Mockito扩展
    @ExtendWith(MockitoExtension.class)
    public class OrderServiceTest {
    
        // 模拟掉ProductService所有方法,仅测试OrderService
        @Mock
        private ProductService productService;
    
        @Spy
        @InjectMocks
        private OrderService orderService;
        
    }
    
    1. 构造测试对象
      a. 实例化创建
      OrderService orderService = new OrderService();
      
      b. 依赖注入
      @Autowired
      private OrderService orderService;
      
      c. Mock创建
      // Mock注解创建
      @Mock
      private OrderService orderService;
      
      // Mock方法创建
      OrderService orderService = Mockito.mock(OrderService.class);
      
      d. Spy创建
      // Spy注解创建
      @Spy
      private OrderService orderService;
      
      // Spy方法创建
      OrderService orderService = Mockito.spy(OrderService.class);
      
    2. 构造依赖对象
      a. 依赖注入
      @Autowired
      private OrderService orderService;
      
      b. 手动设置
      OrderService orderService = new OrderService();
      // new ProductService也可以替换为Mock、Spy生成的对象
      orderService.setProductService(new ProductService());
      
      c. InjectMock注入
      // 需要注入的对象,此处可以用@Spy替换@Mock,不过需注意@Spy和@Mock对象方法调用上的区别
      @Mock
      private ProductService productService;
      
      // 依赖OrderService的被测试对象
      @InjectMocks
      private OrderService orderService;
      
    3. 模拟方法调用。以下示例可通过Junit+Mockito 进行实现。
      a. 返回特定值
      // getById本应返回id=1的Product,此处模拟返回id=2的Product
      Mockito.doReturn(new Product().setId(2L)).when(productService).getById(1L);
      Assertions.assertEquals(2L, productService.getById(1L).getId(), "应该返回Stub的值为2L!");
      
      // 验证productService.getById(1L)被调用过
      Mockito.verify(productService).getById(Mockito.anyLong());
      // 验证productService.delete(1L)未被调用过
      Mockito.verify(productService, Mockito.never()).delete(1L);
      // 验证productService.getById(1L)被调用过1次
      Mockito.verify(productService, Mockito.times(1)).getById(1L);
      
      b. 返回动态值。前三次调用时依次返回Id为1、2、3的Product对象,第4次及之后只返回Id为3的Product对象。
      Mockito.doAnswer(invocation -> {
          // 获取传参
          Long productId = invocation.getArgument(0);
          // 构造一个自定义的Product对象
          return new Product().setId(productId);
      }).when(productService).getById(Mockito.anyLong());
      
      // 断言
      Assertions.assertEquals(3L, productService.getById(3L).getId(), "传3L应返回3L");
      Assertions.assertEquals(2L, productService.getById(2L).getId(), "传2L应返回2L");
      Assertions.assertEquals(1L, productService.getById(1L).getId(), "传1L应返回1L");
      // 验证方法被调用了三次
      Mockito.verify(productService, Mockito.times(3)).getById(Mockito.anyLong());
      
      c. 每次返回不一样的值。
      // 设置3个模拟返回值
      Mockito.doReturn(new Product().setId(1L), new Product().setId(2L), new Product().setId(3L)).when(productService).getById(1L);
      
      // 断言
      // 第一次调用
      Assertions.assertEquals(1L, productService.getById(1L).getId(), "第一次调用返回1L");
      // 第二次调用
      Assertions.assertEquals(2L, productService.getById(1L).getId(), "第二次调用返回2L");
      // 第三次调用
      Assertions.assertEquals(3L, productService.getById(1L).getId(), "第三次调用返回2L");
      // 第四次调用
      Assertions.assertEquals(3L, productService.getById(1L).getId(), "第四次调用返回2L");
      // 第五次调用
      Assertions.assertEquals(3L, productService.getById(1L).getId(), "第五次调用返回2L");
      
      d. 模拟抛出异常。
      // 调用getById方法时抛出异常
      Mockito.doThrow(new InvalidParamException("Invalid Param")).when(productService).getById(1L);
      
      // 断言,方法调用抛出InvalidParamException异常
      InvalidParamException exception = Assertions.assertThrows(InvalidParamException.class, () -> productService.getById(1L), "此处应抛出异常!");
      // 此处可以拿到异常,对异常信息再次校验
      Assertions.assertEquals("Invalid Param", exception.getMessage(), "异常信息应为Invalid Param");
      
      e. 模拟调用静态方法。自Mockito 3以后,静态方法的调用不再需要依赖PowerMock。
      // 创建模拟对象
      try (MockedStatic<ProductService> mockedStatic = Mockito.mockStatic(ProductService.class)) {
          // 利用Stub进行返回值模拟
          mockedStatic.when(ProductService::staticMethod).thenReturn("Mock static method called.");
          // 调用静态方法
          String staticMethodReturn = ProductService.staticMethod();
      
          // 断言
          // 应该返回模拟结果
          Assertions.assertEquals("Mock static method called.", staticMethodReturn, "应当返回模拟结果!");
          // 不应该返回真实结果
          Assertions.assertNotEquals("Static method called.", staticMethodReturn, "不应该返回实际结果!");
      }
      
      f. 调用Mock对象真实方法。
      // 模拟返回值
      Mockito.doReturn(new Product().setId(2L)).when(productService).getById(1L);
      // 断言
      // Mock生效。返回Mock值
      Assertions.assertEquals(2L, productService.getById(1L).getId(), "调用模拟方法返回2L");
      // 验证方法被调用过至少一次
      Mockito.verify(productService, Mockito.atLeastOnce()).getById(1L);
      
      // 调用实际方法
      Mockito.doCallRealMethod().when(productService).getById(1L);
      // 断言
      // 调用真实方法,返回实际值
      Assertions.assertEquals(1L, productService.getById(1L).getId(), "调用真实方法返回1L");
      // 验证方法调用次数增加一次,为2次
      Mockito.verify(productService, Mockito.times(2)).getById(1L);
      
    4. 特殊方法模拟。借助PowerMock对私有方法、静态方法进行测试。
      测试代码示例
      // 使用PowerMockRunner进行测试
      @RunWith(PowerMockRunner.class)
      // 将静态方法、私有方法的类在这里声明
      @PrepareForTest({OrderService.class})
      public class OrderServiceTest {
          
          @Mock
          private ProductService productService;
      
          @InjectMocks
          private OrderService orderService;
      
      }
      
      a. 模拟私有方法的调用
      Long id = 1L;
      // 使用PowerMock模拟orderService方法的执行
      orderService = PowerMockito.spy(orderService);
      // 将orderService的getOrderUser方法返回值改为“Mocked order User:”
      String mockedReturn = "Mocked order User: " + id;
      PowerMockito.doReturn(mockedReturn).when(orderService, "getOrderUser", Mockito.any());
      // 调用orderService的getById方法,getById调用静态方法getOrderUser
      Order order = orderService.getById(id);
      // 验证私有方法被调用过
      PowerMockito.verifyPrivate(orderService).invoke("getOrderUser", Mockito.any());
      // 验证orderService的getOrderUser方法被调用了1次
      PowerMockito.verifyPrivate(orderService, Mockito.times(1)).invoke("getOrderUser", Mockito.any());
      // 验证order的user值为Mock的返回值
      Assertions.assertEquals(mockedReturn, order.getUser());
      
      b. 真实调用私有方法
      Long id = 1L;
      // 使用PowerMock模拟orderService方法的执行
      orderService = PowerMockito.spy(orderService);
      // 调用orderService的私有方法getOrderUser
      String orderUser = Whitebox.invokeMethod(orderService, "getOrderUser", id);
      // 验证私有方法被调用过
      PowerMockito.verifyPrivate(orderService).invoke("getOrderUser", Mockito.any());
      // 验证orderService的getOrderUser方法被调用了1次
      PowerMockito.verifyPrivate(orderService, Mockito.times(1)).invoke("getOrderUser", Mockito.any());
      // 验证order的user值为“Order User:”,未被修改
      Assertions.assertEquals("Order User: " + id, orderUser);
      
      c. 模拟静态方法的调用。这里写法有些怪,verify方法后必须再跟一次静态方法调用
      PowerMockito.mockStatic(OrderService.class);
      // 将orderService的staticMethod方法返回值改为“Mocked order static method called.”
      String mockedReturn = "Mocked order static method called.";
      PowerMockito.when(OrderService.staticMethod()).thenReturn(mockedReturn);
      // 调用静态方法
      String staticResult = OrderService.staticMethod();
      // 验证静态方法被调用过。这里写法有些怪,verify方法后必须跟一次方法调用
      PowerMockito.verifyStatic(OrderService.class);
      OrderService.staticMethod();
      // 验证静态方法被调用过1次。这里写法有些怪,verify方法后必须跟一次方法调用
      PowerMockito.verifyStatic(OrderService.class, Mockito.times(1));
      OrderService.staticMethod();
      // 验证staticResult值为Mock的返回值
      Assertions.assertEquals(mockedReturn, staticResult);
      
      d. 模拟构造方法的调用
      // 将Order对象构造方法的返回值改为2L
      Order mockOrder = new Order(2L);
      PowerMockito.whenNew(Order.class).withArguments(1L).thenReturn(mockOrder);
      // 调用Order对象构造方法
      Order mockResult = new Order(1L);
      // 验证返回值为2L
      Assertions.assertEquals(mockResult.getId(), mockOrder.getId());
      
    5. 验证方法调用。确认被模拟的依赖方法调用是否符合预期(被调用或未被调用)
      a. 验证方法从未被调用过
      // 模拟方法返回值
      Mockito.doReturn(new Product().setId(2L)).when(productService).getById(1L);
      // 调用productService的getById方法,未调用orderService的getById方法
      productService.getById(1L);
      // 验证productService的getById方法被调用过
      Mockito.verify(productService).getById(Mockito.anyLong());
      // 验证orderService的getById方法未被调用过
      Mockito.verify(orderService, Mockito.never()).getById(1L);
      
      b. 验证方法被调用过(至少一次)
      // 模拟方法返回值
      Mockito.doReturn(new Product().setId(2L)).when(productService).getById(1L);
      // 触发一次调用
      productService.getById(1L);
      // 触发第二次调用
      productService.getById(1L);
      
      // 验证方法至少被调用一次
      Mockito.verify(productService, Mockito.atLeastOnce()).getById(Mockito.anyLong());
      
      c. 验证方法被调用指定次数
      // 模拟方法返回值
      Mockito.doReturn(new Product().setId(2L)).when(productService).getById(1L);
      
      // 触发一次调用
      productService.getById(1L);
      // 验证方法至少被调用一次
      Mockito.verify(productService, Mockito.atLeastOnce()).getById(Mockito.anyLong());
      // 验证方法被调用一次
      Mockito.verify(productService, Mockito.atLeastOnce()).getById(Mockito.anyLong());
      
      // 触发第二次调用
      productService.getById(1L);
      // 验证方法被调用两次
      Mockito.verify(productService, Mockito.times(2)).getById(Mockito.anyLong());
      

    四、完整实例

    1. Controller代码测试。
      a. 被测试代码
      @RestController
      @RequestMapping("/order")
      @RequiredArgsConstructor(onConstructor_ = {@Lazy})
      public class OrderController {
      
          private final OrderService orderService;
      
          @GetMapping("/{id}")
          public Order info(@PathVariable Long id) {
              return orderService.getById(id);
          }
      
      }
      
      b. 单元测试代码
      @WebMvcTest(OrderController.class)
      public class OrderControllerTest {
      
          @Autowired
          private MockMvc mockMvc;
      
          @MockBean
          private OrderService orderService;
      
          @Test
          @SneakyThrows
          public void testInfo() {
              // 期望返回的对象
              Order order = new Order(1L);
              // 对OrderService的返回值进行模拟
              Mockito.doReturn(order).when(orderService).getById(order.getId());
      
              // 构造一个请求
              MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/order/" + order.getId()).accept(MediaType.APPLICATION_JSON);
              // 执行请求
              ResultActions resultActions = mockMvc.perform(requestBuilder);
              // 验证Http响应状态码
              resultActions.andExpect(status().isOk());
              // 使用JsonPath验证查询到的Id为1
              resultActions.andExpect(MockMvcResultMatchers.jsonPath("$.id").value(order.getId()));
              // 获取响应对象
              MockHttpServletResponse response = resultActions.andReturn().getResponse();
              // 解析响应内容,进行复杂验证
              Order respObj = JSON.parseObject(response.getContentAsString(), Order.class);
              Assertions.assertEquals(order.getId(), respObj.getId());
          }
      
      }
      

    注意:@MockBean 不能使用Mokicto 中的@Mock@Spy进行替换;

    1. Service代码测试。Service层代码业务逻辑复杂,需要进行全覆盖。
      a. 被测试代码
      @Service
      @RequiredArgsConstructor(onConstructor_ = {@Lazy})
      public class UserService {
      
          private final UserMapper userMapper;
      
          public Integer save(UserCreateRequest createRequest) {
              // 用户密码不能等于123456
              if ("123456".equals(createRequest.getPassword())) {
                  throw new InvalidParamException("用户密码太简单了!");
              }
      
              // 检查用户是否存在
              User user = userMapper.selectByUsername(createRequest.getUsername());
              if (user == null) {
                  // 用户不存在则进行新增
                  return this.create(createRequest);
              }
      
              // 用户存在,则按照用户名进行更新
              // 用户密码不能包含生日
              this.update(user, createRequest);
      
              return user.getId();
          }
      
          /**
           * 新增用户
           *
           * @param createRequest 用户创建请求
           * @return 用户Id
           */
          public Integer create(UserCreateRequest createRequest) {
              // 用户不存在则进行新增
              User createValue = new User();
              createValue.setUsername(createRequest.getUsername());
              // 从老系统查询数据
              User oldUser = oldUserFeign.getByUsername(createRequest.getUsername());
              if (oldUser == null) {
                  // 如果在老系统不存在,使用默认值填充
                  createValue.setSex("未知");
              } else {
                  // 如果在老系统存在,使用老系统用户信息进行填充
                  createValue.setBirthday(oldUser.getBirthday());
                  createValue.setSex(oldUser.getSex());
              }
              
              // 保存到数据库
              return userMapper.create(createValue);
          }
      
          /**
           * 更新用户
           *
           * @param user          用户
           * @param createRequest 更新参数
           */
          public void update(User user, UserCreateRequest createRequest) {
              String formatUserBirthday = user.getBirthday().replace("-", "");
              if (createRequest.getPassword().contains(formatUserBirthday)) {
                  throw new InvalidParamException("用户密码不能包含生日!");
              }
      
              User updateUser = new User();
              updateUser.setUsername(createRequest.getUsername());
              updateUser.setPassword(createRequest.getPassword());
              // 更新到数据库
              userMapper.update(updateUser);
          }
      
      }
      
      
      b. 单元测试代码
      @ExtendWith(MockitoExtension.class)
      public class UserServiceTest {
      
          @Mock
          private UserMapper userMapper;
      
          @Mock
          private OldUserFeign oldUserFeign;
      
          @Spy
          @InjectMocks
          private UserService userService;
      
          /**
           * 测试使用简单密码保存用户信息
           * 1、验证使用简单密码创建用户时,应当抛出异常
           * 2、验证非简单密码创建用户,应当能够正确保存
           */
          @Test
          public void testSaveSimplePasswd() {
              // 验证使用简单密码创建用户时,应当抛出异常
              UserCreateRequest simplePasswdRequest = new UserCreateRequest("user-01", "123456");
              // 执行被测试方法,并验证异常类型是否匹配
              InvalidParamException assertThrows = Assertions.assertThrows(
                      InvalidParamException.class, () -> userService.save(simplePasswdRequest), "简单密码应当抛出异常"
              );
              // 验证异常信息是否匹配
              Assertions.assertEquals("用户密码太简单了!", assertThrows.getMessage(), "简单密码异常信息不匹配!");
              // 抛出异常后,或许代码不应该继续执行
              Mockito.verify(userMapper, Mockito.never()).selectByUsername(simplePasswdRequest.getUsername());
      
              // 验证非简单密码创建用户,应当不会抛出异常
              UserCreateRequest normalPasswdRequest = new UserCreateRequest("user-01", "12345678");
              // 执行被测试方法,且不会抛出异常
              userService.save(normalPasswdRequest);
              // 未抛出异常,后续代码应当执行一次
              Mockito.verify(userMapper, Mockito.times(1)).selectByUsername(normalPasswdRequest.getUsername());
          }
      
          /**
           * 测试使用不存在的用户名保存用户信息,应当执行新增操作,且更新操作不会执行
           */
          @Test
          public void testSaveNotExistUsername() {
              // 用户名不存在的用户请求
              UserCreateRequest notExistUsernameRequest = new UserCreateRequest("user-01", "12345678");
              // 模拟UserMapper.selectByUsername返回值
              Mockito.doReturn(null).when(userMapper).selectByUsername(notExistUsernameRequest.getUsername());
              // 模拟UserService.create方法返回值
              Mockito.doReturn(1).when(userService).create(notExistUsernameRequest);
              // 执行被测试方法
              Integer createdUserId = userService.save(notExistUsernameRequest);
      
              // 未抛出异常,UserMapper.selectByUsername应当执行一次
              Mockito.verify(userMapper, Mockito.times(1)).selectByUsername(notExistUsernameRequest.getUsername());
              // 未抛出异常,UserService.create应当执行一次
              Mockito.verify(userService, Mockito.times(1)).create(notExistUsernameRequest);
              // 用户不存在,UserService.update方法不应该被执行
              Mockito.verify(userService, Mockito.never()).update(Mockito.any(), Mockito.any());
              // UserService.create返回1,UserService.save返回值也应当为1
              Assertions.assertEquals(1, createdUserId, "新增用户Id应该为1!");
          }
      
          /**
           * 验证使用已存在的用户名保存用户信息,应当执行更新操作,且新增操作不会被执行
           */
          @Test
          public void testSaveExistUsername() {
              User user = new User().setId(1);
              // 用户名存在的用户请求
              UserCreateRequest existUsernameRequest = new UserCreateRequest("user-01", "12345678");
              // 模拟UserMapper.selectByUsername返回值
              Mockito.doReturn(user).when(userMapper).selectByUsername(existUsernameRequest.getUsername());
              // 模拟UserService.update调用,但是没有返回值
              Mockito.doNothing().when(userService).update(Mockito.any(), Mockito.any());
              // 执行被测试方法
              Integer updateUserId = userService.save(existUsernameRequest);
              // 未抛出异常,UserMapper.selectByUsername应当执行一次
              Mockito.verify(userMapper, Mockito.times(1)).selectByUsername(existUsernameRequest.getUsername());
              // 用户存在,UserService.create方法不应该被执行
              Mockito.verify(userService, Mockito.never()).create(Mockito.any());
              // 未抛出异常,UserService.update应当执行一次
              Mockito.verify(userService, Mockito.times(1)).update(Mockito.any(), Mockito.any());
          }
      
      }
      
      
  • 相关阅读:
    【C++】算法STL库
    Springboot礼品商城系统设计与实现q92av计算机毕业设计-课程设计-期末作业-毕设程序代做
    01 【介绍 使用步骤 引入方式 基础配置】
    备战23秋招,c/c++Linux后端开发岗(简历/技术面)分享
    VM虚拟机下载与安装
    优思学院|六西格玛品质管理概念,实现卓越品质的艺术
    秒懂生成式AI—大语言模型是如何生成内容的?
    计算机底层原理
    Java 性能优化实战案例分析:并行计算让代码“飞”起来
    折半插入排序
  • 原文地址:https://blog.csdn.net/for_happy123/article/details/139704163