单元测试框架 Robolectric 原理分析

2020-07-07  本文已影响0人  请输入妮称

温馨提示:阅读本文前最好简单使用过 Robolectric。

Robolectric 是基于 Junit 的单元测试框架,实现了在 JVM 上测试 Android 代码的功能。在介绍 Robolectric 前有必要先简单介绍下Junit。

一.Junit介绍

Junit 是 Java 语言的单元测试框架,理论上基于 JVM 的语言都可以使用。本文基于 Junit 4 的源码进行分析,目前最新版本为 Junit 5。

二.Junit源码分析

单元测试的用法很简单。下面以 Calculator 类为例,为其中的 evaluate 方法编写单元测试:

import static org.junit.Assert.assertEquals;
import org.junit.Test;

@RunWith(BlockJUnit4ClassRunner.class)
public class CalculatorTest {
  @Test
  public void evaluatesExpression() {
    Calculator calculator = new Calculator();
    int sum = calculator.evaluate("1+2+3");
    assertEquals(6, sum);
  }
}

可以看到除了 @RunWith(BlockJUnit4ClassRunner.class)@Test 注解,其余实现和普通 Java 方法一致。

运行方式也很简单。如果使用的 Android Studio 的话,只需在 evaluatesExpression 方法上点击右键,会弹出如下弹窗,然后点击 "Run 'evaluatesExpression'",即可运行。

截屏2020-07-04 下午11.52.17.png

下面将分析 evaluatesExpression 方法是如何被调起的。

大体上分三步:
1.查找并创建执行主体(Runner)
2.找到具有 @Test 注解的单测方法
3.运行单测方法

1.查找执行主体(Runner)

执行主体为实现了 Runner 接口的对象。Runner 接口的核心方法为 run 方法,其中一个重要的子类为 ParentRunner

查找 Runner 对象的核心代码在 AllDefaultPossibilitiesBuilder 类里,下面采用伪代码描述执行流程:

// testClass = CalculatorTest.Class
public Runner runnerForClass(Class<?> testClass) throws Throwable {
    if CalculatorTest 存在 @RunWith 注解
        根据注解内容创建 Runner(本例中即为 BlockJUnit4ClassRunner)
    else
        创建 BlockJUnit4ClassRunner
}

BlockJUnit4ClassRunner 属于 ParentRunner的子类。

2.找到具有 @Test 注解的方法

第一步创建 Runner 对象时,在构造方法里会传入 CalculatorTest.Class,然后利用反射,查找标记有 @Test 注解的方法,并将这些方法保存起来。

protected void scanAnnotatedMembers() {
    for (Class<?> eachClass : getSuperClasses(clazz)) {
        for (Method eachMethod : MethodSorter.getDeclaredMethods(eachClass)) {
            addToAnnotationLists(new FrameworkMethod(eachMethod), methodsForAnnotations);
        }
    }
}

3.运行单测方法

接下来最后一步,执行 Runner 对象的 run 方法。run 方法对 classBlock 方法做了简单的包装,核心还是 classBlockmethodBlock 方法。

简化版 methodBlock

protected Statement methodBlock(FrameworkMethod method) { // FrameworkMethod 是对 Method 类的包装
    Object test = createTest() // 创建 CalculatorTest的实例,实现代码大概是:CalculatorTest.Class.newInstance()
    Statement statement = methodInvoker(method, test); // 调用 method,实现代码大概是:method.invoke(test, params)
    return statement;
}

上述执行流程为了突出核心流程做了大幅简化,关心具体实现细节的可以查看源码。

通过上述分析,我们了解了 Junit 框架的基本执行流程。如果我们想以 Junit 为基础实现自己的单元测试框架,只需自定义 Runner 类即可。

三.Robolectric介绍

官方文档:http://robolectric.org
github地址:https://github.com/robolectric/robolectric

Junit 属于 JVM 平台上的单元测试框架,无法提供 Android 运行时环境。如果在单元测试中涉及到 Android 特性,Junit 则无法实现。

通常的做法是启动 Android 模拟器进行测试。但是在模拟器上运行测试用例是非常低效的,构建、安装、启动,每个步骤都异常耗时,为了解决这一问题,Robolectric 通过 mock Android 运行时环境,使得单元测试可以在 JVM 环境上运行。

Robolectric 的使用方式如下:

import static org.junit.Assert.assertEquals;
import org.junit.Test;

@RunWith(RobolectricTestRunner.class)
public class CalculatorTest {
  @Test
  public void evaluatesExpression() {
    Calculator calculator = new Calculator();
    int sum = calculator.evaluate("1+2+3");
    assertEquals(6, sum);
  }
}

依然以 CalculatorTest 为例,只是将注解替换为了 @RunWith(RobolectricTestRunner.class)

四.Robolectric源码分析

本节的重点是分析 Robolectric 如何 mock Android 运行时环境的。在此之前,需要先了解下 Java 类加载器 和 ASM或者可以直接跳到 "Robolectric 的实现" 部分。

1.类加载器

虚拟机设计团队把类加载阶段中的 "通过一个类的全限定名来获取描述此类的二进制字节流" 这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为"类加载器"。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

类加载器分为三种:

如下是类加载器的继承关系:

截屏2020-07-05 下午10.15.29.png
应用程序类加载器和扩展类加载器的具体实现分别为 AppClassLoaderExtClassLoader 。我们在自定义应用程序类加载器时,可以直接继承 UrlClassLoader

2.ASM

官方文档:https://asm.ow2.io/

ASM 是一个可以分析、操纵 Java 字节码的工具,它可以以二进制形式修改或创建字节码。ASM 的应用范围很广泛,热修复框架 Robust 就有使用其进行插桩。

3.Robolectric的实现

经过前面做的大量铺垫,事情逐渐变得明朗起来。

为了 mock Android 运行时环境,我们需要使用自定义 ClassLoader 加载如 Activity、Fragment 等类,然后在加载过程中使用 ASM 修改字节码,将部分方法的实现替换。比如将 getTaskId 替换为如下实现:

protected int getTaskId() {
  return 0;
}

这里存在两种替换方案:
1.静态替换-直接替换掉 android.jar
2.动态替换-运行时按需替换
Robolectric 采用的是第二种方案。

实现过程分为两步,以 Acivity 为例:

1)替换系统类加载器为自定义类加载器

Robolectric 自定义的类加载器为SandboxClassLoader ,其继承自 URLClassLoader

在阅读这部分代码时我对如何替换做了两个猜想:

事实证明自己的猜想都是错误的,一是Java 并没有提供替换系统类加载器的方法;二是替换上下文类加载器替换完成后,需要显示使用,否则依然采用的系统类加载器。

那么该如何替换呢?
经过查阅资料和验证,从调用方式上,类加载器分为显示调用和隐式调用两种。
显示调用是在类加载时直接指明 classLoader,比如下面:

Class.forName("Activity", true, MyClassLoader())

没有指明类加载器时则为隐式调用。

隐式调用有一个重要特点,即类的所有引入类都会采用同一个类加载器。在下例中,类A 采用 MyClassLoader 加载,那么类 B 使用的也是 MyClassLoader

public class A {
    public A() {
        System.out.println(getClass().getClassLoader());
        System.out.println(B.class.getClassLoader());
    }
}

public class Main {
    public static void main(String[] args) throws Exception{
        Class.forName("A", true, new MyClassLoader()).newInstance();
    }
}

输出结果为:
MyClassLoader@355da254
MyClassLoader@355da254

因此,只需在加载单测类(上例中的 CalculatorTest)时,采用自定义类加载器即可。
接下来再回到 Robolectric。Robolectric 实现了自定义的 RobolectricTestRunner ,其继承关系如下所示:

截屏2020-07-05 下午10.27.25.png
Robolectric 在 SandboxTestRunnermethodBlock 方法中进行了类加载器的替换:
// getTestClass().getJavaClass() 作用是获取 CalculatorTest 的 Class 对象
Class bootstrappedTestClass = bootstrappedClass(getTestClass().getJavaClass());
public <T> Class<T> bootstrappedClass(Class<?> clazz) {
    try {
    return (Class<T>) sandboxClassLoader.loadClass(clazz.getName());
    } catch (ClassNotFoundException e) {
    throw new RuntimeException(e);
    }
}

2)查找 Acivity 类的替换类

Robolectric 在 org.robolectric.shadows 包中预定义了许多 Shadow 开头的类,比如 ShadowActivityShadowTextView

@Implements(Activity.class)
public class ShadowActivity extends ShadowContextThemeWrapper {
  // 省略了其他大部分内容
    @Implementation
  protected int getTaskId() {
    return 0;
  }
}

简单来说,在 SandboxClassLoaderfindClass方法中,会去寻找相匹配的 Shadow 类,然后利用 ASM 工具,在加载类时进行字节码的动态替换。

除了预定义 Shadow 类,用户也可以仿照 ShadowActivity 实现自定义 Shadow 类。

预定义 Shadow 类和自定义 Shadow 类 的查找方式不同,预定义 Shadow 类在初始化时,将其存储在了 Map 中:

public class Shadows implements ShadowProvider {
  private static final Map<String, String> SHADOW_MAP = new HashMap<>(391);

  static {
    SHADOW_MAP.put("android.widget.AbsListView", "org.robolectric.shadows.ShadowAbsListView");
    SHADOW_MAP.put("android.widget.AbsSeekBar", "org.robolectric.shadows.ShadowAbsSeekBar");
    SHADOW_MAP.put("android.widget.AbsSpinner", "org.robolectric.shadows.ShadowAbsSpinner");
    SHADOW_MAP.put("android.database.AbstractCursor", "org.robolectric.shadows.ShadowAbstractCursor");
    SHADOW_MAP.put("android.accessibilityservice.AccessibilityButtonController", "org.robolectric.shadows.ShadowAccessibilityButtonController");
    SHADOW_MAP.put("android.view.accessibility.AccessibilityManager", "org.robolectric.shadows.ShadowAccessibilityManager");
    SHADOW_MAP.put("android.view.accessibility.AccessibilityNodeInfo", "org.robolectric.shadows.ShadowAccessibilityNodeInfo");
    SHADOW_MAP.put("android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction", "org.robolectric.shadows.ShadowAccessibilityNodeInfo$ShadowAccessibilityAction");
    SHADOW_MAP.put("android.view.accessibility.AccessibilityRecord", "org.robolectric.shadows.ShadowAccessibilityRecord");
    SHADOW_MAP.put("android.accessibilityservice.AccessibilityService", "org.robolectric.shadows.ShadowAccessibilityService");
    SHADOW_MAP.put("android.view.accessibility.AccessibilityWindowInfo", "org.robolectric.shadows.ShadowAccessibilityWindowInfo");
    ......

自定义 Shadow 类需要在 @Config 注解中显示声明,这样可以通过读取注解中的 shadows 值 ,将原类和 Shadow 类进行关联:

import static org.junit.Assert.assertEquals;
import org.junit.Test;

@Config(shadows = {MyShadowTextView.class})
@RunWith(RobolectricTestRunner.class)
public class CalculatorTest {
  @Test
  public void evaluatesExpression() {
    Calculator calculator = new Calculator();
    int sum = calculator.evaluate("1+2+3");
    assertEquals(6, sum);
  }
}

总结:
本文只简单说明了 Robolectric 的核心流程,至于实现细节,有兴趣的可以通过源码继续钻研。

上一篇下一篇

猜你喜欢

热点阅读