前言
在博客Android單元測試之PowerMockito,主要介紹PowerMockito的使用和對Java測試用例的強大支持。但對于Android app開發來說,寫起單元測試很痛苦:一方面單元測試需要運行在模擬器上或者真機上,不僅麻煩而且緩慢;另一方面,一些依賴Android SDK的對象(如Activity,Button等)的測試非常頭疼。Robolectric可以解決此類問題,它的設計思路便是通過實現一套JVM能運行的Android代碼,從而做到脫離Android環境進行測試。本文將結合項目對Robolectric做一個簡單介紹,并列舉在實踐踩的各種坑。
Robolectric簡介
我們可以使用Android提供的Instrumentation系統如ActivityUnitTestCase、ActivityInstrumentationTestCase2,將單元測試代碼運行在模擬器或者是真機上。雖然這種方式可以work,但是速度非常慢,因為每次運行一次單元測試,都需要將整個項目打包成apk,上傳到模擬器或真機上,就跟運行了一次app似得,這個顯然不是單元測試該有的速度。此外,Google開源的測試框架如UIAutomator和Espresso也是基于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已經不需要做這項配置,如下圖:
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$。
第一步設置如下:
第二步設置如下:
設置完畢后,再次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單元測試的運行環境,方便在單元測試用例代碼中進行復用。具體分下一下:
-
@RunWith(RobolectricTestRunner.class)
通過注解定義Robolectric運行的TestRunner; -
@Config(shadows = {ShadowLog.class}, constants = BuildConfig.class, sdk = 23)
通過配置shadows = {ShadowLog.class}
和ShadowLog.stream = System.out;
來設置Android log輸出方式,使得單元測試運行時在控制臺中可以看到Android代碼中打印出的log日志; -
@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*", "org.json.*", "sun.security.*", "javax.net.*"})
通過PowerMockIgnore注解定義所忽略的package路勁,防止所定義的package路徑下的class類被PowerMockito測試框架mock; - 在setUp()方法中調用
MockitoAnnotations.initMocks(this);
初始化PowerMockito注解,為@PrepareForTest(YourStaticClass.class)注解提供支持; - 在代碼中,我們可以看到定義了兩個基本方法getApplication()和getContext(),在寫測試代碼中使用起來很方便,就像在Activity一樣,增加測試的可讀性;
- 如果項目中使用了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環境中。具體分析如下:
- 通過注解
@PrepareForTest({AppUtil.class, OAuthManager.class, NetUtil.class})
定義PowerMockito要mock的類; - 在Robolectric中讀取不到apk的版本號,通過
PowerMockito.when(AppUtil.getVersionName()).thenReturn("1.4.0");
mock指定AppUtil.getVersionName()
的返回值"1.4.0",即版本號; - 通過
AppApplication.mInstance = getApplication();
使用Robolectric運行環境中的application對AppApplication.mInstance進行依賴注入,因為在很多類中都會用到AppApplication.mInstance進行初始化,例如SharedPreference、SQlite、單例類等,
PreferenceUtil.init();
PersistentPreferenceUtil.init();
上面代碼就需要依賴AppApplication.mInstance進行初始化;
-
ComplaintActivity complaintActivity = Robolectric.buildActivity(ComplaintActivity.class).create().get();
使用Robolectric創建ComplaintActivity對象,其中create()方法就是對應于調用Activity生命周期的onCreate()方法,此外Robolectric支持鏈式調用如:Robolectric.buildActivity(ComplaintActivity.class).create().resume().get();
; -
assertNotNull(complaintActivity);
驗證complaintActivity是否跑起來; - 最后一部分代碼就是調用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),如下:
說明:推薦使用第二種方式,第二種方法正式打包并不會把HttpClient的類加入,減少了包中無用的資源。
小結
在實際的使用中,Robolectric需要踩很多坑的,不過貴在嘗試。至此,單元測試系列博客已經完結,主要分了四篇博客來講述。非常感謝您對本篇博客的支持,要是有什么不足歡迎指正!