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

在我五年的开发生涯中,接触了大量的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);