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

达芬奇密码2018-06-25 12:58


三、间接依赖的bean的mock替换

      对于前面提供的@Mock,@Spy+@InjectMocks的方案,通过了解其源码实现可以发现,存在无法解决间接依赖bean的mock替换问题。还是拿前面的OrderService和UserCouponService举例:

      如果OrderService的实现类OrderServiceImpl直接依赖UserCouponService,所以可以通过上述方案实现注入。但设想下如下场景:

case1.  OrderServiceImpl并没有直接依赖UserCouponService,而是间接依赖一个MarketService。然后MarkService的实现类MarkServiceImpl再依赖了UserCouponService。

case2. OrderServiceImpl同时直接和间接依赖UserCouponService。

     这种场景下,我们发现OrderServiceImpl中直接依赖的UserCouponService已经被替换为Mock对象,而依赖MarkService对象中UserCouponService则不是Mock对象。到这里,问题已经很明显了。@Mock,@Spy+@InjectMocks所实现的方案,只完成了当前测试实例中所有标注了@InjectMocks属性对象中所直接依赖的属性的mock输入。而间接依赖的属性就爱莫能助了。

    这个问题我们该如何解决?

    首先,持有纯正的单元测试理念的同学可能会先跳出来。"单元测试,应该尽可能的细化测试的粒度。像上面的情况,应该mock整个MarketService才对,就没有Mock MarkServiceImpl中的UserCouponService的必要了"。是的,这理念的确是对的。理想比较美好,但现实可能就比较骨干了。因为毕竟我们限制了单元测试编写者使用Mock的灵活性。可能因此无法做一些业务逻辑粒度虽然稍微粗一点,但逻辑含义会相对比较完整,同时输入输出更为简单的"原子业务"粒度的测试。事实上,当我们开始选择使用spring test框架时,我们就已经走在这条路上了。

    因此,我们需要支持间接依赖的bean的mock替换。那么,我们能否在原先的MockTestExecutionListener的实现中,增加多层嵌套的Inject呢?显然答案也是明确的,这等同于要实现一个网状遍历,根本不靠谱。话说回来,我们为什么不通过DI来实现?

    如果大家看过Springboot test框架的源码实现,相信也会发现它已经在其中增加了@MockBean和@SpyBean的设计方案。这套方式使得开发者可以通过在测试类的属性上增加上述注解的方式,将Spring BeanFactory中对应类型的bean替换为Mock或Spy对象。这样所有符合条件的直接或间接依赖的属性bean,都会被mock或spy掉。让我们来具体看看Springboot test框架在这方面的具体实现。首先,我们看下对应jar包中的spring.factories文件。

# Spring Test ContextCustomizerFactories
org.springframework.test.context.ContextCustomizerFactory=\
org.springframework.boot.test.context.ImportsContextCustomizerFactory,\
org.springframework.boot.test.context.SpringBootTestContextCustomizerFactory,\
org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizerFactory,\
org.springframework.boot.test.mock.mockito.MockitoContextCustomizerFactory

# Test Execution Listeners
org.springframework.test.context.TestExecutionListener=\
org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener,\
org.springframework.boot.test.mock.mockito.ResetMocksTestExecutionListener   

      其中值得重点关注的,主要是MockitoContextCustomizerFactory以及MockitoTestExecutionListener和ResetMocksTestExecutionListener这两个

TestExecutionListener。玄机尽在其中。先看下其中MockitoContextCustomizerFactory的实现:

class MockitoContextCustomizerFactory implements ContextCustomizerFactory {
   @Override
   public ContextCustomizer createContextCustomizer(Class testClass,
         List configAttributes) {
      // We gather the explicit mock definitions here since they form part of the
      // MergedContextConfiguration key. Different mocks need to have a different key.
      DefinitionsParser parser = new DefinitionsParser();
      parser.parse(testClass);
      return new MockitoContextCustomizer(parser.getDefinitions());
   }
}

     可以看到MockitoContextCustomizerFactory主要依赖了一个DefinitionsParser,后者会解析当前testClass及其父类中的所有@MockBean和@SpyBean注解信息,解析后将相关MockDefition传递给MockitoContextCustomizer。再来看看MockitoContextCustomizer的实现:


class MockitoContextCustomizer implements ContextCustomizer {
private final Set<Definition> definitions;

MockitoContextCustomizer(Set<? extends Definition> definitions) {
this.definitions = new LinkedHashSet<Definition>(definitions);
}

@Override
public void customizeContext(ConfigurableApplicationContext context,
MergedContextConfiguration mergedContextConfiguration) {
if (context instanceof BeanDefinitionRegistry) {
         //看这里
MockitoPostProcessor.register((BeanDefinitionRegistry) context,
this.definitions);
}
}

      从上面的源码可以看到,MockitoContextCustomizer是一个Spring初始化扩展ContextCustomizer的实现类,它会在spring容器初始化阶段,往

BeanDefinitionRegistry里添加MockitoPostProcessor的BeanDefinition(事实上还会包含一个SpyPostProcessor,后面也有关于它的相关介绍)。其中,MockitoPostProcessor是一个同时集成InstantiationAwareBeanPostProcessorAdapter和实现了BeanFactoryPostProcessor接口的类。接下来,对Spring的各种Processor的生命周期或作用阶段不太清楚的同学可能要先去补补功课。然后,让我们来看下这家伙都在干什么:

     首先,在BeanFactoryPostProcessor.postProcessBeanFactory阶段:

1.为所有注解了@MockBean的属性创建Mock对象,注册到BeanFactory中,同时往BeanDefinitionRegistry添加相应的BeanDefinition。

2.为所有注解了@SpyBean的属性创建Spy相关的BeanDefinition,添加到BeanDefinitionRegistry,同时维护SpyDefinition、beanName、测试实例对应的Field的关系信息。

     这里大家可能和我一样,会有一些疑问:

1.为什么@SpyBean和@MockBean在这个阶段有这些不同呢?

     相信大家都已经想到,因为Spy的CallRealMethod设计,导致其需要依赖target bean。所以其Spy代理对象的生成,会延后到后面SpyPostProcessor进行创建。

2.MockitoPostProcessor为什么需要继承InstantiationAwareBeanPostProcessorAdapter呢?

    阅读其源码可以发现,MockitoPostProcessor只重载了postProcessPropertyValues方法,它会在属性Inject阶段,将所有的MockBean和SpyBean对象注入到测试实例中。

    讲完了MockBean对象的生成、注册和注入,该到SpyBean了。SpyBean到测试实例的注入,前面讲过,是在MockitoPostProcessor的postProcessPropertyValues阶段。因此,MockitoPostProcessor只负责SpyBean的创建和注册,这个阶段是不会真正去创建Spy对象的。让我们来具体看下SpyPostProcessor的实现:

static class SpyPostProcessor extends InstantiationAwareBeanPostProcessorAdapter
      implements PriorityOrdered {

   private static final String BEAN_NAME = SpyPostProcessor.class.getName();
   private final MockitoPostProcessor mockitoPostProcessor;
   SpyPostProcessor(MockitoPostProcessor mockitoPostProcessor) {
      this.mockitoPostProcessor = mockitoPostProcessor;
   }

   @Override
   public int getOrder() {
      return Ordered.HIGHEST_PRECEDENCE;
   }

   @Override
   public Object getEarlyBeanReference(Object bean, String beanName)
         throws BeansException {
      return createSpyIfNecessary(bean, beanName);
   }

   @Override
   public Object postProcessAfterInitialization(Object bean, String beanName)
         throws BeansException {
      if (bean instanceof FactoryBean) {
         return bean;
      }
      return createSpyIfNecessary(bean, beanName);
   }

      可以看到,SpyPostProcessor继承了InstantiationAwareBeanPostProcessorAdapter,并实现了PriorityOrdered接口。在getEarlyBeanReference

和postProcessAfterInitialization阶段,SpyPostProcessor都去做了createSpyIfNecessary,即创建spy代理对象。而其中具体的创建逻辑,是由

SpyDefinition类代理的。那我们再看下SpyDefinition的实现:

public  T createSpy(String name, Object instance) {
   Assert.notNull(instance, "Instance must not be null");
   Assert.isInstanceOf(this.typeToSpy.resolve(), instance);
   if (this.mockUtil.isSpy(instance)) {
      return (T) instance;
   }
   MockSettings settings = MockReset.withSettings(getReset());
   if (StringUtils.hasLength(name)) {
      settings.name(name);
   }
   settings.spiedInstance(instance);
   settings.defaultAnswer(Mockito.CALLS_REAL_METHODS);
   return (T) Mockito.mock(instance.getClass(), settings);
}

    看到这里,是否已经全然明了?~ 

    是的,我当时也是这么觉得,开开心心的run it!然后,就开始被一系列的问题打击......

    首先,第一个问题是,我发现当@SpyBean标记的属性是一个符合Spring的AbstractAutoProxyCreator代理条件的bean时,其属性值竟然不是Spy代理对象,而是一个对象名称类似于XXXEnhancerByMockitoWithCGLIB@dddEnhancerBySpringCGLIB@eee的东东。不过,这个问题显而易见。应该是委托bean先被Mockito代理,再被spring AutoProxy代理。这就导致了这个"伪Mock"对象根本就无法正常录制脚本。

    那么,是否这个顺序应该反过来才是合理呢?

    于是乎自定义SpyPostProcessor的实现,使其优先级低于AbstractAutoProxyCreator。果然,这招还挺有效,生成的spy对象名称已经成功的换成预期的XXXEnhancerBySpringCGLIB@eeeEnhancerByMockitoWithCGLIB@ddd。同时,观察其他bean对其的依赖,也都全部更新为其spy代理的实例,且对象地址相同。MS大功告成!~

    然而悲剧很快上演。这个时候我悲催的发现,连录制都失败了。追踪其行为发现,虽然该对象名称已经改XXXEnhancerBySpringCGLIB@eeeEnhancerByMockitoWithCGLIB@ddd。但对其的调用,只会切入到Spring Aop的拦截部分,而Mockito代理的MethodInvokation拦截部分根本没有进入。观察下该bean对象的属性,发现其除了代表SpringAOP代理拦截的CGLIBCALLBAK0 6MockitoCGLIBCALLBAK0~1。CALLBAK0~1这两个属性,丫的名称一样,导致虚拟机从上往下调用时,只处理了优先增强的Spring AOP拦截器CGLIB$CALLBAK0~1。

    这是两次动态代理导致的问题吗?

    还真不是!因为我记得之前代理顺序反过来的时候,SpringAOP代理对象中,并没有由于Mockito代理而生成的CGLIB$CALLBAK0~1属性。当然,其target对象的确是Mockito代理对象。那为什么将代理顺序反过来时,会这么奇怪?

    跟踪下Mockito的mock代理实现,发现这家伙干了件很不光彩的事情。我们看下其中关键的MockUtil. createMock代码部分:

public class MockUtil {

    private static final MockMaker mockMaker = Plugins.getMockMaker();

    public boolean isTypeMockable(Class type) {
      return !type.isPrimitive() && !Modifier.isFinal(type.getModifiers());
    }

    public  T createMock(MockCreationSettings settings) {
        MockHandler mockHandler = new MockHandlerFactory().create(settings);

        T mock = mockMaker.createMock(settings, mockHandler);

        Object spiedInstance = settings.getSpiedInstance();
        if (spiedInstance != null) {
            new LenientCopyTool().copyToMock(spiedInstance, mock);
        }

        return mock;
    }

     如上源码所示,在mock代理对象创建出来后,发现当前是spy场景时,会将spyInstance中的所有属性,copy到mock代理对象中。这就导致了SpringAOP代理的CGLIBCALLBAK0 6MockitoCGLIBCALLBAK0~1属性,阿门!

     既然凶手已经找到,问题也总应该能够得到解决。看到这里,相信大家和我一样会有一个疑问,为什么SpyBean的代理对象,需要从target对象全量copy属性?Mockito在创建Spy对象时,不是已经在MockSettingImpl中增加了spiedInstance的引用了吗,难道它没有在CallRealMethod的实现中,将请求路由到spiedInstance的相关方法的invoke中去吗?

    有此疑问,那我们就看下它内部的实现原理。首先看下Mockito代理对象的拦截行为MethodInterceptorFilter的实现:

public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy)
        throws Throwable {
    if (objectMethodsGuru.isEqualsMethod(method)) {
        return proxy == args[0];
    } else if (objectMethodsGuru.isHashCodeMethod(method)) {
        return hashCodeForMock(proxy);
    } else if (acrossJVMSerializationFeature.isWriteReplace(method)) {
        return acrossJVMSerializationFeature.writeReplace(proxy);
    }

    MockitoMethodProxy mockitoMethodProxy = createMockitoMethodProxy(methodProxy);
    new CGLIBHacker().setMockitoNamingPolicy(methodProxy);

    MockitoMethod mockitoMethod = createMockitoMethod(method);

    CleanTraceRealMethod realMethod = new CleanTraceRealMethod(mockitoMethodProxy);
    Invocation invocation = new InvocationImpl(proxy, mockitoMethod, args, SequenceNumber.next(), realMethod);
    return handler.handle(invocation);
}

    可以发现,拦截器的所有调用后面会被传递到InvocationImpl的mockitoMethod或realMethod上。那我们再看下MockHandlerImpl类的的相关实现:

// look for existing answer for this invocation
StubbedInvocationMatcher stubbedInvocation = invocationContainerImpl.findAnswerFor(invocation);

if (stubbedInvocation != null) {
    stubbedInvocation.captureArgumentsFrom(invocation);
    return stubbedInvocation.answer(invocation);
} else {
     Object ret = mockSettings.getDefaultAnswer().answer(invocation);

     上述代码片段告诉我们,Mockito的录制与响应逻辑是,当缺少匹配的Stub invocation实例时,调用会被路由到InvocationImpl的callRealMethod方法上。打开其实现,马上一场骗局映入眼帘。

public Object callRealMethod() throws Throwable {
    if (method.isAbstract()) {
        new Reporter().cannotCallAbstractRealMethod();
    }
    return realMethod.invoke(mock, rawArguments);
}

     在InvocationImpl.callRealMethod的实现中,根本就不是从MockSettingImpl中获取spiedInstance(即委托对象)进行invoke。而是诉诸于Mock代理对象的method.invoke。对于普通公开方法的调用,的确也没有问题,因为代理类都是继承或实现了委托目标类或接口的,因此对其他普通方法的调用最终还是会传递到委托类的方法调用上。但是,对于属性的访问,那就只能呵呵了。因为proxy对象的同名属性和target对象的同名属性,在内存中,可是两块独立的地址。这个在文章开始也讲过类似的问题,mockito在这里挖了一个坑。所以,mockito在spy场景的mock中,"补救式"的做了一个全量的属性copy...


    事已至此,我能想到的办法有两个:

方案1. 在属性全量copy时,增加过滤设计。例如过滤CGLIB$CALLBAK(XXX)等名称的属性copy,这样就不会导致CALLBACK截杀。但我不想这么做:

   直接的,这里也没有预留任何的属性过滤器回调,扩展难以优雅,而且即便有,以后还受动态代理的织入方式的影响。

   根本的,这个实现多少有点hack的味道。文档告诉我spy对象没有录制匹配时,是调用被spy的委托对象,而实际上是在调用copy出来的代理实例!违反自然的设计,终将被时间证明其不合理性。


方案2. 去除这里的属性全量copy,让RealMethod的调用传递到对真实委托对象的调用上。是的,我觉得这种方式更合理。于是,重写MethodInterceptorFilter,覆盖其intercept方法。具体可以看下EduPowerMockMethodInterceptorFilter。(PS:示例这里我用了PowerMockito的

自定义Filter版本。因为底层mock框架往PowerMockito迁移的工作正做了一半,这一半也能配合正常工作,同时原理也一样,图个方便,我就拿这个讲了)

public Object superIntercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy)
        throws Throwable {
    if (objectMethodsGuru.isEqualsMethod(method)) {
        return proxy == args[0];
    } else if (objectMethodsGuru.isHashCodeMethod(method)) {
        return hashCodeForMock(proxy);
    } else if (acrossJVMSerializationFeature.isWriteReplace(method)) {
        return acrossJVMSerializationFeature.writeReplace(proxy);
    }

    MockitoMethodProxy mockitoMethodProxy = createMockitoMethodProxy(methodProxy);
    new EduCGLIBHacker().setMockitoNamingPolicy(methodProxy);

    MockitoMethod mockitoMethod = createMockitoMethod(method);

    CleanTraceRealMethod realMethod = new CleanTraceRealMethod(mockitoMethodProxy);
    Invocation invocation = new EduInvocationImpl(proxy, mockitoMethod, args, SequenceNumber.next(), realMethod,mockSettings.getSpiedInstance());
    return getHandler().handle(invocation);
}

然后再看下扩展的EduInvocationImpl中的实现:


public EduInvocationImpl(Object mock, MockitoMethod mockitoMethod, Object[] args,
                         int sequenceNumber, RealMethod realMethod, Object target) {
    super(mock, mockitoMethod, args, sequenceNumber, realMethod);
    mockitoMethodHolder=mockitoMethod;
    realMethodHolder=realMethod;
    targetHolder=target;
}
public Object callRealMethod() throws Throwable {
    if (mockitoMethodHolder.isAbstract()) {
        new Reporter().cannotCallAbstractRealMethod();
    }
    return mockitoMethodHolder.getJavaMethod().invoke(targetHolder,getRawArguments());
    //return realMethodHolder.invoke(targetHolder, getRawArguments());
}

     在上述代码里面,我将具体的请求路由到对target的"反射版本"的method调用中。(PS:至于反射版本和字节码增强版本的method invokation的区别,等下次介绍PowerMockio版本实现时再一起带上)。这下,应该差不多了吧?不过经过这么几次折腾,我已经不再奢望一次成功。应该还会有什么幺蛾子吧,来吧!

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

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

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