优化基于@SpringBootTest 的测试案例,让你的测试飞起来

1. 问题背景

单元测试是保证代码业务质量最有效的手段。Spring Boot 为开发者提供了便利的切片和集成测试工具和注解。此处为什么不说单元测试,作者认为单元测试的前提是不启动 ApplicationContext 容器。那采用 @SpringBootTest 进行测试案例的编写,如何进行提速呢?

2. 原理分析

此处默认大家集成 Junit5 进行测试案例的编写。@SpringBootTest 默认会搜索源码路径下标注 @SpringBootConfiguration 的类。并通过该类作为启动类。因此,在默认的情况下 @SpringBootTest 会查找到应用程序的启动类,也就是标注了 @SpringBootApplication 的类。比如下边的 SpringBootTestApplication 类。

@SpringBootApplication
public class SpringBootTestApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootTestApplication.class, args);
    }
}

通过上述默认的启动方式,@SpringBootTest 执行如下启动逻辑:

  • @EnableAutoConfiguration 开启自动装配,装备类路径下所有的*AutoConfiguration 逻辑
  • @ComponentScan 开启工程源码路径下的所有 Bean 的装配

3. 优化提速

基于上述分析,要优化 @SpringBootTest 的执行速度,可以从如下两方面入手:

3.1 禁止自动装配的执行

以一个简单的 Spring Boot 集成了 Spring MVC 和 MyBatis 的项目分析,启动默认的 @SpringBootTest 案例,发现竟然总共装配了近 270 个 Bean。简直不可接受:

优化基于@SpringBootTest 的测试案例,让你的测试飞起来_第1张图片

分析 @SpringBootTest 注解的属性,可以通过 classes 指定 Spring Boot 启动时加载配置类或者 Bean 的定义类。
此处需要说明的是 classes 属性必须引入标注了 @Configuration 或者 @SpringBootConfiguration 注解的配置类,才能够阻止默认查找标注了 @SpringBootConfiguration 的配置类。如下代码所示:

// 或者标注 @SpringBootConfiguration
@Configuration
public class OuterConfig {
}

@SpringBootTest(classes = {OuterConfig.class})
class SpringBootTestApplicationTests {
    @Autowired
    private ApplicationContext applicationContext;

    @Test
    void contextLoads() {
        System.out.println(applicationContext.getBeanDefinitionCount());
        Stream.of(applicationContext.getBeanDefinitionNames()).forEach(System.out::println);
    }
}

运行结果如下,总共只导入了 9 个 Bean:
优化基于@SpringBootTest 的测试案例,让你的测试飞起来_第2张图片

如果需要对某个目录下的 Bean 进行扫描装配,只需要在 OuterConfig 上增加 @ComponentScan 并指定扫描路径即可,如:

// 或者标注 @SpringBootConfiguration
@Configuration
@ComponentScan("cn.cincout.spring.boot.springboottest.application.calculate")
public class OuterConfig {
}

如果仅仅需要测试自己编写的某个 Service 类,可进行如下编写:

@Service
@Slf4j
public class CalculateServiceImpl implements CalculateService {
    @Override
    public int add(int a, int b) {
        log.info("calculate a + b");
        return new Calculator().add(a, b);
    }
}

/**
 * 如果指定 @SpringBootTest(classes=“”) 就不会查找默认的 @SpringBootConfiguration 的类
 * 在这种情况下内部静态类也不会被默认加载,必须显式的引入
 * 但是会执行 Spring Boot 提供的 properties 文件的读取,但是不会
 * 实现 @@ConfigurationProperties 注解的逻辑
 * @sine 1.8
 */
@SpringBootTest(classes = {CalculateServiceImpl.class, CalculateServiceImplWithSpringBootTestITTest.InnerConfig.class})
class CalculateServiceImplWithSpringBootTestITTest {
    @Autowired
    ApplicationContext applicationContext;

    @Autowired
    CalculateService calculateService;

    /**
     * 不会被默认加载
     */
    @Configuration
    protected static class InnerConfig {
    }

    @Test
    void add() {
        System.out.println(applicationContext.getBeanDefinitionCount());
        int result = calculateService.add(1, 2);
        Assertions.assertEquals(3, result);
    }
}

3.2 设置 WebEnvironment.NONE

如果不是测试 WebMvc,强列建议关闭 WebEnvironment.NONE 能够少实例化几十个 Bean。

/**.
 * just init 9 bean without auto config and component scan
 * 如果 @SpringBootTest 在当前路径下可以找到 @SpringBootConfiguration 注解的类,可以使用该类作为默认配置
 * 如果提供了 @Configuration 作为测试类的内部静态配置类,@SpringBootTest 将使用该类作为默认配置,此时自动装配不再生效
 * 如果主动配置 @SpringBootTest(classes={}) ,@SpringBootTest 也不会寻找默认的注解了@SpringBootConfiguration 的类
 *
 * @TestConfiguration 只能用来添加测试Bean 或者 对现有的 Bean 进行覆盖,如果是覆盖需要添加
 * spring.main.allow-bean-definition-overriding=true 配置参数
 * @TestConfiguration 不会阻止 @SpringBootTest 寻找 @SpringBootConfiguration 注解的类
 *
 * SpringBootContextLoader 会将当前被测试的类 设置为 setMainApplicationClass,将内部静态配置类和@SpringBootTest(classes={})
 * 引入的配置类作为 addPrimarySources
 * @sine 1.8
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class SpringBootWebProjectTestWithNoneWebEnvironmentConfigTest {
    @Autowired
    ApplicationContext applicationContext;

    @Test
    void contextLoad() {
        System.out.println(applicationContext.getBeanDefinitionCount());
        Stream.of(applicationContext.getBeanDefinitionNames()).forEach(System.out::println);

    }

    /**
     * 如果使用内部静态类 @TestConfiguration,依然回去查找 @SpringBootConfiguration
     */
    @TestConfiguration
    protected static class InnerConfig {

    }
}

3.3 自定义 @ComponentScan

通过自定义指定 @ComponentScan 扫描的路径,可以有效的减少 ApplicationContext 实例化的 Bean 的数量。如 3.1 中所述。

4. 对 Spring Boot 属性配置文件的测试

关闭了自动装配, @ConfigurationProperties 标注的类的属性注入将会失败,但是 Spring Boot 已经读取了 properties 文件中的属性(系统环境变量、Java 环境变量)存储到Environment。该功能由ConfigDataEnvironmentPostProcessor实现读取(2.4.0 版本后),该版本之前由 ConfigFileApplicationListener 读取。
那么如何开启 @ConfigurationProperties 功能呢?只需要在自定义配置类上加上 @EnableConfigurationProperties,如下:

@Data
@ConfigurationProperties(prefix = "pool")
@Component
public class PoolConfigProperties {
    private int maxTotal;
    private int minIdle;

    private String threadCount;
}

/**
 * @SpringBootTest(classes="") 通过该方法配置时,基于@ConfigurationProperties
 * 的属性注入失败,需要增加配置类 标注  @EnableConfigurationProperties
 * @ComponentScan 会扫描源码和测试代码中相同路径下的 Bean
 * @sine 1.8
 */
@SpringBootTest(classes = {PoolConfigPropertiesWithSpringBootTest.InnerConfig.class, PoolConfigProperties.class})
class PoolConfigPropertiesWithSpringBootTest {
    @Autowired
    private PoolConfigProperties poolConfigProperties;

    @Autowired
    ApplicationContext applicationContext;

    @Configuration
    //@ComponentScan("cn.cincout.spring.boot.springboottest.application.properties")
    @EnableConfigurationProperties
    protected static class InnerConfig {

    }

    @Test
    void getMaxTotal() {
        System.out.println(applicationContext.getBeanDefinitionCount());
        Stream.of(applicationContext.getBeanDefinitionNames()).forEach(System.out::println);


        int maxTotal = poolConfigProperties.getMaxTotal();
        Assertions.assertEquals(10, maxTotal);
    }

}

除上述之外,可以通过 spring-test 提供的 @ContextConfiguration 实现 @ConfigurationProperties。如下:

/**
 * 可以 @TestPropertySource 和 @EnableConfigurationProperties 实现 @ConfigurationProperties(prefix = "pool")注解的功能
 * @sine 1.8
 */
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {PoolConfigPropertiesTest.InnerConfig.class, PoolConfigProperties.class})
@TestPropertySource(locations = {"classpath:application.properties"})
class PoolConfigPropertiesTest {
    @Autowired
    private PoolConfigProperties poolConfigProperties;

    @TestConfiguration
    @EnableConfigurationProperties
    protected static class InnerConfig {

    }

    @Test
    void getMaxTotal() {
        int maxTotal = poolConfigProperties.getMaxTotal();
        Assertions.assertEquals(10, maxTotal);
    }
}

除上述外,可以采用 @SpringJUnitConfig 替代 @ExtendWith(SpringExtension.class)@ContextConfiguration。如下:

@SpringJUnitConfig(classes = {CalculateServiceImpl.class})
class CalculateServiceImplWithSpringJunitConfigTest {
    @Autowired
    CalculateService calculateService;
    @Autowired
    ApplicationContext applicationContext;

    @Test
    void add() {
        System.out.println(applicationContext.getBeanDefinitionCount());
        Stream.of(applicationContext.getBeanDefinitionNames()).forEach(System.out::println);


        int result = calculateService.add(1, 2);
        Assertions.assertEquals(3, result);
    }
}

5. 装配指定的部分自动装配*AutoConfiguration

分析 Spring Boot 或 Mybatis 提供的切片测试注解可知,通过组合下列注解,可以实现指定需要自动装配的配置类进行加载装配,比如 @MybatisTest

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(MybatisTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(MybatisTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureMybatis
@AutoConfigureTestDatabase
@ImportAutoConfiguration
public @interface MybatisTest {
}

通过分析 @MybatisTest,我们想测试 @Mapper 时,可以通过如下方式实现部分自动装配(当然也可以实用 @MybatisTest 注解):

@SpringBootTest(classes = {CustomerMapperWithAutoMybatisTest.InnerMybatisConfig.class},
        webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Transactional
class CustomerMapperWithAutoMybatisTest {
    @Autowired
    private CustomerMapper customerMapper;
    @Autowired
    private ApplicationContext applicationContext;
    
    // 可以用 @Configuration 替代
    @SpringBootConfiguration
    // DataSourceAutoConfiguration 装配时需要该属性 Bean
    @EnableConfigurationProperties(value = {DataSourceProperties.class})
    // 导入需要自动装配的配置类
    @ImportAutoConfiguration(classes = {
            DataSourceAutoConfiguration.class,
            MybatisAutoConfiguration.class,
            DataSourceTransactionManagerAutoConfiguration.class
    })
    // 关闭默认的自动装配
    @OverrideAutoConfiguration(enabled = false)
    // 扫描 @Mapper
    @MapperScan("cn.cincout.spring.boot.springboottest.inf.persistence.mapper")
    protected static class InnerMybatisConfig {
    }

    @Test
    @Sql(scripts = {"classpath:/sql/insertCustomer.sql"})
    @Rollback(value = true)
    void selectById() {
        System.out.println(applicationContext.getBeanDefinitionCount());
        Stream.of(applicationContext.getBeanDefinitionNames()).forEach(System.out::println);

        CustomerEntity entity = customerMapper.selectById(2);
        Assertions.assertNotNull(entity);
    }
}

6. 总结

通过上述的案例及实例,想要优化 @SpringBootTest 执行速度,最核心的就是减少加载的 Bean。包括自动装配 *AutoConfiguration@ComponentScan
实际在编写测试的时候,我们可以从以下三类测试类型入手:

  1. 单元测试 不启动 ApplicationContext,通过 Mock 的方式解除外部依赖,速度是最快的;
  2. 切片测试 代码按分层组织的形式,针对某层进行测试,比如 DAO、DOMAIN、SERVICE、CONTROLLER 等,详细的后续会继续写文章进行介绍;
  3. 集成测试 启动 ApplicationContext,对多个组件进行集成测试,但是我们依然可以通本文介绍的方式减少 Bean 加载的数量,来对测试的执行进行提速。

你可能感兴趣的:(优化基于@SpringBootTest 的测试案例,让你的测试飞起来)