在SpringBoot中如何编写高效运行的单元测试

发表于:2023-4-26 09:38

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:bug绝缘体    来源:知乎

  在我五年的开发生涯中,接触了大量的Spring项目,这么多的项目里,我发现单元测试都极度匮乏,或许大家都未意识到单元测试的作用,亦或是懒得编写或是不会写。但是单元测试是必要的,可以这么说,程序员有责任编写功能代码,同时也就有责任为自己的代码编写单元测试,最终受益的也是程序员自己。
  本篇文章通过深入研究Spring的测试框架,来探讨如何编写高效运行的单元测试。先来看功能代码,这是一个创建用户的接口(省略无关代码):
  @Service
  public class UsersServiceImpl implements UsersService {
      // ......
      @Override
      @Transactional(rollbackFor = Exception.class)
      @AopLock(spEL = "#reqVo.createOrEditUsersForm.username")
      public Users createUsers(CreateUsersReqVo reqVo, UserDetails userDetails) {
          if (findUsersByUsername(reqVo.getCreateOrEditUsersForm().getUsername()) != null) {
              throw new BizException(BizExceptionEnum.USER_EXISTS);
          }
          UsersExample usersExample = new UsersExample();
          usersExample.createCriteria().andEmailEqualTo(reqVo.getCreateOrEditUsersForm().getEmail());
          if (!usersMapper.selectByExample(usersExample).isEmpty()) {
              throw new BizException(BizExceptionEnum.EMAIL_EXISTS);
          }
          Users user = new Users();
          BeanUtils.copyProperties(reqVo.getCreateOrEditUsersForm(), user);
          user.setPassword(passwordEncoder.encode(user.getPassword()));
          if (userDetails != null) {
              user.setCreateUsername(userDetails.getUsername());
              user.setLastOpUsername(userDetails.getUsername());
          } else {
              user.setCreateUsername("");
              user.setLastOpUsername("");
          }
          usersMapper.insertSelective(user);
          Authorities authorities = new Authorities();
          authorities.setUsername(reqVo.getCreateOrEditUsersForm().getUsername());
          authorities.setAuthority(AppConsts.ROLE_ORDINARY);
          authoritiesMapper.insert(authorities);
          user = usersMapper.selectByPrimaryKey(reqVo.getCreateOrEditUsersForm().getUsername());
          return user;
      }
  }
  通过研究Spring的测试框架,第一版针对Service的单元测试是这样写的:
  // 添加事务注解,则默认情况下测试方法执行完后事务会被回滚,结合@commit注解使用可让事务被提交
  @Transactional
  @RunWith(SpringRunner.class)
  @SpringBootTest(
          classes = CoreApplication.class
  )
  public class UsersServiceNormalTests {
      @Autowired
      private UsersService usersService;
      @Test
      // 添加此注解则事务不会回滚
      // @Commit
      // 指定运行测试方法前要先执行sql脚本
      @Sql("classpath:sql-script/users.sql")
      public void createUsersTest() {
          CreateUsersReqVo reqVo = new CreateUsersReqVo();
          CreateUsersForm form = new CreateUsersForm();
          form.setUsername("jufeng98");
          form.setPassword("admin");
          form.setEmail("jufeng98@qq.com");
          form.setGender("M");
          reqVo.setCreateOrEditUsersForm(form);
          Users users = usersService.createUsers(reqVo, mockUserDetails());
          Assert.assertEquals(users.getUsername(), "jufeng98");
      }
  }
  注意 @Transactional 和 @Sql 注解的使用,这样就能让测试方法可以重复执行而不污染数据库
  单元测试这样写目前没什么问题,但是随着业务的发展,我们的应用会变得越来越庞大,会引入各种各样的框架,如Apollo、dubbo、mongodb和redis等等,导致了我们应用启动越来越慢。而单元测试这里使用@SpringBootTest注解时,该注解相当于完整启动了整个应用,这会让执行测试类的耗时随着应用的变大而不断变长,在我接触的实际项目中有些项目启动就要耗时一分多钟,这意味着执行测试类也要耗时一分多钟,这就变得无法接受,所以此种写法不推荐。
  通过继续研究Spring的测试框架,我的想法是,我仅仅需要测试UsersService类,所以不应该让Spring组装其他无关的bean,我只需要将UsersService类所依赖的bean组装起来就行了,所以,先编写一个mybatis的测试配置类:
  @TestConfiguration 
  @MapperScan(basePackages = "org.javamaster.b2c.core.mapper")
  @Profile(PROFILE_UNIT_TEST)
  public class MybatisTestConfig {
      @Bean
      public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
          SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
          // ......
          return sqlSessionFactoryBean.getObject();
      }
      @Bean
      public PlatformTransactionManager transactionManager(DataSource dataSource) {
          return new DataSourceTransactionManager(dataSource);
      }
      @Bean
      public JdbcTemplate jdbcTemplate(DataSource dataSource) {
          return new JdbcTemplate(dataSource);
      }
  }
  接着还有其他的相关bean依赖以此类推来编写,最终得到的写法:
  @Transactional
  @ContextConfiguration(classes = {
          DatasourceTestConfig.class,
          MybatisTestConfig.class,
          RedissonTestConfig.class,
          WebTestConfig.class,
          UsersServiceImpl.class
  })
  @ActiveProfiles(PROFILE_UNIT_TEST)
  public class UsersServiceBestTests extends CommonTestCode {
      @Autowired
      private UsersService usersService;
      @Test
      // @Commit
      @Sql("classpath:sql-script/users.sql")
      public void createUsersTest() {
          CreateUsersReqVo reqVo = new CreateUsersReqVo();
          CreateUsersForm form = new CreateUsersForm();
          form.setUsername("jufeng98");
          form.setPassword("admin");
          form.setEmail("jufeng98@qq.com");
          form.setGender("M");
          reqVo.setCreateOrEditUsersForm(form);
          Users users = usersService.createUsers(reqVo, mockUserDetails());
          Assert.assertEquals(users.getUsername(), "jufeng98");
      }
  }
  此时执行测试类就非常快了,因为我们只组装了必要的bean,只要Service依赖的东西不变,那么执行时间就基本不会有太多变化,避免了第一种写法的问题。
  这种方式唯一的缺点就是需要清楚知道依赖了哪些bean,并将他们组装起来,虽然麻烦了点,但这是值得的,避免了组装无关的bean,让测试类能快速启动执行。
  最后这里是针对Service层(接口)的测试,对于Controller层的测试是缺失的,所以为了能快速测试Controller,我又研究了针对Controller的测试类,先来看看Controller的功能代码:
  @Validated
  @RestController
  @RequestMapping("/admin/users")
  public class UsersController {
      @Autowired
      private UsersService usersService;  
      /**
       * 创建用户
       */
      @PostMapping("/createUsers")
      public Result<Users> createUsers(@Validated @RequestBody CreateUsersReqVo reqVo,
                                       @AuthenticationPrincipal UserDetails userDetails) {
          return new Result<>(usersService.createUsers(reqVo, userDetails));
      }
  }
  第一种针对Controller的测试类写法,这种写法也是用了@SpringBootTest注解,所以也有执行耗时随着应用的变大而不断变长的问题(此种写法不推荐):
  // 添加事务注解,则默认情况下测试方法执行完后事务会被回滚,结合@commit注解使用可让事务被提交
  @Transactional
  @RunWith(SpringRunner.class)
  @SpringBootTest(
          classes = CoreApplication.class
  )
  public class UsersControllerNormalTests {
      @Autowired
      private WebApplicationContext context;
      private MockMvc mockMvc;
      @Autowired
      private ObjectMapper objectMapper;
      @Before
      public void setup() {
          mockMvc = MockMvcBuilders
                  .webAppContextSetup(context)
                  .build();
      }
      @Test
      @SneakyThrows
      // SpringSecurity的测试注解,用于mock当前登录的用户信息
      @WithMockUser(
              username = "admin",
              password = "admin",
              authorities = "ROLE_ADMIN"
      )
      // 添加此注解则事务不会回滚
      // @Commit
      // 指定运行测试方法前要先执行sql脚本
      @Sql("classpath:sql-script/users.sql")
      public void createUsersTest() {
          ObjectNode reqVo = objectMapper.createObjectNode();
          ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
          createOrEditUsersForm.put("username", "jufeng98");
          createOrEditUsersForm.put("password", "admin");
          createOrEditUsersForm.put("email", "jufeng98@qq.com");
          createOrEditUsersForm.put("gender", "M");
          // 发起请求并对结果进行断言
          mockMvc
                  .perform(
                          post("/admin/users/createUsers")
                                  .contentType(MediaType.APPLICATION_JSON_UTF8)
                                  .content(objectMapper.writeValueAsString(reqVo))
                                  .accept(MediaType.APPLICATION_JSON_UTF8)
                  )
                  .andDo(print())
                  .andExpect(status().isOk())
                  .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                  .andExpect(jsonPath("$.data.username").value("jufeng98"));
      }
  }
  第二种针对Controller的测试类写法,也是通过自己组装Spring应用的上下文:
  @Transactional
  @RunWith(SpringRunner.class)
  @ContextConfiguration(classes = {
          DatasourceTestConfig.class,
          MybatisTestConfig.class,
          RedissonTestConfig.class,
          WebTestConfig.class,
          UsersServiceImpl.class,
          SecurityTestConfig.class,
          UsersController.class
  })
  // 下面这三个注解用于配置SpringMVC的测试上下文
  @AutoConfigureMockMvc
  @AutoConfigureWebMvc
  @WebAppConfiguration
  @ActiveProfiles(PROFILE_UNIT_TEST)
  public class UsersControllerBestTests extends CommonTestCode {
      @Autowired
      private MockMvc mockMvc;
      @Autowired
      private ObjectMapper objectMapper;
      @Test
      @SneakyThrows
      @WithMockUser(
              username = "admin",
              password = "admin",
              authorities = "ROLE_ADMIN"
      )
      // @Commit
      @Sql("classpath:sql-script/users.sql")
      public void createUsersTest() {
          ObjectNode reqVo = objectMapper.createObjectNode();
          ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
          createOrEditUsersForm.put("username", "jufeng98");
          createOrEditUsersForm.put("password", "admin");
          createOrEditUsersForm.put("email", "jufeng98@qq.com");
          createOrEditUsersForm.put("gender", "M");
          mockMvc
                  .perform(
                          post("/admin/users/createUsers")
                                  .contentType(MediaType.APPLICATION_JSON_UTF8)
                                  .content(objectMapper.writeValueAsString(reqVo))
                                  .accept(MediaType.APPLICATION_JSON_UTF8)
                  )
                  .andDo(print())
                  .andExpect(status().isOk())
                  .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                  .andExpect(jsonPath("$.data.username").value("jufeng98"));
      }
  }
  所以,掌握此类写法就能让单元测试高效运行,于此同时,我们也可利用单元测试来快速调试代码,这也大大提高了我们的开发效率,可谓一举两得。
  另外,Spring也提供了两个非常有用的测试注解:@MockBean、@SpyBean,还有一个辅助类:MockRestServiceServer。下面依次介绍其用法,首先是@MockBean,此注解会代理bean的所有方法,对于未mock的方法调用均是返回null:
  @RunWith(SpringRunner.class)
  @ContextConfiguration(classes = {
          WebTestConfig.class,
          DatasourceTestConfig.class,
          SecurityTestConfig.class,
          UsersController.class
  })
  @AutoConfigureMockMvc
  @AutoConfigureWebMvc
  @WebAppConfiguration
  @ActiveProfiles(PROFILE_UNIT_TEST)
  public class MockBeanTests extends CommonTestCode {
      @Autowired
      private MockMvc mockMvc;
      @Autowired
      private ObjectMapper objectMapper;
      @MockBean
      private UsersService usersService;
      @Test
      @SneakyThrows
      @WithMockUser(
              username = "admin",
              password = "admin",
              authorities = "ROLE_ADMIN"
      )
      public void createUsersTest() {
          Users users = new Users();
          users.setUsername("jufeng98");
          // @MockBean注解会代理bean的所有方法,对于未mock的方法调用均是返回null,这里的意思是针对调用createUsers方法
          // 的任意入参,均返回指定的结果
          given(usersService.createUsers(any(), any())).willReturn(users);
          ObjectNode reqVo = objectMapper.createObjectNode();
          ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
          createOrEditUsersForm.put("username", "jufeng98");
          createOrEditUsersForm.put("password", "admin");
          createOrEditUsersForm.put("email", "jufeng98@qq.com");
          createOrEditUsersForm.put("gender", "M");
          mockMvc
                  .perform(
                          post("/admin/users/createUsers")
                                  .contentType(MediaType.APPLICATION_JSON_UTF8)
                                  .content(objectMapper.writeValueAsString(reqVo))
                                  .accept(MediaType.APPLICATION_JSON_UTF8)
                  )
                  .andDo(print())
                  .andExpect(status().isOk())
                  .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                  .andExpect(jsonPath("$.data.username").value("jufeng98"));
      }
  }
  @SpyBean 可达到部分mock的效果,未被mock的方法会被真实调用:
  @RunWith(SpringRunner.class)
  @ContextConfiguration(classes = { 
          MybatisTestConfig.class,
          WebTestConfig.class,
          DatasourceTestConfig.class,
          SecurityTestConfig.class,
          UsersController.class,
          UsersServiceImpl.class
  })
  @AutoConfigureMockMvc
  @AutoConfigureWebMvc
  @WebAppConfiguration
  @ActiveProfiles(PROFILE_UNIT_TEST)
  public class SpyBeanTests extends CommonTestCode {
      @Autowired
      private MockMvc mockMvc;
      @Autowired
      private ObjectMapper objectMapper;
      @SpyBean
      private UsersService usersService;
      @Test
      @SneakyThrows
      @WithMockUser(
              username = "admin",
              password = "admin",
              authorities = "ROLE_ADMIN"
      )
      public void createUsersTest() {
          Users users = new Users();
          users.setUsername("jufeng98");
          // @SpyBean可达到部分mock的效果,仅当 doReturn("").when(service).doSomething() 时,doSomething方法才被mock,
          // 其他的方法仍被真实调用。
          // 未发生实际调用
          doReturn(users).when(usersService).createUsers(any(), any());
          ObjectNode reqVo = objectMapper.createObjectNode();
          ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
          createOrEditUsersForm.put("username", "jufeng98");
          createOrEditUsersForm.put("password", "admin");
          createOrEditUsersForm.put("email", "jufeng98@qq.com");
          createOrEditUsersForm.put("gender", "M");
          mockMvc
                  .perform(
                          post("/admin/users/createUsers")
                                  .contentType(MediaType.APPLICATION_JSON_UTF8)
                                  .content(objectMapper.writeValueAsString(reqVo))
                                  .accept(MediaType.APPLICATION_JSON_UTF8)
                  )
                  .andDo(print())
                  .andExpect(status().isOk())
                  .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                  .andExpect(jsonPath("$.data.username").value("jufeng98"));
      }
  }
  最后是MockRestServiceServer类,用于mock使用RestTemplate调用http接口的返回,假设我们有个接口是这样的,使用了RestTemplate调用http接口获取信息:
  @Validated
  @RestController
  @RequestMapping("/admin/test")
  public class TestController {
      @Autowired
      private TestService testService;
      @PostMapping("/getOrderPayType")
      public Result<String> getOrderPayType(@RequestBody JsonNode jsonNode) {
          return new Result<>(testService.getOrderPayType(jsonNode.get("orderCode").asText()));
      }
  }
  @Service
  public class TestServiceImpl implements TestService {
      @Autowired
      private RestTemplate restTemplate;
      @Override
      public String getOrderPayType(String orderCode) {
          JsonNode jsonNode = restTemplate.getForObject("http://b2c-cloud-order-service/getOrderPayType?orderCode={1}", JsonNode.class, orderCode);
          return Objects.requireNonNull(jsonNode).get("payType").asText();
      }
  }
  那么单元测试就可以这样写:
  @RunWith(SpringRunner.class)
  @ContextConfiguration(classes = {
          DatasourceTestConfig.class,
          SecurityTestConfig.class,
          WebTestConfig.class,
          TestController.class,
          TestServiceImpl.class
  })
  @AutoConfigureMockMvc
  @AutoConfigureWebMvc
  @WebAppConfiguration
  @ActiveProfiles(PROFILE_UNIT_TEST)
  public class MockRestServiceServerTests extends CommonTestCode {
      @Autowired
      protected MockMvc mockMvc;
      @Autowired
      private RestTemplate restTemplate;
      @Test
      @WithMockUser(
              username = "admin",
              password = "admin",
              authorities = "ROLE_ADMIN"
      )
      @SneakyThrows
      public void test() {
          MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
          // mock http接口的返回
          server
                  .expect(requestTo("http://b2c-cloud-order-service/getOrderPayType?orderCode=C93847639357"))
                  .andRespond(withSuccess("{\"orderCode\":\"C93847639357\",\"payType\":\"alipay\"}", MediaType.APPLICATION_JSON_UTF8));
          mockMvc
                  .perform(
                          post("/admin/test/getOrderPayType")
                                  .contentType(MediaType.APPLICATION_JSON_UTF8)
                                  .content("{\"orderCode\":\"C93847639357\"}")
                                  .accept(MediaType.APPLICATION_JSON_UTF8)
                  )
                  .andDo(print())
                  .andExpect(status().isOk())
                  .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                  .andExpect(jsonPath("$.data").value("alipay"));
      }
  }
  最后SpringBoot还提供了大量的各类辅助测试的注解例如@JdbcTest、@DataRedisTest、@DataJpaTest等等,大家有兴趣可以去研究。
  前面说到,使用@ContextConfiguration来自己组装应用上下文时,如果待测试的Controller依赖的对象很多的话,那么这里就要把依赖的类一层层的找出并逐一列到@ContextConfiguration的classes属性里,显得非常麻烦,为了解决这个问题,可使用@BootstrapWith注解指定自定义的WebTestContextBootstrapper接口的实现类ScanDependenciesContextBootstrapper(注意加粗的两行),在ScanDependenciesContextBootstrapper里通过扫描类路径将所有依赖自动找出,这样,就解决了需要自己找出所有依赖并一一列出的问题,大大简化测试类的编写:
  @Transactional
  @ScanTestedDependencies(UsersController.class)
  @BootstrapWith(ScanDependenciesContextBootstrapper.class)
  @ContextConfiguration(classes = {
          MybatisTestConfig.class,
          RedisTestConfig.class,
          SecurityTestConfig.class,
  })
  public class UsersControllerSuperBestTests extends CommonSuperTestCode {
      // ......
  }
  /**
   * 扫描指定类的所有依赖和指定接口的所有子类的依赖.通过查看类字段是否带有Autowired,
   * 将其class加入到ContextConfiguration的classes里.
   * 对于不带有Autowired的特殊依赖字段,使用additionalInterfaces指定.
   * <br/>
   * 注意:只会扫描当前模块(即target目录下的class),不会去扫描jar包(为了节省时间),
   * 若依赖位于其他jar包,则仍需在ContextConfiguration注解中显式指明
   *
   * @author yudong
   * @date 2021/5/15
   */
  @Target(ElementType.TYPE)
  @Retention(RetentionPolicy.RUNTIME)
  @Documented
  @Inherited
  public @interface ScanTestedDependencies {
      Class<?> value();
      Class<?>[] additionalInterfaces() default {};
  }
  public class ScanDependenciesContextBootstrapper extends WebTestContextBootstrapper {
      private final Set<Class<?>> alreadyHandle = new HashSet<>();
      private Vector<?> allTargetClasses;
      // 找出待测试Controller类的所有依赖class,并加入到@ContextConfiguration的calsses里
      @Override
      @SuppressWarnings("all")
      protected MergedContextConfiguration processMergedContextConfiguration(MergedContextConfiguration mergedConfig) {
          MergedContextConfiguration mergedContextConfiguration = super.processMergedContextConfiguration(mergedConfig);
          Class<?> testClass = mergedConfig.getTestClass();
          ScanTestedDependencies scanTestedDependencies = testClass.getAnnotation(ScanTestedDependencies.class);
          if (scanTestedDependencies == null) {
              return mergedContextConfiguration;
          }
          initTargetAllClasses(testClass.getClassLoader());
          Class<?> targetTestedClass = scanTestedDependencies.value();
          if (targetTestedClass.isInterface()) {
              throw new IllegalArgumentException("待测试的类不能是接口:" + targetTestedClass.getSimpleName());
          }
          List<Class<?>> list = getDependencyClasses(targetTestedClass);
          Class<?>[] interfaces = scanTestedDependencies.additionalInterfaces();
          for (Class<?> additionalInterface : interfaces) {
              List<Class<?>> implClasses = getInterfaceImplClasses(additionalInterface);
              list.addAll(implClasses);
              for (Class<?> clazz : implClasses) {
                  list.addAll(getDependencyClasses(clazz));
              }
          }
          list.add(targetTestedClass);
          list = list.stream()
                  .filter(clz -> !clz.getName().startsWith("org.springframework") && !Modifier.isAbstract(clz.getModifiers()))
                  .collect(Collectors.toList());
          Class<?>[] classes = mergedConfig.getClasses();
          Class<?>[] targetClasses = list.toArray(new Class<?>[]{});
          Class<?>[] allClasses = new Class[classes.length + targetClasses.length];
          System.arraycopy(classes, 0, allClasses, 0, classes.length);
          System.arraycopy(targetClasses, 0, allClasses, classes.length, targetClasses.length);
          ReflectTestUtils.reflectSet(mergedContextConfiguration, "classes", allClasses);
          return mergedContextConfiguration;
      }
      // ......
  }
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号