Android單元測試之Robolectric

前言

在博客Android單元測試之PowerMockito,主要介紹PowerMockito的使用和對Java測試用例的強大支持。但對于Android app開發來說,寫起單元測試很痛苦:一方面單元測試需要運行在模擬器上或者真機上,不僅麻煩而且緩慢;另一方面,一些依賴Android SDK的對象(如Activity,Button等)的測試非常頭疼。Robolectric可以解決此類問題,它的設計思路便是通過實現一套JVM能運行的Android代碼,從而做到脫離Android環境進行測試。本文將結合項目對Robolectric做一個簡單介紹,并列舉在實踐踩的各種坑。

Robolectric簡介

我們可以使用Android提供的Instrumentation系統如ActivityUnitTestCase、ActivityInstrumentationTestCase2,將單元測試代碼運行在模擬器或者是真機上。雖然這種方式可以work,但是速度非常慢,因為每次運行一次單元測試,都需要將整個項目打包成apk,上傳到模擬器或真機上,就跟運行了一次app似得,這個顯然不是單元測試該有的速度。此外,Google開源的測試框架如UIAutomatorEspresso也是基于Instrumentation的,更偏向于UI方面的自測化測試,要是應用在單元測試上速度也是不敢恭維的。

對了,說一句題外話,感興趣的同學可以看一下ActivityUnitTestCase和ActivityInstrumentationTestCase2的源碼,你會驚奇地發現,它們的實現方式還是有所區別,雖然都是依賴Instrumentation把Activity加載起來,運行在同一個進程中,但ActivityUnitTestCase是運行在UI主線程中的,而ActivityInstrumentationTestCase2是運行在子線程中的,所以在實際的使用中還是有區別的,ActivityUnitTestCase可以直接操控UI,而ActivityInstrumentationTestCase2則是不行,需要借助于runOnUiThread()方法來更新UI,否則會拋異常。

言歸正傳吧,我們還是接著說Robolectric。Robolectric通過實現一套JVM能運行的Android代碼,然后在unit test運行的時候去截取android相關的代碼調用,然后轉到自己實現的代碼去執行這個調用的過程。舉個例子說明一下,比如Android里面有個類叫Button,Robolectric則實現了一個叫ShadowButton類。這個類基本上實現了Button的所有公共接口。假設你在unit test里面寫到String text = button.getText().toString();,在這個unit test運行時,Robolectric會自動判斷你調用了Android相關的代碼button.getText(),在底層截取這個調用過程,轉到ShadowButton的getText方法來執行。而ShadowButton是真正實現了getText這個方法的,所以這個過程便可以正常執行。

除了實現Android里面的類的現有接口,Robolectric還做了另外一件事情,極大地方便了unit testing的工作。那就是他們給每個Shadow類額外增加了很多接口,方便我們讀取對應Android類的一些狀態。比如ImageView有一個方法叫setImageResource(resourceId),然而并沒有一個對應的getter方法叫getImageResourceId(),這樣你是沒有辦法測試這個ImageView是不是顯示了你想要的image。而在Robolectric實現的ShadowImageView里面,則提供了getImageResourceId()這個接口,你可以用來測試它是否正確的顯示了你想要的image。

Robolectric入門

build.gradle配置:
dependencies {
    testCompile "org.robolectric:robolectric:3.3.2"
}
注解配置:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 23)
public class ExampleRobolectricTestCase {
    ......
}

說明:上面配置的是RobolectricTestRunner,而不是RobolectricGradleTestRunner,在Robolectric之前的版本是有這個RobolectricGradleTestRunner,但在最新的版本上卻沒有了,也不知道是為什么。但是有一點,使用最新版本后,倒是沒有出現找不到資源文件res的警告。最新的Robolectric最高可支持Android API 23。

Android Studio環境配置:

1.在Build Variants面板中,將Test Artifact切換成Unit Tests模式,不過在新版本的Android Studio已經不需要做這項配置,如下圖:

Test Artifact.png

2.Working directory設置
如果在運行測試方法過程中遇見如下異常:

java.io.FileNotFoundException: build\intermediates\bundles\debug\AndroidManifest.xml
......

或者如下警告:

No such manifest file: build/intermediates/bundles/debug/AndroidManifest.xml
......

解決的方式就是將Working directory的值設置為$MODULE_DIR$。

第一步設置如下:


Edit Configurations.png

第二步設置如下:


Run/Debug Configurations.png

設置完畢后,再次run就可以了。

Robolectric實戰

首先在build.gradle中的完整配置如下:
    testCompile "junit:junit:4.12"
    testCompile "org.assertj:assertj-core:1.7.0"
    testCompile "org.robolectric:robolectric:3.3.2"

    // PowerMock brings in the mockito dependency
    testCompile 'org.powermock:powermock-module-junit4:1.6.5'
    testCompile 'org.powermock:powermock-module-junit4-rule:1.6.5'
    testCompile 'org.powermock:powermock-api-mockito:1.6.5'
    testCompile 'org.powermock:powermock-classloading-xstream:1.6.5'

從配置中,可以看出在實際運用中,我們是使用JUnit4+Mockito+PowerMockito+Robolectric,這是一個牛逼的組合,在寫單元測試用例時簡直溜得飛起,通過PowerMockito彌補Mockito測試框架不能mock靜態方法、final方法和private方法的不足,還可以在JVM中就可以很方便的調用Android相關的類和方法,速度也比較快。

然后定義抽象類BaseRobolectricTestCase:
@RunWith(RobolectricTestRunner.class)
@Config(shadows = {ShadowLog.class}, constants = BuildConfig.class, sdk = 23)
@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*", "org.json.*", "sun.security.*", "javax.net.*"})
public abstract class BaseRobolectricTestCase {

    @Rule
    public PowerMockRule rule = new PowerMockRule();

    private static boolean hasInited = false;

    @Before
    public void setUp() {
        ShadowLog.stream = System.out;
        if (!hasInited) {
            initRxJava();
            hasInited = true;
        }
        MockitoAnnotations.initMocks(this);
    }

    public Application getApplication() {
        return RuntimeEnvironment.application;
    }

    public Context getContext() {
        return RuntimeEnvironment.application;
    }

    private void initRxJava() {

        RxJavaPlugins.getInstance().registerSchedulersHook(new RxJavaSchedulersHook() {
            @Override
            public Scheduler getIOScheduler() {
                return Schedulers.immediate();
            }
        });
        RxAndroidPlugins.getInstance().registerSchedulersHook(new RxAndroidSchedulersHook() {
            @Override
            public Scheduler getMainThreadScheduler() {
                return Schedulers.immediate();
            }
        });
    }

}

這個抽象類代碼比較多,主要是設置Robolectric單元測試的運行環境,方便在單元測試用例代碼中進行復用。具體分下一下:

  1. @RunWith(RobolectricTestRunner.class)通過注解定義Robolectric運行的TestRunner;
  2. @Config(shadows = {ShadowLog.class}, constants = BuildConfig.class, sdk = 23)通過配置shadows = {ShadowLog.class}ShadowLog.stream = System.out;來設置Android log輸出方式,使得單元測試運行時在控制臺中可以看到Android代碼中打印出的log日志;
  3. @PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*", "org.json.*", "sun.security.*", "javax.net.*"})通過PowerMockIgnore注解定義所忽略的package路勁,防止所定義的package路徑下的class類被PowerMockito測試框架mock;
  4. 在setUp()方法中調用MockitoAnnotations.initMocks(this);初始化PowerMockito注解,為@PrepareForTest(YourStaticClass.class)注解提供支持;
  5. 在代碼中,我們可以看到定義了兩個基本方法getApplication()和getContext(),在寫測試代碼中使用起來很方便,就像在Activity一樣,增加測試的可讀性;
  6. 如果項目中使用了rxjava框架,在對rxjava相關的代碼進行單元測試時,通過initRxJava()方法將異步處理轉化為同步處理,如此一來方便單元測試驗證;
最后編寫Activity測試用例代碼:
public class ComplaintActivityTest extends BaseRobolectricTestCase {

    @Test
    @PrepareForTest({AppUtil.class, OAuthManager.class, NetUtil.class})
    public void jumpCompensate() throws Exception {
        PowerMockito.mockStatic(AppUtil.class);
        PowerMockito.when(AppUtil.getVersionName()).thenReturn("1.4.0");

        PowerMockito.mockStatic(OAuthManager.class);
        OAuthManager mockOAuth = PowerMockito.mock(OAuthManager.class);
        PowerMockito.when(OAuthManager.getInstance()).thenReturn(mockOAuth);
        PowerMockito.when(mockOAuth.getSargerasToken()).thenReturn("c97faa92-34ea-4248-a19e-9a9fb848b29b");

        AppApplication.mInstance = getApplication();

        PowerMockito.mockStatic(NetUtil.class);
        PowerMockito.when(NetUtil.isNetworkConnected(AppApplication.getInstance())).thenReturn(true);

        PreferenceUtil.init();
        PersistentPreferenceUtil.init();

        ComplaintActivity complaintActivity = Robolectric.buildActivity(ComplaintActivity.class).create().get();
        assertNotNull(complaintActivity);
        complaintActivity.jumpCompensate();
        Intent expectedIntent = new Intent(complaintActivity, HelpActivity.class);
        ShadowActivity shadowActivity = Shadows.shadowOf(complaintActivity);
        Intent actualIntent = shadowActivity.getNextStartedActivity();
        Assert.assertEquals(expectedIntent.getComponent().getClassName(), actualIntent.getComponent().getClassName());
    }

}

上面前一部分代碼主要設置ComplaintActivity運行所依賴的屬性,這也是在單元測試最為繁瑣的地方,因為不是運行在真實的Android環境中。具體分析如下:

  1. 通過注解@PrepareForTest({AppUtil.class, OAuthManager.class, NetUtil.class})定義PowerMockito要mock的類;
  2. 在Robolectric中讀取不到apk的版本號,通過PowerMockito.when(AppUtil.getVersionName()).thenReturn("1.4.0");mock指定AppUtil.getVersionName()的返回值"1.4.0",即版本號;
  3. 通過AppApplication.mInstance = getApplication();使用Robolectric運行環境中的application對AppApplication.mInstance進行依賴注入,因為在很多類中都會用到AppApplication.mInstance進行初始化,例如SharedPreference、SQlite、單例類等,
PreferenceUtil.init();
PersistentPreferenceUtil.init();

上面代碼就需要依賴AppApplication.mInstance進行初始化;

  1. ComplaintActivity complaintActivity = Robolectric.buildActivity(ComplaintActivity.class).create().get();使用Robolectric創建ComplaintActivity對象,其中create()方法就是對應于調用Activity生命周期的onCreate()方法,此外Robolectric支持鏈式調用如:Robolectric.buildActivity(ComplaintActivity.class).create().resume().get();
  2. assertNotNull(complaintActivity);驗證complaintActivity是否跑起來;
  3. 最后一部分代碼就是調用jumpCompensate方法進行跳轉,驗證跳轉的Intent是否符合預期;

至于其他的一些如Fragment、Dialog、Toast等驗證,可以參考這篇博客,這里就不展開。

Robolectric常見的坑

1.Application空指針問題

這是因為SharedPreferences和單例等類初始化時需要依賴Application對象,我們常見的用法是使用Application.getApplication()方法來獲取,在Robolectric中則是需要使用RuntimeEnvironment.application來進行替換,上面就是通過依賴的方式進行替換。

2. AppCompatActivity錯誤

假如你在Robolectric的@Config注解中配置了manifest = Config.NONE,那就完蛋了,因為在網上根本找不解決的方法,你遇到如下異常不能使用support V7包的類:

java.lang.IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity.

     at android.support.v7.app.AppCompatDelegateImplV7.createSubDecor(AppCompatDelegateImplV7.java:343)
     at android.support.v7.app.AppCompatDelegateImplV7.ensureSubDecor(AppCompatDelegateImplV7.java:312)
     at android.support.v7.app.AppCompatDelegateImplV7.initWindowDecorActionBar(AppCompatDelegateImplV7.java:172)
     at android.support.v7.app.AppCompatDelegateImplBase.getSupportActionBar(AppCompatDelegateImplBase.java:88)
     at android.support.v7.app.AppCompatActivity.getSupportActionBar(AppCompatActivity.java:110)
     at me.ele.shopcenter.components.BaseActivity.initActionBar(BaseActivity.java:104)
     at me.ele.shopcenter.components.BaseActivity.onCreate(BaseActivity.java:52)
     at me.ele.shopcenter.ui.order.ComplaintActivity.onCreate(ComplaintActivity.java:93)
     at android.app.Activity.performCreate(Activity.java:6251)
     at org.robolectric.util.ReflectionHelpers.callInstanceMethod(ReflectionHelpers.java:231)

解決的方式就是去掉manifest = Config.NONE配置,這是坑爹的,我就遇到這個錯誤,花了好長一段時間才發現是這個配置導致的。

3.Asset文件路徑錯誤

需要用到context.getAssets().open("XXX")加載asset目錄下的文件時,要是遇到以下錯誤:

java.io.FileNotFoundException: build/intermediates/bundles/debug/assets/https.cer (No such file or directory)
    at java.io.FileInputStream.open0(Native Method)
    at java.io.FileInputStream.open(FileInputStream.java:195)
    at java.io.FileInputStream.<init>(FileInputStream.java:138)
    at org.robolectric.res.FileFsFile.getInputStream(FileFsFile.java:84)
    at org.robolectric.shadows.ShadowAssetManager.open(ShadowAssetManager.java:319)
    at android.content.res.AssetManager.open(AssetManager.java)

解決方式是,不要用AssetManager來加載文件,而是自己使用Java API來加載文件,如:

new FileInputStream(new File("/Users/michaelzhong/Desktop/shop/talaris_shop_center/app/src/main/assets/https.cer"));

這個方式有點丑,需要用到你要加載的文件的絕對路徑,靈活性低,不方便移植,不過這是我目前想到的解決方式。

4.找不到android.net.http.AndroidHttpClient的類文件

在Android API23開始,google就移除了HttpClient相關的類,有兩種方法解決上述問題。
方法一:在build.gradle添加應用useLibrary ‘org.apache.http.legacy’
方法二:在test目錄下添加HttpClient類(記得包名為android.net.http),如下:


AndroidHttpClient.png

說明:推薦使用第二種方式,第二種方法正式打包并不會把HttpClient的類加入,減少了包中無用的資源。

小結

在實際的使用中,Robolectric需要踩很多坑的,不過貴在嘗試。至此,單元測試系列博客已經完結,主要分了四篇博客來講述。非常感謝您對本篇博客的支持,要是有什么不足歡迎指正!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,882評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,208評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,746評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,666評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,477評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,960評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,047評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,200評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,726評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,617評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,807評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,327評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,049評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,425評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,674評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,432評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,769評論 2 372

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,608評論 25 708
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,781評論 18 139
  • 背景 Mock、PowerMock、Junit等都只是在java層面的單元測試。但對于android app開發來...
    johnnycmj閱讀 2,965評論 1 2
  • 我是可諾媽,養育一對可愛雙胞胎兄妹,這本書從懷孕開始就讀了,斷斷續續到今天兩娃娃2周歲,當雙胞胎兄妹出生頭一年,我...
    繪愛正面管教閱讀 2,360評論 1 2
  • 一:簡介 Retrofit是Square公司開發的一款針對Android網絡請求的框架,Retrofit2底層基于...
    往事一塊六毛八閱讀 536評論 0 0