自己动手写Mock

相信做过单元测试的同学都有用过各种各样的mock工具,至于为什么要mock,网上有一大堆解释,简单来说就是当有些功能难以实现,或者有些功能在测试的时候无法访问的时候,这种情况就需要用到mock了。

当然mock的框架有很多,常用的有EasyMock, Jmockit, Mockito等,我个人感觉Mockito的接口用起来很舒服,所以较喜欢Mockito。Mockito的常用接口写法如下:

Mocktio.when(list.get(1)).thenReturn(10);  

接口的含义从使用方法上就可以看出来,即指定当调用某个方法的时候返回特定的结果。这个使用方法严格来讲叫Stub,关于mock,stub还有spy等名词我在这里就不解释了(其实我也解释不太清楚),有兴趣的同学可以参考Mocks Aren't Stubs,其中有一句话说的比较清楚,mock注重行为的验证,stub注重状态的验证

下面我会通过Cglib动态代理的方式来实现Mockito的这种写法。

待Mock接口

下面是待mock接口的定义,我们假设这是一个数据库操作,但是在实际做单元测试的时候我们不希望去访问真正的数据库,所以就需要mock这个数据访问对象。

public interface UserDao {  
    int getAge(int id, String name);
    String getName(int id);
}

调用方法抽象

从Mockito的使用方法来看,Mockito内部把方法的调用跟返回值做了映射,所以我们必须把每个方法的调用做一个抽象,然后用Map进行存储。这里涉及一个问题,就是我们要用自定义的类来做Map的键,所以不得不了解一下Map的存储原理。简单说一下Map的存储访问原理,Map在存储的时候首先会根据对象的hashCode方法找到对应的bucket,然后每一个bucket维护一个链表用来解决冲突;Map在根据key访问对应的value的时候,同样先根据hashCode方法找到对应的bucket,然后遍历这个bucket的链表,调用equals进行对象比较,返回相等的对象。默认的hashCode方法会返回这个对象的存储地址,所以每一个对象的hashCode都是不同的,所以如果我们想用自定义的对象用作Map的key的话,就必须重写hashCode方法,我这里使用《Effective Java》中建议的方法重写hashCode。重写hashCode之后还要重写equals方法,引用《Effective Java》的一句话,“重写hashCode方法后,永远要重写equals方法”。做了这几件事情后,一个方法的抽象我们就建立起来了,下面就开始进入本篇博客的核心部分了。

public class Invocation {

    private Object o;
    private Method method;
    private Object[] objs;
    private MethodProxy methodProxy;

    public Invocation(Object o, Method method, Object[] objs, MethodProxy methodProxy) {
        this.o = o;
        this.method = method;
        this.objs = copyArgs(objs);
        this.methodProxy = methodProxy;
    }
    // 深拷贝
    private Object[] copyArgs(Object[] objs) {
        Object[] args = new Object[objs.length];
        System.arraycopy(objs, 0, args, 0, objs.length);
        return args;
    }
    // 重写equals方法
    @Override
    public boolean equals(Object obj) {
        if (obj == null || !(obj instanceof Invocation))
            return false;

        Invocation invocation = (Invocation) obj;
        return this.method.equals(invocation.method) && this.methodProxy.equals(invocation.methodProxy)
                && Arrays.deepEquals(this.objs, invocation.objs);
    }
    // 重写hashCode方法
    @Override
    public int hashCode() {
        int hashcode = 0;
        hashcode += this.method.hashCode();
        hashcode += this.methodProxy.hashCode();
        for (Object obj : objs) {
            hashcode += obj.hashCode();
        }
        return hashcode;
    }
}

动态代理实现when().thenReturn()式mock

利用Cglib实现动态代理可以参考Java动态代理模式这篇博文。当进行方法调用时,会触发方法拦截器interceptor方法,在这个方法中首先保存最近一次方法调用,供之后使用,然后去map中找看有没有这个方法调用对应的返回值,如果有直接返回,否则返回null。

// stub: return according to last invocation
public static class  StubMethodInterceptor implements MethodInterceptor {

    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        Invocation invocation = new Invocation(o, method, objects, methodProxy);
        lastInvocation = invocation;
        if (map.containsKey(invocation)) {
            return map.get(invocation);
        }
        return null;
    }
}

下面就是保存方法调用和返回值的地方。可以看到thenReturn中将最近一次方法的调用和其规定返回值保存至HashMap中,这样当真正进行方法调用的时候,动态代理就会返回预先规定的返回值了。

public static <T> When<T> when(T value) {  
    return new When<T>();                                                           }
public static class When<T> {  
    public void thenReturn(T retValue) {                                          
        map.put(lastInvocation, retValue);                                                                              
    }
}

测试

下面给出测试用例。

// 创建mock对象
UserDao stub = TestDouble.mock(UserDao.class, new TestDouble.StubMethodInterceptor());  
// 规定返回值
TestDouble.when(stub.getAge(1, "zsh")).thenReturn(5);  
TestDouble.when(stub.getName(1)).thenReturn("zsh");  
// 测试返回值
Assert.assertEquals(5, stub.getAge(1, "zsh"));  
Assert.assertEquals("zsh", stub.getName(1));  

最后附上源码下载地址

参考

Mocks Aren't Stubs

Shaohang Zhao

Read more posts by this author.