美文网首页单元测试Android知识Android开发
Robolectric Shadow类实现方式探索

Robolectric Shadow类实现方式探索

作者: 键盘男 | 来源:发表于2017-05-07 22:33 被阅读508次

    前言

    同学们平时用robolectric可能没太留意robolectric的Custum Shadow功能。简单地说,就是用Shadow类代替原始类,并不让调用者感知。Shadow机制不仅仅让用户修改自己写的类,robolectric大量用到shadow机制,这是最核心的技术。

    本文并不打算深入讲解robolectric shadow机制,robolectric用了比较复杂的原理。笔者希望用更简单的方式,实现基本的shadow机制。

    Shadow是什么?

    官方原文:

    Robolectric defines many shadow classes, which modify or extend the behavior of classes in the Android OS......Every time a method is invoked on an Android class, Robolectric ensures that the shadow class’ corresponding method is invoked first.

    大概意思是,robolectric有很多shadow类来修改或拓展Android OS原本的类......每一次执行android类时,robolectric确保shadow类先执行。

    简单的例子:

    Foo:

    public class Foo {
    
        public void display(){
            System.out.println("foo");
        }
    }
    

    ShadowFoo:

    @Implements(Foo.class)
    public class ShadowFoo {
    
        @Implementation
        public void display(){
            System.out.println("shadow foo");
        }
    }
    

    运行单元测试时,执行单元测试:

    @RunWith(RobolectricTestRunner.class)
    @Config(shadows = {ShadowFoo.class}, manifest = Config.NONE)
    public class FooTest {
    
        Foo foo;
    
        @Before
        public void setUp() throws Exception {
            foo = new Foo();
        }
    
        @Test
        public void display() throws Exception {
            foo.display();
        }
    }
    

    运行结果:

    shadow foo

    Robolectric单元测试,配置Shadow后,ShadowFoo会覆盖Foo行为。你可以写很多ShadowFoo,单元测试时配置不同的Shadow做不同的行为。

    Shadow意义何在?

    覆盖Android sdk行为

    在Android Studio可以看到Android大部分源;我们运行APP后,在Android Studio打断点debug代码,可以看到android代码执行。实际上,APP执行的是手机Android系统的代码,并不是我们AS依赖的sdk。那么,单元测试依赖的android sdk,真的跟我们在AS看到的代码一样吗?

    我们做个简单的测试:

    public class TextUtilsTest {
    
        @Test
        public void testIsEmpty() {
            TextUtils.isEmpty("");
        }
    }
    

    结果是这样:

    java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked. See http://g.co/androidstudio/not-mocked for details.
    at android.text.TextUtils.isEmpty(TextUtils.java)
    at com.example.robolectric.TextUtilsTest.testIsEmpty(TextUtilsTest.java:14)
    ...

    我们在AS查看TextUtils.isEmpty源码:

        public static boolean isEmpty(@Nullable CharSequence str) {
            if (str == null || str.length() == 0)
                return true;
            else
                return false;
        }
    

    这里都是jdk提供的基础代码,为什么就报错了呢?

    我们在AS查看依赖的android sdk路径:

    1.右键->Show in Explore

    sdk路径:{sdk目录}/platforms/android-25 (sdk不同版本在不同目录)

    2.然后用Java Decompiler查看这个jar代码:

    TextUtils.isEmpty()

    android.jar的代码,只是一个stub,里面根本没有android源码,全部方法都throw new RuntimeException("Stub!")

    因此,robolectric在运行时,需要替换这些代码。这就是Shadow机制存在的必要!

    (提醒,robolectric替换android代码,并不是所有都用shadow机制,大部分只是让ClassLoader加载robolectric提供的android-all.jar而已。View类基本用Shadow机制。)

    控制依赖外部环境的方法行为

    大多数情况下,我们用mock就能做到控制方法行为。但一些静态方法,例如NetworkUtils.isConnected(),mockito就做不到了。当然可以用powermockito,笔者认为mockito和powermockito混合使用比较蛋疼,毕竟方法名很多雷同,引用时比较麻烦。

    场景:1.网络正常,返回mock数据;2.网络断开,抛出异常。

    public class UserApi {
    
        Observable<String> getMyInfo() {
            if (NetworkUtils.isConnected()) {
                return Observable.just("...");
            } else {
                return Observable.error(new RuntimeException("Network disconnected."));
            }
        }
    }
    

    Shadow:

    @Implements(NetworkUtils.class)
    public class ShadowNetworkUtils {
    
        public static boolean sIsConnected;
    
        @Implementation
        public static boolean isConnected() {
            return sIsConnected;
        }
    
        public static void setIsConnected(boolean isConnected) {
            ShadowNetworkUtils.sIsConnected = isConnected;
        }
    }
    

    单元测试:

    @RunWith(RobolectricTestRunner.class)
    @Config(shadows = ShadowNetworkUtils.class)
    public class UserApiTest {
    
        UserApi userApi;
    
        @Before
        public void setUp() throws Exception {
            userApi = new UserApi();
        }
    
        @Test
        public void testGetMyInfo() {
    
            ShadowNetworkUtils.setIsConnected(true);
    
            String data = userApi.getMyInfo()
                                 .toBlocking()
                                 .first();
    
            Assert.assertEquals(data, "...");
        }
    
        // 期望抛出错误
        @Test(expected = RuntimeException.class)
        public void testNetworkDisconnected() {
            ShadowNetworkUtils.setIsConnected(false);
    
            userApi.getMyInfo()
                   .subscribe();
        }
    }
    

    由于NetworkUtils.setIsConnected()根据真实网络情况返回true or false,而且使用android api,所以运行单元测试必然报错。因此,我们希望能模拟网络正常和网络断开的情况,用ShadowNetworkUtils非常适合。


    自己实现Shadow

    思路

    原始类方法调用Shadow类方法

    这种方法需要在jvm动态改变原始类字节码,本方法存在Shadow类对象或者调用实际Shadow类静态方法,而不仅仅把Shadow类字节码拷贝给原始类。这么说有点抽象,继续看下文就懂了。

    框架选型

    动态修改jvm字节码,有好几款框架:asmcglibaspectJjavassist等。

    asm比较底层,非常难用;mockito就是用到cglib,笔者感觉cglib做动态代理比较在行,未试过修改字节码,有待考究;aspectJ笔者最喜欢,语法简洁,但最大问题是,笔者还不会在Android Studio配置成让单元测试可用(如果你懂的请留言);javassist api跟java反射api很像,也挺简单的,很快上手。

    最后笔者选择了javassist。

    实战

    gradle

    在build.gradle依赖javassist:

    dependencies {
        testCompile group: 'org.javassist', name: 'javassist', version: '3.21.0-GA'
    }
    

    准备工具类

    Robolectric的Implements注解(你也可以自己写)

    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.TYPE})
    public @interface Implements {
    
      /**
       * @return The class to shadow.
       */
      Class<?> value() default void.class;
    
      /**
       * @return class name.
       */
      String className() default "";
    }
    

    注解工具类:

    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    
    import javassist.ClassPool;
    import javassist.CtClass;
    import javassist.NotFoundException;
    import javassist.bytecode.annotation.Annotation;
    import javassist.bytecode.annotation.AnnotationImpl;
    import javassist.bytecode.annotation.ClassMemberValue;
    import javassist.bytecode.annotation.MemberValue;
    import javassist.bytecode.annotation.StringMemberValue;
    
    public class AnnotationHelper {
    
            /**
         * 获取Shadow类{@linkplain Implements}注解的类名
         *
         * @param clazz
         * @return
         * @throws ClassNotFoundException
         * @throws NotFoundException
         */
        public static String getAnnotationClassName(Class clazz) throws ClassNotFoundException, NotFoundException {
    
            ClassPool pool = ClassPool.getDefault();
            CtClass   cc   = pool.get(clazz.getName());
    
            Implements implememts = (Implements) cc.getAnnotation(Implements.class);
            String     className  = implememts.className();
    
            if (className == null || className.equals("")) {
                // 获取Implements注解value值
                className = getValue(implememts, "value");
            }
    
            return className;
        }
    
        /**
         * 获取注解某参数值
         */
        private static String getValue(Object obj, String param) {
            AnnotationImpl annotationImpl = (AnnotationImpl) getAnnotationImpl(obj);
            Annotation     annotation     = annotationImpl.getAnnotation();
            MemberValue    memberValue    = annotation.getMemberValue(param);
    
            if (memberValue instanceof ClassMemberValue) {
                return ((ClassMemberValue) memberValue).getValue();
            } else if (memberValue instanceof StringMemberValue) {
                return ((StringMemberValue) memberValue).getValue();
            }
            return "";
        }
    
        private static InvocationHandler getAnnotationImpl(Object obj) {
            Class clz = obj.getClass()
                           .getSuperclass();
    
            try {
                Field field = clz.getDeclaredField("h");
                field.setAccessible(true);
    
                InvocationHandler annotation = (InvocationHandler) field.get(obj);
    
                return annotation;
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            return null;
        }
    }
    

    动态改变字节码

    我们希望NetworkUtils修改后,有如下效果:

    public class NetworkUtils {
    
        public static boolean isConnected() {
            return ShadowNetworkUtils.isConnected();
        }
    }
    

    因此,我们要动态生成跟上面一模一样的源码的字节码,通过javassist替换原始类的方法。

    public class JavassistHelper {
    
        public static void callShadowStaticMethod(Class<?> shadowClass) {
            try {
                // 原始类类名
                String primaryClassName = AnnotationHelper.getAnnotationClassName(shadowClass);
    
                ClassPool cp = ClassPool.getDefault();
    
                // 原始类CtClass
                CtClass cc = cp.get(primaryClassName);
                // Shadow类CtClass
                CtClass shadowCt = cp.get(shadowClass.getName());
    
                CtMethod[] methods = cc.getDeclaredMethods();
    
                for (CtMethod method : methods) {
                    // 仅处理静态方法
                    if (Modifier.isStatic(method.getModifiers())) {
                        // 从Shadow类CtClass获取方法名、参数与原始类一致的CtMethod
                        CtMethod shadowMethod = shadowCt.getDeclaredMethod(method.getName(), method.getParameterTypes());
    
                        if (shadowMethod != null) {
                            String src = getStaticMethodSrc(shadowClass, shadowMethod);
    
                            method.setBody(src);
    
                            // 输出该方法源码
                            System.out.println(src);
                        }
                    }
                }
    
                // 最后让jvm加载一下修改后的类
                Class c = cc.toClass();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
        private static String getStaticMethodSrc(Class<?> shadowClass, CtMethod method) {
    
            StringBuilder sb = new StringBuilder();
            try {
                CtClass returnType = method.getReturnType();
    
                if (!isVoid(returnType)) {
                    sb.append("return ");
                }
    
                sb.append(shadowClass.getName() + "." + method.getName() + "($$);");// $$表示该方法所有参数
            } catch (NotFoundException e) {
                e.printStackTrace();
            }
    
            return sb.toString();
        }
    
        private static boolean isVoid(CtClass returnType) {
    
            if (returnType.equals(CtClass.voidType)) {
                return true;
            }
    
            return false;
        }
    }
    

    单元测试

    public class NetworkUtilsTest {
    
        @Before
        public void setUp() throws Exception {
            // 修改NetworkUtils静态方法字节码,此方法必须在jvm加载NetworkUtils之前调用
            JavassistHelper.callShadowStaticMethod(ShadowNetworkUtils.class);
        }
    
        @Test
        public void testIsConnected() {
            ShadowNetworkUtils.setIsConnected(false);
    
            Assert.assertFalse(NetworkUtils.isConnected());
    
            ShadowNetworkUtils.setIsConnected(true);
    
            Assert.assertTrue(NetworkUtils.isConnected());
        }
    }
    

    单元测试通过,并输出:

    return com.example.robolectric.ShadowNetworkUtils.isConnected($$);

    unit test pass

    输出字符串为修改的静态方法源码。如果是非静态方法,建议用mockito处理。


    写在最后

    笔者写本文的初衷,一来是想摆脱powermockito和robolectric,二来借此研究robolectric shadow实现原理。不料,robolectric不是浪得虚名,shadow机制非常复杂,一时半刻笔者只了解冰山一角,希望有朝一日能弄明白跟大家分享。

    希望本文给大家跟多启发,用javassist在单元测试实现更多功能。


    关于作者

    我是键盘男。

    在广州生活,在互联网体育公司上班,猥琐文艺码农。每天谋划砍死产品经理。喜欢科学、历史,玩玩投资,偶尔旅行。

    相关文章

      网友评论

      • 小夜呆呆:您好,关于Javassist+gradle问个问题,现在我定义了plugin ,加入了transform,遍历了项目里面Jar与文件夹,想利用Javassist更改代码,因为项目模块化的原因,其余的Moudle的也可以在Jar里面找到,想要解压相关的jar,更改代码,重新生成Jar,这时候所有更改的Jar,会报DuplicateFileException这个异常。本来的Jar已经删除了,我现在怀疑是生成Jar的代码有问题,您有什么好的意见吗?
        小夜呆呆: @键盘男 不好意思,病急乱投医了,谢谢您
        键盘男: @小夜呆呆 你的需求恐怕不在本文范围。不过duplicateFileException很可能是因为JVM已经加载过该类了,这时用javassist是修改不了的。我不懂plugin
        小夜呆呆:还有一点,就是如果只有一个Module的话,也就是修改一个jar,是没有问题的,可以正常包的,两个以上才后报这个问题

      本文标题:Robolectric Shadow类实现方式探索

      本文链接:https://www.haomeiwen.com/subject/jyuptxtx.html