前言
在博客Android單元測(cè)試之PowerMockito斟珊,主要介紹PowerMockito的使用和對(duì)Java測(cè)試用例的強(qiáng)大支持。但對(duì)于Android app開發(fā)來說,寫起單元測(cè)試很痛苦:一方面單元測(cè)試需要運(yùn)行在模擬器上或者真機(jī)上,不僅麻煩而且緩慢;另一方面菱农,一些依賴Android SDK的對(duì)象(如Activity,Button等)的測(cè)試非常頭疼柿估。Robolectric可以解決此類問題循未,它的設(shè)計(jì)思路便是通過實(shí)現(xiàn)一套JVM能運(yùn)行的Android代碼,從而做到脫離Android環(huán)境進(jìn)行測(cè)試秫舌。本文將結(jié)合項(xiàng)目對(duì)Robolectric做一個(gè)簡(jiǎn)單介紹的妖,并列舉在實(shí)踐踩的各種坑。
Robolectric簡(jiǎn)介
我們可以使用Android提供的Instrumentation系統(tǒng)如ActivityUnitTestCase足陨、ActivityInstrumentationTestCase2嫂粟,將單元測(cè)試代碼運(yùn)行在模擬器或者是真機(jī)上。雖然這種方式可以work墨缘,但是速度非常慢星虹,因?yàn)槊看芜\(yùn)行一次單元測(cè)試,都需要將整個(gè)項(xiàng)目打包成apk镊讼,上傳到模擬器或真機(jī)上宽涌,就跟運(yùn)行了一次app似得,這個(gè)顯然不是單元測(cè)試該有的速度蝶棋。此外卸亮,Google開源的測(cè)試框架如UIAutomator和Espresso也是基于Instrumentation的,更偏向于UI方面的自測(cè)化測(cè)試玩裙,要是應(yīng)用在單元測(cè)試上速度也是不敢恭維的兼贸。
對(duì)了段直,說一句題外話,感興趣的同學(xué)可以看一下ActivityUnitTestCase和ActivityInstrumentationTestCase2的源碼寝受,你會(huì)驚奇地發(fā)現(xiàn)坷牛,它們的實(shí)現(xiàn)方式還是有所區(qū)別罕偎,雖然都是依賴Instrumentation把Activity加載起來很澄,運(yùn)行在同一個(gè)進(jìn)程中,但ActivityUnitTestCase是運(yùn)行在UI主線程中的颜及,而ActivityInstrumentationTestCase2是運(yùn)行在子線程中的甩苛,所以在實(shí)際的使用中還是有區(qū)別的,ActivityUnitTestCase可以直接操控UI俏站,而ActivityInstrumentationTestCase2則是不行讯蒲,需要借助于runOnUiThread()方法來更新UI,否則會(huì)拋異常肄扎。
言歸正傳吧墨林,我們還是接著說Robolectric。Robolectric通過實(shí)現(xiàn)一套JVM能運(yùn)行的Android代碼犯祠,然后在unit test運(yùn)行的時(shí)候去截取android相關(guān)的代碼調(diào)用旭等,然后轉(zhuǎn)到自己實(shí)現(xiàn)的代碼去執(zhí)行這個(gè)調(diào)用的過程。舉個(gè)例子說明一下衡载,比如Android里面有個(gè)類叫Button搔耕,Robolectric則實(shí)現(xiàn)了一個(gè)叫ShadowButton類。這個(gè)類基本上實(shí)現(xiàn)了Button的所有公共接口痰娱。假設(shè)你在unit test里面寫到String text = button.getText().toString();
弃榨,在這個(gè)unit test運(yùn)行時(shí),Robolectric會(huì)自動(dòng)判斷你調(diào)用了Android相關(guān)的代碼button.getText()
梨睁,在底層截取這個(gè)調(diào)用過程鲸睛,轉(zhuǎn)到ShadowButton的getText方法來執(zhí)行。而ShadowButton是真正實(shí)現(xiàn)了getText這個(gè)方法的坡贺,所以這個(gè)過程便可以正常執(zhí)行官辈。
除了實(shí)現(xiàn)Android里面的類的現(xiàn)有接口,Robolectric還做了另外一件事情拴念,極大地方便了unit testing的工作钧萍。那就是他們給每個(gè)Shadow類額外增加了很多接口,方便我們讀取對(duì)應(yīng)Android類的一些狀態(tài)政鼠。比如ImageView有一個(gè)方法叫setImageResource(resourceId)
风瘦,然而并沒有一個(gè)對(duì)應(yīng)的getter方法叫g(shù)etImageResourceId(),這樣你是沒有辦法測(cè)試這個(gè)ImageView是不是顯示了你想要的image公般。而在Robolectric實(shí)現(xiàn)的ShadowImageView里面万搔,則提供了getImageResourceId()
這個(gè)接口胡桨,你可以用來測(cè)試它是否正確的顯示了你想要的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之前的版本是有這個(gè)RobolectricGradleTestRunner,但在最新的版本上卻沒有了酗捌,也不知道是為什么呢诬。但是有一點(diǎn),使用最新版本后胖缤,倒是沒有出現(xiàn)找不到資源文件res的警告尚镰。最新的Robolectric最高可支持Android API 23。
Android Studio環(huán)境配置:
1.在Build Variants面板中哪廓,將Test Artifact切換成Unit Tests模式狗唉,不過在新版本的Android Studio已經(jīng)不需要做這項(xiàng)配置,如下圖:
2.Working directory設(shè)置
如果在運(yùn)行測(cè)試方法過程中遇見如下異常:
java.io.FileNotFoundException: build\intermediates\bundles\debug\AndroidManifest.xml
......
或者如下警告:
No such manifest file: build/intermediates/bundles/debug/AndroidManifest.xml
......
解決的方式就是將Working directory的值設(shè)置為$MODULE_DIR$涡真。
第一步設(shè)置如下:
第二步設(shè)置如下:
設(shè)置完畢后分俯,再次run就可以了。
Robolectric實(shí)戰(zhàn)
首先在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'
從配置中哆料,可以看出在實(shí)際運(yùn)用中缸剪,我們是使用JUnit4+Mockito+PowerMockito+Robolectric,這是一個(gè)牛逼的組合剧劝,在寫單元測(cè)試用例時(shí)簡(jiǎn)直溜得飛起橄登,通過PowerMockito彌補(bǔ)Mockito測(cè)試框架不能mock靜態(tài)方法、final方法和private方法的不足讥此,還可以在JVM中就可以很方便的調(diào)用Android相關(guān)的類和方法拢锹,速度也比較快。
然后定義抽象類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();
}
});
}
}
這個(gè)抽象類代碼比較多萄喳,主要是設(shè)置Robolectric單元測(cè)試的運(yùn)行環(huán)境卒稳,方便在單元測(cè)試用例代碼中進(jìn)行復(fù)用。具體分下一下:
-
@RunWith(RobolectricTestRunner.class)
通過注解定義Robolectric運(yùn)行的TestRunner他巨; -
@Config(shadows = {ShadowLog.class}, constants = BuildConfig.class, sdk = 23)
通過配置shadows = {ShadowLog.class}
和ShadowLog.stream = System.out;
來設(shè)置Android log輸出方式充坑,使得單元測(cè)試運(yùn)行時(shí)在控制臺(tái)中可以看到Android代碼中打印出的log日志; -
@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*", "org.json.*", "sun.security.*", "javax.net.*"})
通過PowerMockIgnore注解定義所忽略的package路勁染突,防止所定義的package路徑下的class類被PowerMockito測(cè)試框架mock捻爷; - 在setUp()方法中調(diào)用
MockitoAnnotations.initMocks(this);
初始化PowerMockito注解,為@PrepareForTest(YourStaticClass.class)注解提供支持份企; - 在代碼中也榄,我們可以看到定義了兩個(gè)基本方法getApplication()和getContext(),在寫測(cè)試代碼中使用起來很方便司志,就像在Activity一樣甜紫,增加測(cè)試的可讀性降宅;
- 如果項(xiàng)目中使用了rxjava框架,在對(duì)rxjava相關(guān)的代碼進(jìn)行單元測(cè)試時(shí)囚霸,通過initRxJava()方法將異步處理轉(zhuǎn)化為同步處理腰根,如此一來方便單元測(cè)試驗(yàn)證;
最后編寫Activity測(cè)試用例代碼:
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());
}
}
上面前一部分代碼主要設(shè)置ComplaintActivity運(yùn)行所依賴的屬性拓型,這也是在單元測(cè)試最為繁瑣的地方额嘿,因?yàn)椴皇沁\(yùn)行在真實(shí)的Android環(huán)境中。具體分析如下:
- 通過注解
@PrepareForTest({AppUtil.class, OAuthManager.class, NetUtil.class})
定義PowerMockito要mock的類吨述; - 在Robolectric中讀取不到apk的版本號(hào)岩睁,通過
PowerMockito.when(AppUtil.getVersionName()).thenReturn("1.4.0");
mock指定AppUtil.getVersionName()
的返回值"1.4.0",即版本號(hào)揣云; - 通過
AppApplication.mInstance = getApplication();
使用Robolectric運(yùn)行環(huán)境中的application對(duì)AppApplication.mInstance進(jìn)行依賴注入,因?yàn)樵诤芏囝愔卸紩?huì)用到AppApplication.mInstance進(jìn)行初始化冰啃,例如SharedPreference邓夕、SQlite、單例類等阎毅,
PreferenceUtil.init();
PersistentPreferenceUtil.init();
上面代碼就需要依賴AppApplication.mInstance進(jìn)行初始化焚刚;
-
ComplaintActivity complaintActivity = Robolectric.buildActivity(ComplaintActivity.class).create().get();
使用Robolectric創(chuàng)建ComplaintActivity對(duì)象,其中create()方法就是對(duì)應(yīng)于調(diào)用Activity生命周期的onCreate()方法扇调,此外Robolectric支持鏈?zhǔn)秸{(diào)用如:Robolectric.buildActivity(ComplaintActivity.class).create().resume().get();
矿咕; -
assertNotNull(complaintActivity);
驗(yàn)證complaintActivity是否跑起來; - 最后一部分代碼就是調(diào)用jumpCompensate方法進(jìn)行跳轉(zhuǎn)狼钮,驗(yàn)證跳轉(zhuǎn)的Intent是否符合預(yù)期碳柱;
至于其他的一些如Fragment、Dialog熬芜、Toast等驗(yàn)證莲镣,可以參考這篇博客,這里就不展開涎拉。
Robolectric常見的坑
1.Application空指針問題
這是因?yàn)镾haredPreferences和單例等類初始化時(shí)需要依賴Application對(duì)象瑞侮,我們常見的用法是使用Application.getApplication()方法來獲取,在Robolectric中則是需要使用RuntimeEnvironment.application來進(jìn)行替換鼓拧,上面就是通過依賴的方式進(jìn)行替換半火。
2. AppCompatActivity錯(cuò)誤
假如你在Robolectric的@Config注解中配置了manifest = Config.NONE
,那就完蛋了季俩,因?yàn)樵诰W(wǎng)上根本找不解決的方法钮糖,你遇到如下異常不能使用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
配置,這是坑爹的种玛,我就遇到這個(gè)錯(cuò)誤藐鹤,花了好長(zhǎng)一段時(shí)間才發(fā)現(xiàn)是這個(gè)配置導(dǎo)致的瓤檐。
3.Asset文件路徑錯(cuò)誤
需要用到context.getAssets().open("XXX")加載asset目錄下的文件時(shí),要是遇到以下錯(cuò)誤:
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"));
這個(gè)方式有點(diǎn)丑肄满,需要用到你要加載的文件的絕對(duì)路徑谴古,靈活性低,不方便移植稠歉,不過這是我目前想到的解決方式掰担。
4.找不到android.net.http.AndroidHttpClient的類文件
在Android API23開始,google就移除了HttpClient相關(guān)的類怒炸,有兩種方法解決上述問題带饱。
方法一:在build.gradle添加應(yīng)用useLibrary ‘org.apache.http.legacy’
方法二:在test目錄下添加HttpClient類(記得包名為android.net.http),如下:
說明:推薦使用第二種方式阅羹,第二種方法正式打包并不會(huì)把HttpClient的類加入勺疼,減少了包中無用的資源。
小結(jié)
在實(shí)際的使用中捏鱼,Robolectric需要踩很多坑的执庐,不過貴在嘗試。至此导梆,單元測(cè)試系列博客已經(jīng)完結(jié)轨淌,主要分了四篇博客來講述。非常感謝您對(duì)本篇博客的支持看尼,要是有什么不足歡迎指正递鹉!