教育单元测试mock框架优化之路(上)

达芬奇密码2018-06-25 12:47
  众所周知,mock对于单元测试,尤其是基于spring容器的单元测试,是非常重要的。它可以帮助我们隔离其他模块的错误、外部依赖、慢操作、未实现功能等等,也可以帮助我们创建一些难以复现的场景等。
      教育这边目前选择的mock框架底层是基于Mockito的。因为Mockito具有使用起来简单,学习成本很低,API简洁,测试代码可读性高,无须录制mock方法就能返回默认值等优点。实际上,大名鼎鼎的springboot的test框架就是选择了mockito做为其底层实现,同时将其与junit4,spring完美的结合在一起。教育这边对单元测试的mock框架,也是站在这些巨人的肩膀上,同时针对碰到的问题,做了一系列的改造和优化。虽然框架改造仍有很多路要走,不过到目前为止碰到的问题和相关解决办法或思路,还是很值的拿出来和大家分享交流一下的。

一、当@InjectMocks遇到动态代理

      可能大家会一个疑问,为什么不直接使用springboot的test框架?这里面主要有两个原因:
      1. 新框架的学习和迁移需要时间(这个听起来更像托词囧。的确,我们正在积极向springboot/cloud框架迁移中);
      2.springboot的test框架的默认实现中,对于@InjectMocks标注的属性,如果是动态代理对象时,其Inject注入的@Mock或@Spy对象,会被设置到proxy对象的属性上,而非target对象的属性上,这将导致mock失效。
上面的情况听起来可能有点抽象,让我们来看一个实际的例子。
图1. 一个Mockito的@Spy和@InejctMocks使用场景

图2. 预期是注入userCouponService的spy代理对象,实际上是userCouponService的Spring AOP代理对象

图3. bizOrderService的动态代理对象的同名属性,包含了一个userCouponService的spy代理对象


图4. 测试实例中引用的userCouponService为spy代理对象,可以进行录制;BizOrderServiceImpl中
userCouponService属性则不是spy代理对象,无法进行正常mock应答。
那么,是什么原因导致的呢?让我们来看下SpringBoot test框架中负责完成这一任务的 MockitoTestExecutionListener的实现。 
public class MockitoTestExecutionListener extends AbstractTestExecutionListener {
        @Override
	public void prepareTestInstance(TestContext testContext) throws Exception {
		if (hasMockitoAnnotations(testContext)) {
                        //看这里
			MockitoAnnotations.initMocks(testContext.getTestInstance());
		}
		injectFields(testContext);
	}   

    可以看到Springboot的MockitoTestExecutionListener,在test实例化阶段,通过Mockito框架的MockitoAnnotations来对test实例进行mock注解扫描和注入。再具体跟进下MockitoAnnotations.initMocks实现内部,可以发现问题出现在其MockInjectionStrategy的实现上。我拿其中一种MockInjectionStrategy策略(Spy注入策略)来举例说明。

public class SpyOnInjectedFieldsHandler extends MockInjectionStrategy {
    @Override
    protected boolean processInjection(Field field, Object fieldOwner, Set
       如上代码块,其中的fieldOwner为当前测试实例,field为当前测试类的orderService属性对象。因此,生成的mock(spy)对象,被注入到orderService的proxy对象,而非orderService的target对象。
       针对待InjectMocks的field对象如果是动态代理的情况,其实我们可以通过扩展Mockito框架的MockInjectionStrategy的processInjection方法,对相应用field的value进行unwrap代理后再进行InjectMock处理即可。遗憾的是,由于Mockito框架并发基于IOC设计,所以如何优雅的扩展,还需要后面再细细看下源码。当下我们是仿照了这个流程,自己完整的实现了一个spring test框架AbstractTestExecutionListener类来解决这个问题。下面是我们自定义的MockListener的主干实现。
public class MockListener extends AbstractTestExecutionListener {
    @Override
    public void beforeTestMethod(TestContext testContext) throws Exception {
        testContext.setAttribute(TEST_CONTEXT_MOCKS_MAP_KEY, new HashMap());
        testContext.setAttribute(TEST_CONTEXT_SPYS_MAP_KEY, new HashMap());
        /*1. 为带@EduMock注解的属性创建Mock对象*/
        createMocks(testContext);
        /*2. 为带@Spy注解的属性创建Spy对象*/
        createSpys(testContext);
        /*3. 为带@EduInjectMocks注解的bean注入mock/spy对象*/
        injectIntoTargetBeans(testContext);
    }
    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        /*还原为真实的对象*/
        Map> injectedBeanMap = getInjectedBeanMap(testContext);
        /*恢复bean依赖的真实对象*/
        for (Map.Entry> entry : injectedBeanMap.entrySet()){
            Object targetBean = entry.getKey();
            Map originalFieldMap = entry.getValue();
            unInjectTargetBean(targetBean, originalFieldMap);
        }
        testContext.removeAttribute(TEST_CONTEXT_MOCKS_MAP_KEY);
        testContext.removeAttribute(TEST_CONTEXT_SPYS_MAP_KEY);
        testContext.removeAttribute(TEST_CONTEXT_INJECTED_MAP_KEY);
    }
      这是其中injectIntoTargetBean部分的逻辑:
private void injectIntoTargetBean(TestContext testContext, Field targetBeanField) throws Exception{
        Object testInst = testContext.getTestInstance();
        Object injectedBean = targetBeanField.get(testInst);     //被注入的目标Bean
        //看这里
        Object beanUnwrap = UnWrapProxyUtil.unwrapProxy(injectedBean);  //被spring代理的真实bean
        Class clazz = beanUnwrap.getClass();
        final Field[] fields = clazz.getDeclaredFields();      
        Map mockMap = (Map)testContext.getAttribute(TEST_CONTEXT_MOCKS_MAP_KEY);
        Map spyMap = (Map)testContext.getAttribute(TEST_CONTEXT_SPYS_MAP_KEY);
        
        Map originalFieldMap = new HashMap();
        for (Field field : fields){
            Object mock = mockMap.get(field.getName());
            Object spy = spyMap.get(field.getName());           
            if (null != mock || null != spy){
                field.setAccessible(true);            
                /*暂存目标bean原有的属性,测试类执行完后需要替换回原有属性*/
                originalFieldMap.put(field, field.get(beanUnwrap));              
                /*注入mock对象*/
                if (null != mock){
                    ReflectionTestUtils.setField(beanUnwrap, field.getName(), mock);
                }              
                /*注入spy对象*/
                if (null != spy){
                    ReflectionTestUtils.setField(beanUnwrap, field.getName(), spy);
                }
            }
        }
        
        Map> injectedBeanMap = getInjectedBeanMap(testContext);
        injectedBeanMap.put(beanUnwrap, originalFieldMap);
    }
private void injectIntoTargetBean(TestContext testContext, Field targetBeanField) throws Exception{
        Object testInst = testContext.getTestInstance();   
        Object injectedBean = targetBeanField.get(testInst);     //被注入的目标Bean
        //看这里
        Object beanUnwrap = UnWrapProxyUtil.unwrapProxy(injectedBean);  //被spring代理的真实bean        
        Class clazz = beanUnwrap.getClass();
        final Field[] fields = clazz.getDeclaredFields();      
        Map mockMap = (Map)testContext.getAttribute(TEST_CONTEXT_MOCKS_MAP_KEY);
        Map spyMap = (Map)testContext.getAttribute(TEST_CONTEXT_SPYS_MAP_KEY);       
        Map originalFieldMap = new HashMap();
        for (Field field : fields){
            Object mock = mockMap.get(field.getName());
            Object spy = spyMap.get(field.getName());          
            if (null != mock || null != spy){
                field.setAccessible(true);               
                /*暂存目标bean原有的属性,测试类执行完后需要替换回原有属性*/
                originalFieldMap.put(field, field.get(beanUnwrap));              
                /*注入mock对象*/
                if (null != mock){
                    ReflectionTestUtils.setField(beanUnwrap, field.getName(), mock);
                }            
                /*注入spy对象*/
                if (null != spy){
                    ReflectionTestUtils.setField(beanUnwrap, field.getName(), spy);
                }
            }
        }       
        Map> injectedBeanMap = getInjectedBeanMap(testContext);
        injectedBeanMap.put(beanUnwrap, originalFieldMap);
    }

     可以看出来,我们自定义的MockListener与springboot test框架的MockitoTestExecutionListener的区别在于:

1. 通过UnWrapProxyUtil.unwrapProxy对待InjectMocks的域值进行解除代理处理,获取实际的target对象来做InjectMocks。
2. mock的实际和reset实现的方式不同。MockitoTestExecutionListener是在prepareTestInstance阶段做一次测试实例的mock注入,然后根据配置再每个测试方法执行前或后来进行所有mock对象的reset。我们自己实现的MockListener,是在每次测试方法执行前生成对应于的mock对象并完成注入,每次测试方法执行结束后,删除所有mock对象,并将标注有@InjectMocks的对象的所有的mock属性恢复为原始属性值。
3.@Mock与@Spy注解的属性(功能)有缩减,不过Mockito提供出来的注解功能扩展基本也是鸡肋,不要也罢。
      总体来说,我们自己实现的基于注解的Mock属性注入机制,解决了待注入对象为动态代理对象时带来的问题,但在性能上,由于实现的简化(其实是偷工减料~),略有损失。不过对于微服务的单元测试来说,这点性能变化一般也不会产生有什么严重的后果。当然,后面还是会把mockito的MockInjectionStrategy策略的优雅扩展弄清楚。框架如果能更好,为什么不呢?


二、 组件Bean如何优雅自动MOCK

     解决完上面这个问题后,我们仍然面临新问题:

1.   需要手动配置大量的dubbo consumer、amqp template等mock bean的xml配置。

图5. 大量的dubbo consumer的mock bean配置

图6. 大量的amqp的mock bean配置

2.  需要在产品端再额外单独定义(主要是因为namespace等属性不同)的Redis client,Memcache client,ZookeeperLockContext等bean的mock配置。

图7. 由于namespace不同而需要定义多个JedisClient的Mock Bean

图8. 由于beanName不同而需要定义多个ZookeeperLock的Mock Bean

3.  由于需要将单元测试的bean的mock配置部分单独出来,导致单元测试基类的import管理混乱和不稳定。甚至导致业务bean定义的xml组织需要考虑单元测试mock的需求。这样,不仅导致配置复杂,业务设计受单元测试影响,又或导致业务和单元测试发生较大不一致。

图9. 一个复杂的单元测试汇总xml配置文件

针对上述问题,我们的解决思路是这样的:
1. 业务Bean的组织方式不应收到单元测试mock实现方案的影响。Mock框架应该有能力在不破坏业务Bean的整体组织的基础上,根据需要动态的进行Bean组件的新增或替换。
2. 自动替换的实现类应该与具体的mock需求、打桩实现方案分离。后面只要按需扩展不同的bean mock需求和打桩实现即可。
3. mock匹配方案可以按照类型、bean名称、基类或接口来进行。
4. mock对象或工厂类可以继承或过滤原bean的相关属性。
5. 引入数据清理抽象。可以通过StubDataCleanListener来无差别的清理不同的Mock桩实现的内存状态数据。
       因此,我基于Spring的 BeanDefinitionRegistryPostProcessor扩展点实现了做自动mock替换的 EduMockAutoConfigBeanFactoryPostProcessor。该Processor会 根据自定义的@ AutoMock注解,对符合匹配规则的 BeanDefinition 进行修改,将对应业务 BeanDefinition的beanClass 替换为的相应mock对象的BeanFactory类。此外,可以进一步通过属性匹配过滤器来传递或过滤bean属性到mock对象。
      具体的EduMockAutoConfigBeanFactoryPostProcessor的 postProcessBeanDefinitionRegistry逻辑就是按照前面的设计思路实现的,我就不这里详细罗列了。不过,我们可以从从@ AutoMock和@ MockSetting这两个配置注解来了解该Processor支持的功能。
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoMock {
    Class[] byClass() default {};//根据类型进行匹配
    Class[] bySuperClass() default {};//根据基类进行匹配
    MockSetting[] byMockSetting() default {};//根据复杂的匹配或过滤配置
}
 @Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MockSetting{
      Class byClass() ;
      String propNameIncludeRegFilter() default ""; //符合正则匹配的属性会被传递
      String propNameExcludeRegFilter() default "";//符合正则匹配的属性会被过滤
      boolean copyContructArgs() default false;//传递构造函数参数
}
     下面列一个dubbo consumer的Mock自动替换的Dubbo consumer的mock FactoryBean实现类。要增加一种组件的Mock替换是不是非常简单?~
@Component
@AutoMock(byMockSetting = @MockSetting(byClass = ReferenceBean.class,propNameIncludeRegFilter = "^interface$"))
public class DubboReferenceMockFactoryBean extends ReferenceConfig implements FactoryBean<Object>{
@Override
public Object getObject() throws Exception {
return PowerMockito.mock(this.getInterfaceClass());
}
@Override
public Class<?> getObjectType() {
return this.getInterfaceClass();
}
@Override
public boolean isSingleton() {
return true;
}
public String getVersion() {
return null;
}
public void setVersion(String version) {
}
}


相关阅读:教育单元测试mock框架优化之路(中)

教育单元测试mock框架优化之路(下)

本文来自网易实践者社区,经作者方金德授权发布。