单元测试框架 Robolectric 原理分析
温馨提示:阅读本文前最好简单使用过 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'",即可运行。
下面将分析 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
方法做了简单的包装,核心还是 classBlock
和methodBlock
方法。
简化版 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 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
类加载器分为三种:
-
启动类加载器
负责加载 <JAVA_HOME>/lib 目录下的文件。 -
扩展类加载器
负责加载 <JAVA_HOME>/lib/ext 目录下的文件。 -
应用程序类加载器
也称为系统类加载器。开发者可以直接使用这个类加载器,默认情况下,应用程序类都是由这个加载器加载。
如下是类加载器的继承关系:
应用程序类加载器和扩展类加载器的具体实现分别为
AppClassLoader
、ExtClassLoader
。我们在自定义应用程序类加载器时,可以直接继承 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
,其继承关系如下所示:
Robolectric 在
SandboxTestRunner
的 methodBlock
方法中进行了类加载器的替换:
// 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 开头的类,比如 ShadowActivity
、ShadowTextView
:
@Implements(Activity.class)
public class ShadowActivity extends ShadowContextThemeWrapper {
// 省略了其他大部分内容
@Implementation
protected int getTaskId() {
return 0;
}
}
简单来说,在 SandboxClassLoader
的 findClass
方法中,会去寻找相匹配的 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 的核心流程,至于实现细节,有兴趣的可以通过源码继续钻研。