單元測試框架 Robolectric 原理分析

溫馨提示:閱讀本文前最好簡單使用過 Robolectric浸踩。

Robolectric 是基于 Junit 的單元測試框架辕漂,實現(xiàn)了在 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 注解瓤球,其余實現(xiàn)和普通 Java 方法一致融欧。

運行方式也很簡單。如果使用的 Android Studio 的話卦羡,只需在 evaluatesExpression 方法上點擊右鍵噪馏,會彈出如下彈窗权她,然后點擊 "Run 'evaluatesExpression'",即可運行逝薪。

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

下面將分析 evaluatesExpression 方法是如何被調(diào)起的隅要。

大體上分三步:
1.查找并創(chuàng)建執(zhí)行主體(Runner)
2.找到具有 @Test 注解的單測方法
3.運行單測方法

1.查找執(zhí)行主體(Runner)

執(zhí)行主體為實現(xiàn)了 Runner 接口的對象。Runner 接口的核心方法為 run 方法董济,其中一個重要的子類為 ParentRunner步清。

查找 Runner 對象的核心代碼在 AllDefaultPossibilitiesBuilder 類里,下面采用偽代碼描述執(zhí)行流程:

// testClass = CalculatorTest.Class
public Runner runnerForClass(Class<?> testClass) throws Throwable {
    if CalculatorTest 存在 @RunWith 注解
        根據(jù)注解內(nèi)容創(chuàng)建 Runner(本例中即為 BlockJUnit4ClassRunner)
    else
        創(chuàng)建 BlockJUnit4ClassRunner
}

BlockJUnit4ClassRunner 屬于 ParentRunner的子類虏肾。

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

第一步創(chuàng)建 Runner 對象時廓啊,在構(gòu)造方法里會傳入 CalculatorTest.Class,然后利用反射封豪,查找標記有 @Test 注解的方法谴轮,并將這些方法保存起來。

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

3.運行單測方法

接下來最后一步吹埠,執(zhí)行 Runner 對象的 run 方法第步。run 方法對 classBlock 方法做了簡單的包裝,核心還是 classBlockmethodBlock 方法缘琅。

簡化版 methodBlock

protected Statement methodBlock(FrameworkMethod method) { // FrameworkMethod 是對 Method 類的包裝
    Object test = createTest() // 創(chuàng)建 CalculatorTest的實例粘都,實現(xiàn)代碼大概是:CalculatorTest.Class.newInstance()
    Statement statement = methodInvoker(method, test); // 調(diào)用 method,實現(xiàn)代碼大概是:method.invoke(test, params)
    return statement;
}

上述執(zhí)行流程為了突出核心流程做了大幅簡化刷袍,關(guān)心具體實現(xiàn)細節(jié)的可以查看源碼翩隧。

通過上述分析,我們了解了 Junit 框架的基本執(zhí)行流程呻纹。如果我們想以 Junit 為基礎(chǔ)實現(xiàn)自己的單元測試框架堆生,只需自定義 Runner 類即可。

三.Robolectric介紹

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

Junit 屬于 JVM 平臺上的單元測試框架雷酪,無法提供 Android 運行時環(huán)境淑仆。如果在單元測試中涉及到 Android 特性,Junit 則無法實現(xiàn)太闺。

通常的做法是啟動 Android 模擬器進行測試糯景。但是在模擬器上運行測試用例是非常低效的,構(gòu)建省骂、安裝蟀淮、啟動,每個步驟都異常耗時钞澳,為了解決這一問題怠惶,Robolectric 通過 mock Android 運行時環(huán)境,使得單元測試可以在 JVM 環(huán)境上運行轧粟。

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源碼分析

本節(jié)的重點是分析 Robolectric 如何 mock Android 運行時環(huán)境的。在此之前通惫,需要先了解下 Java 類加載器 和 ASM或者可以直接跳到 "Robolectric 的實現(xiàn)" 部分茂翔。

1.類加載器

虛擬機設(shè)計團隊把類加載階段中的 "通過一個類的全限定名來獲取描述此類的二進制字節(jié)流" 這個動作放到 Java 虛擬機外部去實現(xiàn),以便讓應(yīng)用程序自己決定如何去獲取所需要的類履腋。實現(xiàn)這個動作的代碼模塊稱為"類加載器"珊燎。

對于任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在 Java 虛擬機中的唯一性遵湖,每一個類加載器悔政,都擁有一個獨立的類名稱空間。

類加載器分為三種:

  • 啟動類加載器
    負責加載 <JAVA_HOME>/lib 目錄下的文件延旧。

  • 擴展類加載器
    負責加載 <JAVA_HOME>/lib/ext 目錄下的文件谋国。

  • 應(yīng)用程序類加載器
    也稱為系統(tǒng)類加載器。開發(fā)者可以直接使用這個類加載器迁沫,默認情況下芦瘾,應(yīng)用程序類都是由這個加載器加載。

如下是類加載器的繼承關(guān)系:

截屏2020-07-05 下午10.15.29.png

應(yīng)用程序類加載器和擴展類加載器的具體實現(xiàn)分別為 AppClassLoader 弯洗、ExtClassLoader 旅急。我們在自定義應(yīng)用程序類加載器時逢勾,可以直接繼承 UrlClassLoader 牡整。

2.ASM

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

ASM 是一個可以分析、操縱 Java 字節(jié)碼的工具溺拱,它可以以二進制形式修改或創(chuàng)建字節(jié)碼逃贝。ASM 的應(yīng)用范圍很廣泛,熱修復(fù)框架 Robust 就有使用其進行插樁迫摔。

3.Robolectric的實現(xiàn)

經(jīng)過前面做的大量鋪墊沐扳,事情逐漸變得明朗起來。

為了 mock Android 運行時環(huán)境句占,我們需要使用自定義 ClassLoader 加載如 Activity沪摄、Fragment 等類,然后在加載過程中使用 ASM 修改字節(jié)碼纱烘,將部分方法的實現(xiàn)替換杨拐。比如將 getTaskId 替換為如下實現(xiàn):

protected int getTaskId() {
  return 0;
}

這里存在兩種替換方案:
1.靜態(tài)替換-直接替換掉 android.jar
2.動態(tài)替換-運行時按需替換
Robolectric 采用的是第二種方案。

實現(xiàn)過程分為兩步擂啥,以 Acivity 為例:

1)替換系統(tǒng)類加載器為自定義類加載器

Robolectric 自定義的類加載器為SandboxClassLoader 哄陶,其繼承自 URLClassLoader

在閱讀這部分代碼時我對如何替換做了兩個猜想:

  • 直接替換系統(tǒng)類加載器
  • 替換上下文類加載器

事實證明自己的猜想都是錯誤的哺壶,一是Java 并沒有提供替換系統(tǒng)類加載器的方法屋吨;二是替換上下文類加載器替換完成后蜒谤,需要顯示使用,否則依然采用的系統(tǒng)類加載器至扰。

那么該如何替換呢鳍徽?
經(jīng)過查閱資料和驗證,從調(diào)用方式上敢课,類加載器分為顯示調(diào)用和隱式調(diào)用兩種旬盯。
顯示調(diào)用是在類加載時直接指明 classLoader,比如下面:

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

沒有指明類加載器時則為隱式調(diào)用翎猛。

隱式調(diào)用有一個重要特點胖翰,即類的所有引入類都會采用同一個類加載器。在下例中切厘,類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();
    }
}

輸出結(jié)果為:
MyClassLoader@355da254
MyClassLoader@355da254

因此,只需在加載單測類(上例中的 CalculatorTest)時疫稿,采用自定義類加載器即可培他。
接下來再回到 Robolectric。Robolectric 實現(xiàn)了自定義的 RobolectricTestRunner 遗座,其繼承關(guān)系如下所示:

截屏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 包中預(yù)定義了許多 Shadow 開頭的類舀凛,比如 ShadowActivityShadowTextView

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

簡單來說途蒋,在 SandboxClassLoaderfindClass方法中猛遍,會去尋找相匹配的 Shadow 類,然后利用 ASM 工具号坡,在加載類時進行字節(jié)碼的動態(tài)替換懊烤。

除了預(yù)定義 Shadow 類,用戶也可以仿照 ShadowActivity 實現(xiàn)自定義 Shadow 類宽堆。

預(yù)定義 Shadow 類和自定義 Shadow 類 的查找方式不同腌紧,預(yù)定義 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 類進行關(guān)聯(lián):

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);
  }
}

總結(jié):
本文只簡單說明了 Robolectric 的核心流程,至于實現(xiàn)細節(jié)籽慢,有興趣的可以通過源碼繼續(xù)鉆研浸遗。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市嗡综,隨后出現(xiàn)的幾起案子乙帮,更是在濱河造成了極大的恐慌,老刑警劉巖极景,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件察净,死亡現(xiàn)場離奇詭異驾茴,居然都是意外死亡,警方通過查閱死者的電腦和手機氢卡,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門锈至,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人译秦,你說我怎么就攤上這事峡捡。” “怎么了筑悴?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵们拙,是天一觀的道長。 經(jīng)常有香客問我阁吝,道長砚婆,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任突勇,我火速辦了婚禮装盯,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘甲馋。我一直安慰自己韵洋,他們只是感情好邑雅,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布浩销。 她就那樣靜靜地躺著购披,像睡著了一般。 火紅的嫁衣襯著肌膚如雪共屈。 梳的紋絲不亂的頭發(fā)上绑谣,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天,我揣著相機與錄音拗引,去河邊找鬼。 笑死幌衣,一個胖子當著我的面吹牛矾削,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播豁护,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼哼凯,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了楚里?” 一聲冷哼從身側(cè)響起断部,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎班缎,沒想到半個月后蝴光,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體她渴,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年蔑祟,在試婚紗的時候發(fā)現(xiàn)自己被綠了趁耗。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡疆虚,死狀恐怖苛败,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情径簿,我是刑警寧澤罢屈,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站篇亭,受9級特大地震影響儡遮,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜暗赶,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一鄙币、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧蹂随,春花似錦十嘿、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至激率,卻和暖如春咳燕,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背乒躺。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工招盲, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人嘉冒。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓曹货,卻偏偏與公主長得像,于是被迫代替她去往敵國和親讳推。 傳聞我的和親對象是個殘疾皇子顶籽,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355