前言

众所周知,Spring 框架的显著缺点就是启动慢,启动时内存消耗高,所以我们进行单测时应该尽量减少上下文中测试无关 Bean 的加载,同时,大型分布式系统中设计大量的 RPC 和中间件交互,这些服务不适合在本机单测时也混入其中,同时在 Spring 服务逻辑功能验证单测中,如果涉及到和数据库的交互,也应该避免和真实数据库数据交互,同时尽量避免脏数据的残留,基于这些前提,笔者认为对 Spring 服务的单测起码要做到以下要求。

  1. 最小上下文,测试无关 Bean 不允许实例化
  2. 对中间件,RPC,数据库等中间层进行 Mock

在单测启动前我们也先聊一下中间件还有数据库相关的 Mock 细节。

[重要] 功能逻辑单测时,所有直接和中间件交互的实例在单测中是不需要加载的,大多时候我们都会封装一个 wrapper 避免业务层和原生实例的直接交互,比如很少有人在业务 Service 层里面直接注入 KafkaTemplate*(如果你真的这么做我比较建议你把代码优化一下)*而是封装一个 MessageQueueWrapper 来封装可能是 Kafka,RocketMQ 或者 RabbitMQ 的客户端,同时可能还包括一些路由规则和其他的通用操作,比如序列化数据和日志,所以我们只需要 Mock 包装器就可以。

[重要] 数据库方面,用 MyBatis Generator 来生成的 Mapper,然后再封装一个 dao 层的 service 来做一些参数类型的校验以及特定功能的实现,是一种比较普遍的做法。虽然理论上也可以直接 Mock 包装类,但是由于数据库本身就是业务的核心,牵扯到的 Bean 太多,同时大多时候数据库的单测会涉及到 mapper.xml 文件的修改,涉及到对数据库的正确读写,还有脏数据持久化的问题,所以建议采用 Mock 数据库的方案,这里面可以分为两种方案,一个是将数据库连接路由到本地测试库,另一个就是 H2 内嵌数据库。

[特殊] 就是一部分中间件客户端SDK采用了静态加载和静态方法和中间件交互,比如配置中心这种全局都可能需要用上的中间件。这种中间件可以自己去看有没有支持自定义的单测方案,如果没有的话,只能单测的时候先让他自己加载(大概率因为连不上内网失败),然后采用 Mockito 或 PowerMock 这些支持 Mock 静态方法的框架来实现 Mock。

单测经验(踩坑)总结

下面这些都是我自己在写单测的时候的各种踩坑,主要的坑都来自 Dubbo 😡😡😡😡

@SpringBootTest 限制上下文

这个是 Spring 测试的核心框架,经常用在 SpringBoot/Spring 集成测试的基类上,提供测试的Spring上下文环境,功能是加载整个 Spring Boot 应用程序上下文,包括所有的配置、Bean、组件等。

@SpringBootTest 其实是整合了 @RunWith(SpringRunner.class) 的,用的时候不用重复写上。

如果想要限制 SpringBoot 的上下文,最好的方案是使用注解里面的 classes 属性,如果有 classes 的时候,Spring 只会对这几个类进行实例化和依赖注入。

不过这里最重要的是,Spring 不会在这个时候触发 AutoConfiguration,这样很多侵入性很大的 starter 就不会影响到单测的启动(说的就是你 Dubbo),至于原理,暂时还没看源码,不过印象里 AutoConfiguration 是在 @SpringBootApplication 中触发的,如果不加载启动类,就不会触发一些烦人的 starter

@SpringBootTest(classes = {NormalBean.class})
public class MockTest {

    @Resource
    private NormalBean normalBean;

    @Test
    public void test1(){
        normalBean.m1();
    }
}

换句话来说,哪怕你的 NormalBean 里面有 @DubboReference 的依赖,启动类上还有 @EnableDubbo 这种限制上下文的引入依然不会触发 Dubbo 框架的初始化,十分方便。

这也就是为什么建议 RPC 加上包装类防腐层的原因之一,Mock 包装 Bean 是所有 RPC 框架单测 Mock 失效时的最兜底策略

提到 Dubbo 就不得吐槽这个传奇的拒绝测试框架了,如果你现在去 Github 的 Issue 里面搜索 Mock,你会发现这是一个两年还没解决完的问题,@MockBean 无法对 @DubboReference 进行 Mock,即使有 Mock 也无法注入,十分让人火大。

刚想去看看 issue, 果然不止我有这个问题 https://github.com/apache/dubbo/issues/9116

同时,Dubbo 启动时,即使你在配置里面写了

  registry:
    check: false
#    address: zookeeper://localhost:2181
#    group: mock
    register: false
  consumer:
    check: false

启动的时候 Dubbo 框架依然会死心不改地去尝试连接 Zookeeper 然后连接失败报错,直接阻止你去完成任何单元测试,十分烦人。

甚至哪怕没有 @EnableDubbo 注释,Spring 依然会毅然决然启动 dubbo,跟疯狗一样,也就是在测试类中重新指定启动类的方案完全失效。

@Import 和 @ImportResource

这个注释需要搭配 @SpringBootTest 一同使用,最主要的作用是引入其他的 Bean,不过有的时候这个注解的作用确实不大,使用的大多数场景是,整个上下文都启动了,但是我们仍然需要其他的 Bean 来做测试的时候来使用的,然而本来单测就是一个限制上下文的场景,@SpringBootTest(classes={}) 也可以导入其他的 Bean 来做测试。

@ImportResource 是用来导入 XML 文件的

@TestConfiguration

一般作为内部类使用,不过也可以作为单独的类存在。当用作内部类时,也能 @SpringBootTest 扫描到,就可以直接在单测类里面引入特殊的 Bean,避免因为测试而引入冗余类,写法可以直接仿制 @Configuration 就行,比如下面这个例子:

// test 里面建的新 Bean 直接在内部写 Configuration
@Service
public class MyTestBean {

    @Resource
    private List<String> blackList;

    public void tell(){
        System.out.println(blackList);
    }

    @TestConfiguration
    public static class MyConfig{
        @Bean
        public List<String> blackList(){
            return Arrays.asList("Booob","Jaaaack","Kaaate");
        }
    }
}

// test 类
@SpringBootTest(classes = {MyTestBean.class})
public class ConfigurationTest {

    @Resource
    private MyTestBean myTestBean;

    @Resource
    private List<String> blackList; // 你会发现内部的 Bean 也被成功注入了

    @Test
    public void test(){
        myTestBean.tell();
        System.out.println(blackList);
    }
}

但是,有一个很坑的地方在于,如果采用下面这些方案

你就会惊奇地发现,Dubbo 又神奇的启动了🤬🤬🤬🤬!!

// 限制导入自己的内部 Config 类
@SpringBootTest(classes = {ConfigurationTest.MyConfig.class})
public class ConfigurationTest {

    @Resource
    private List<String> blackList; // 你会发现内部的 Bean 也被成功注入了

    @Test
    public void test(){
        System.out.println(blackList);
    }
    
    @TestConfiguration
    public static class MyConfig{
        @Bean
        public List<String> blackList(){
            return Arrays.asList("Booob","Jaaaack","Kaaate");
        }
 	}
}

// 或者限制导入其他的内部 Config 类
@SpringBootTest(classes = {MyTestBean.MyConfig.class})
public class ConfigurationTest {

    @Resource
    private List<String> blackList;

    @Test
    public void test(){
        System.out.println(blackList);
    }
    
    @TestConfiguration
    public static class MyConfig{
        @Bean
        public List<String> blackList(){
            return Arrays.asList("Booob","Jaaaack","Kaaate");
        }
 	}
}

@ContextConfiguration

上面提到 @SpringBootTest 导入 @TestConfiguration 的内部类时会意外启动 Dubbo 的 starter,那有没有既可以避免这个问题又可以只导入内部类的方案呢,答案是有的,就是用 @ContextConfiguration,这个注释专门用来导入指定的 Configuration,

@SpringBootTest(classes = {DefaultBean.class})
@ContextConfiguration(classes = {ConfigurationTest.MyConfig.class})
public class ConfigurationTest {

    @Resource
    private List<String> blackList;

    @Test
    public void test(){
        System.out.println(blackList);
    }
    
    @TestConfiguration
    public static class MyConfig{
        @Bean
        public List<String> blackList(){
            return Arrays.asList("Booob","Jaaaack","Kaaate");
        }
 	}
}


// DefaultBean 这个只是一个空 Bean 为了避免 @SpringBootTest 启动整个 Spring 上下文
@Service
public class DefaultBean {
}

代理中间件和数据库

其实各种代理中间件和数据库最方便的方案可以用 @ActiveProfiles("test") 并且配置 application-test.yml, 然后在里面配置各种代理和测试的中间间和数据库。也可以在 jvm 启动时使用参数 active=test 来指定配置文件。这种方案对代码的侵入性很低,也不用在 pom 中添加其他的类似于 h2database 和 testcontainers 这些内嵌式的代理测试库,太多的 Test 导致 Maven 依赖太混乱其实也很不好。

如果系统采用的是 RDS 平台,没办法直接在 application.yml 中更改配置,那就只能采用另一种代理方案,也就是自己提供代理的 DataSource

加载方式有很多种:@ContextConfiguration 或者 new AnnotationConfigApplicationContext(DenoTestMapperConfig.class);

@Configuration
@MapperScan(basePackageClasses = TestMapper.class)
public class DenoTestMapperConfig {

    @Bean(name = "myTestDatasource")
    @Qualifier("myTestDatasource")
    public DataSource dataSource(){
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setUrl("jdbc:mysql://xx.xx.xx.xx/table?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8");
        dataSource.setUsername("username");
        dataSource.setPassword("*********");
        return dataSource;
    }

    @Bean(name = "myTestSqlSessionFactory")
    public SqlSessionFactory sqlSessionFactory(@Qualifier("myTestDatasource") @Lazy DataSource dataSource) throws Exception{ // @Lazy 避免循环依赖
        String path = " path to TestMapper.xml";
        Resource resource = new PathMatchingResourcePatternResolver().getResource(path);
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        bean.setMapperLocations(new Resource[]{resource});
        return bean.getObject();
    }
}

h2database 和 testcontainers 这些内嵌式容器就大家自己去尝试吧,我是觉得配这些东西不如自己搭一个测试环境出来,更方便。

Mockito

官方文档

中文文档

这个是一个十分好用的 Mock 框架,核心是 @MockBean 和 @SpyBean 这两个注解,两种都可以在 Spring 上下文种注入自己 Mock 的 Bean,最大的区别在于两者 CallRealMethod 时的区别,下面是一个简单的例子:

@MockBean 是创建了一个新的 Bean 来代替原来的 Bean,这也就意味着原来的 Bean 里面的所有的依赖注入全部无效,所以有 rpc 依赖的 Mybean 依然能成功实例化,而 @SpyBean 类似一个代理 Bean,会将原来的 Bean 包裹起来代理,原来 Bean 中所有的依赖注入全都保留(包括注入的 MockBean),这样便可以通过调用原来方法来完成方法增强的效果。

顺带一提,两种模拟方式的实现方法上也有细微的差别,因为 MockBean 一般会用在 RPC 或者一些中间件的 客户端上面,为了获取数据,而 SpyBean 一般只是用来增强和监控。

@MockBean 比较推荐使用 Mockito.when(mockedBean.doSomething()).thenReturn(); 如果是 void 方法可以采用 Mockito.doNothing().when(mockedBean).doSomething(Mockito.any());

@SpyBean 比较推荐 Mokito.doAnswer(...).when(mockedBean).doSomething();

@SpringBootTest(classes = {DefaultBean.class})
public class MockTest {

    @MockBean
    private MyBean myBean;

    @SpyBean
    private NormalBean normalBean;

    @BeforeEach
    public void doBefore(){
        System.out.println("setup Before");
        MockitoAnnotations.openMocks(this);

        Mockito.when(myBean.rpcSay(Mockito.anyString())).thenAnswer(
                invocation -> {
                    String argument = (String) invocation.getArgument(0);
                    System.out.println("[Mock run]" + argument);
                    return "[Mock ans]" + argument;
                }
        );

        Mockito.doAnswer(invocation -> {
            String argument = (String) invocation.getArgument(0);
            System.out.println("[Spy run]" + argument);
            invocation.callRealMethod();  // 在这里 @SpyBean 可以成功执行,而 @MockBean 会报 NPE 
            return null;
        }).when(normalBean).m2(Mockito.anyString());
    }
    
    @Test
    public void test2(){
        normalBean.m2("word");
    }
}

@Service
public class NormalBean {

    @Resource
    private MyBean myBean;

    public void m2(String word){
        System.out.println("myBean.rpcSay = " + myBean.rpcSay(word));
    }
}

@Component
public class MyBean {

    @DubboReference
    private MyRPC rpc;

    public String  rpcSay(String word){
        return word + rpc.say(word);
    }
}

// 结果输出如下:
setup Before
[Spy run]word
[Mock run]word
myBean.rpcSay = [Mock ans]word