概述
Android的單元測試可以分為兩部分:
- Local unit tests:運(yùn)行于本地JVM
- Instrumented test:運(yùn)行于真機(jī)或者模擬器
如果使用Local測試,需要保證測試過程中不會(huì)調(diào)用Android系統(tǒng)API媚送,否則會(huì)拋出RuntimeException異常,因?yàn)長ocal測試是直接跑在本機(jī)JVM的,而之所以我們能使用Android系統(tǒng)API掰曾,是因?yàn)榫幾g的時(shí)候茧痕,我們依賴了一個(gè)名為“android.jar”的jar包财岔,但是jar包里所有方法都是直接拋出了一個(gè)RuntimeException,是沒有任何任何實(shí)現(xiàn)的盏浇,這只是Android為了我們能通過編譯提供的一個(gè)Stub!當(dāng)APP運(yùn)行在真實(shí)的Android系統(tǒng)的時(shí)候芽狗,由于類加載機(jī)制绢掰,會(huì)加載位于framework的具有真正實(shí)現(xiàn)的類。由于我們的Local是直接在PC上運(yùn)行的,所以調(diào)用這些系統(tǒng)API便會(huì)出錯(cuò)滴劲。
那么問題來了攻晒,我們既要使用Local測試,但測試過程又難免遇到調(diào)用系統(tǒng)API那怎么辦哑芹?其中一個(gè)方法就是mock objects炎辨,比如借助Mockito,另外一種方式就是使用Robolectric
聪姿, Robolectric就是為解決這個(gè)問題而生的碴萧。它實(shí)現(xiàn)一套JVM能運(yùn)行的Android代碼,然后在unit test運(yùn)行的時(shí)候去截取android相關(guān)的代碼調(diào)用末购,然后轉(zhuǎn)到他們的他們實(shí)現(xiàn)的Shadow代碼去執(zhí)行這個(gè)調(diào)用的過程
如何使用破喻?
為項(xiàng)目添加依賴
testCompile "org.robolectric:robolectric:3.1.4"
Robolectric在第一次運(yùn)行時(shí),會(huì)下載一些sdk依賴包盟榴,每個(gè)sdk依賴包大概50M曹质,下載速度比較慢,用戶可以直接在網(wǎng)上下載相應(yīng)依賴包擎场,放置在本地maven倉庫地址中羽德,默認(rèn)路徑為:C:\Users\username\.m2\repository\org\robolectric
指定RobolectricTestRunner為運(yùn)行器
為測試用例添加注解,指定測試運(yùn)行器為RobolectricTestRunner。注意迅办,這里要通過Config指定constants = BuildConfig.class
宅静,Robolectric 會(huì)通過constants推導(dǎo)出輸出路徑,如果不進(jìn)行配置站欺,Robolectric可能不能找到你的manifest姨夹、resources和assets資源
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class MainActivityTest {
}
什么是Shadow類
Shadow是Robolectric的立足之本,如其名矾策,作為影子磷账,一定是變幻莫測,時(shí)有時(shí)無贾虽,且依存于本尊逃糟。Robolectric定義了大量模擬Android系統(tǒng)類行為的Shadow類,當(dāng)這些系統(tǒng)類被創(chuàng)建的時(shí)候蓬豁,Robolectric會(huì)查找對應(yīng)的Shadow類并創(chuàng)建一個(gè)Shadow類與原始類關(guān)聯(lián)履磨。每當(dāng)系統(tǒng)類的方法被調(diào)用的時(shí)候,Robolectric會(huì)保證Shadow對應(yīng)的方法會(huì)調(diào)用庆尘。這些Shadow對象剃诅,豐富了本尊的行為,能更方便的對Android相關(guān)的對象進(jìn)行測試驶忌。
比如矛辕,我們可以借助ShadowActivity驗(yàn)證頁面是否正確跳轉(zhuǎn)了
/**
* 驗(yàn)證點(diǎn)擊事件是否觸發(fā)了頁面跳轉(zhuǎn)笑跛,驗(yàn)證目標(biāo)頁面是否預(yù)期頁面
*
* @throws Exception
*/
@Test
public void testJump() throws Exception {
// 默認(rèn)會(huì)調(diào)用Activity的生命周期: onCreate->onStart->onResume
MainActivity activity = Robolectric.setupActivity(MainActivity.class);
// 觸發(fā)按鈕點(diǎn)擊
activity.findViewById(R.id.activity_main_jump).performClick();
// 獲取對應(yīng)的Shadow類
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
// 借助Shadow類獲取啟動(dòng)下一Activity的Intent
Intent nextIntent = shadowActivity.getNextStartedActivity();
// 校驗(yàn)Intent的正確性
assertEquals(nextIntent.getComponent().getClassName(), SecondActivity.class.getName());
}
@Config配置
可以通過@Config
定制Robolectric的運(yùn)行時(shí)的行為。這個(gè)注解可以用來注釋類和方法聊品,如果類和方法同時(shí)使用了@Config飞蹂,那么方法的設(shè)置會(huì)覆蓋類的設(shè)置。你可以創(chuàng)建一個(gè)基類翻屈,用@Config配置測試參數(shù)陈哑,這樣,其他測試用例就可以共享這個(gè)配置了
配置SDK版本
Robolectric會(huì)根據(jù)manifest文件配置的targetSdkVersion選擇運(yùn)行測試代碼的SDK版本伸眶,如果你想指定sdk來運(yùn)行測試用例惊窖,可以通過下面的方式配置
@Config(sdk = Build.VERSION_CODES.JELLY_BEAN)
public class SandwichTest {
@Config(sdk = Build.VERSION_CODES.KITKAT)
public void getSandwich_shouldReturnHamSandwich() {
}
}
配置Application類
Robolectric會(huì)根據(jù)manifest文件配置的Application配置去實(shí)例化一個(gè)Application類,如果你想在測試用例中重新指定厘贼,可以通過下面的方式配置
@Config(application = CustomApplication.class)
public class SandwichTest {
@Config(application = CustomApplicationOverride.class)
public void getSandwich_shouldReturnHamSandwich() {
}
}
指定Resource路徑
Robolectric可以讓你配置manifest界酒、resource和assets路徑,可以通過下面的方式配置
@Config(manifest = "some/build/path/AndroidManifest.xml",
assetDir = "some/build/path/assetDir",
resourceDir = "some/build/path/resourceDir")
public class SandwichTest {
@Config(manifest = "other/build/path/AndroidManifest.xml")
public void getSandwich_shouldReturnHamSandwich() {
}
}
使用第三方Library Resources
當(dāng)Robolectric測試的時(shí)候嘴秸,會(huì)嘗試加載所有應(yīng)用提供的資源毁欣,但如果你需要使用第三方庫中提供的資源文件,你可能需要做一些特別的配置岳掐。不過如果你使用gradle來構(gòu)建Android應(yīng)用凭疮,這些配置就不需要做了,因?yàn)镚radle Plugin會(huì)在build的時(shí)候自動(dòng)合并第三方庫的資源串述,但如果你使用的是Maven哭尝,那么你需要配置libraries變量:
@RunWith(RobolectricTestRunner.class)
@Config(libraries = {
"build/unpacked-libraries/library1",
"build/unpacked-libraries/library2"
})
public class SandwichTest {
}
使用限定的資源文件
Android會(huì)在運(yùn)行時(shí)加載特定的資源文件,如根據(jù)設(shè)備屏幕加載不同分辨率的圖片資源剖煌、根據(jù)系統(tǒng)語言加載不同的string.xml,在Robolectric測試當(dāng)中逝淹,你也可以進(jìn)行一個(gè)限定耕姊,讓測試程序加載特定資源.多個(gè)限定條件可以用破折號拼接在在一起。
/**
* 使用qualifiers加載對應(yīng)的資源文件
*
* @throws Exception
*/
@Config(qualifiers = "zh-rCN")
@Test
public void testString() throws Exception {
final Context context = RuntimeEnvironment.application;
assertThat(context.getString(R.string.app_name), is("單元測試Demo"));
}
Properties文件
如果你嫌通過注解配置上面的東西麻煩栅葡,你也可以把以上配置放在一個(gè)Properties文件之中茉兰,然后通過@Config指定配置文件,比如欣簇,首先創(chuàng)建一個(gè)配置文件robolectric.properties:
# 放置Robolectric的配置選項(xiàng):
sdk=21
manifest=some/build/path/AndroidManifest.xml
assetDir=some/build/path/assetDir
resourceDir=some/build/path/resourceDir
然后把robolectric.properties文件放到src/test/resources目錄下规脸,運(yùn)行的時(shí)候,會(huì)自動(dòng)加載里面的配置
系統(tǒng)屬性配置
- robolectric.offline:true代表關(guān)閉運(yùn)行時(shí)獲取jar包
- robolectric.dependency.dir:當(dāng)處于offline模式的時(shí)候熊咽,指定運(yùn)行時(shí)的依賴目錄
- robolectric.dependency.repo.id:設(shè)置運(yùn)行時(shí)獲取依賴的Maven倉庫ID莫鸭,默認(rèn)是sonatype
- robolectric.dependency.repo.url:設(shè)置運(yùn)行時(shí)依賴的Maven倉庫地址,默認(rèn)是https://oss.sonatype.org/content/groups/public/
- robolectric.logging.enabled:設(shè)置是否打開調(diào)試開關(guān)
以上設(shè)置可以通過Gradle進(jìn)行配置横殴,如:
android {
testOptions {
unitTests.all {
systemProperty 'robolectric.dependency.repo.url', 'https://local-mirror/repo'
systemProperty 'robolectric.dependency.repo.id', 'local'
}
}
}
驅(qū)動(dòng)Activity生命周期
利用ActivityController
我們可以讓Activity執(zhí)行相應(yīng)的生命周期方法被因,如:
@Test
public void testLifecycle() throws Exception {
// 創(chuàng)建Activity控制器
ActivityController<MainActivity> controller = Robolectric.buildActivity(MainActivity.class);
MainActivity activity = controller.get();
assertNull(activity.getLifecycleState());
// 調(diào)用Activity的performCreate方法
controller.create();
assertEquals("onCreate", activity.getLifecycleState());
// 調(diào)用Activity的performStart方法
controller.start();
assertEquals("onStart", activity.getLifecycleState());
// 調(diào)用Activity的performResume方法
controller.resume();
assertEquals("onResume", activity.getLifecycleState());
// 調(diào)用Activity的performPause方法
controller.pause();
assertEquals("onPause", activity.getLifecycleState());
// 調(diào)用Activity的performStop方法
controller.stop();
assertEquals("onStop", activity.getLifecycleState());
// 調(diào)用Activity的performRestart方法
controller.restart();
// 注意此處應(yīng)該是onStart,因?yàn)閜erformRestart不僅會(huì)調(diào)用restart,還會(huì)調(diào)用onStart
assertEquals("onStart", activity.getLifecycleState());
// 調(diào)用Activity的performDestroy方法
controller.destroy();
assertEquals("onDestroy", activity.getLifecycleState());
}
通過ActivityController梨与,我們可以模擬各種生命周期的變化堕花。但是要注意,我們雖然可以隨意調(diào)用Activity的生命周期粥鞋,但是Activity生命周期切換有自己的檢測機(jī)制缘挽,我們要遵循Activity的生命周期規(guī)律。比如呻粹,如果當(dāng)前Activity并非處于stop狀態(tài)壕曼,測試代碼去調(diào)用了controller.restart方法,此時(shí)Activity是不會(huì)回調(diào)onRestart和onStart的尚猿。
除了控制生命周期窝稿,還可以在啟動(dòng)Activity的時(shí)候傳遞Intent:
/**
* 啟動(dòng)Activity的時(shí)候傳遞Intent
*
* @throws Exception
*/
@Test
public void testStartActivityWithIntent() throws Exception {
Intent intent = new Intent();
intent.putExtra("test", "HelloWorld");
Activity activity = Robolectric.buildActivity(MainActivity.class).withIntent(intent).create().get();
assertEquals("HelloWorld", activity.getIntent().getExtras().getString("test"));
}
onRestoreInstanceState回調(diào)中傳遞Bundle:
/**
* savedInstanceState會(huì)在onRestoreInstanceState回調(diào)中傳遞給Activity
*
* @throws Exception
*/
@Test
public void testSavedInstanceState() throws Exception {
Bundle savedInstanceState = new Bundle();
Robolectric.buildActivity(MainActivity.class).create().restoreInstanceState(savedInstanceState).get();
// verify something
}
在真實(shí)環(huán)境下,視圖是在onCreate之后的某一時(shí)刻在attach到Window上的凿掂,在此之前伴榔,View是處于不可操作狀態(tài)的,你不能點(diǎn)擊它庄萎。在Activity的onPostResume方法調(diào)用之后踪少,View才會(huì)attach到Window之中。但是糠涛,在Robolectric之中援奢,我們可以用控制器的visible
方法使得View變?yōu)榭梢姡優(yōu)榭梢娭笕碳瘢涂梢阅M點(diǎn)擊事件了
@Test
public void testVisible() throws Exception {
ActivityController<MainActivity> controller = Robolectric.buildActivity(MainActivity.class);
MainActivity activity = controller.get();
// 調(diào)用Activity的performCreate并且設(shè)置視圖visible
controller.create().visible();
// 觸發(fā)點(diǎn)擊
activity.findViewById(R.id.activity_main_button1).performClick();
// 驗(yàn)證
assertEquals(shadowOf(activity).getNextStartedActivity().getComponent().getClassName(), SecondActivity.class.getName());
}
追加模塊
為了減少依賴包的大小集漾,Robolectric的shadows類成了好幾部分:
SDK Package | Robolectric Add-On Package |
---|---|
com.android.support.support-v4 | org.robolectric:shadows-support-v4 |
com.android.support.multidex | org.robolectric:shadows-multidex |
com.google.android.gms:play-services | org.robolectric:shadows-play-services |
com.google.android.maps:maps | org.robolectric:shadows-maps |
org.apache.httpcomponents:httpclient | org.robolectric:shadows-httpclient |
用戶可以根據(jù)自身需求添加以下依賴包,如
dependencies {
... ...
testCompile 'org.robolectric:robolectric:3.1.4'
testCompile 'org.robolectric:shadows-support-v4:3.1.4'
testCompile 'org.robolectric:shadows-multidex:3.1.4'
testCompile 'org.robolectric:shadows-play-services:3.1.4'
testCompile 'org.robolectric:shadows-maps:3.1.4'
testCompile 'org.robolectric:shadows-httpclient:3.1.4'
}
自定義Shadow類
- Shadow類需要一個(gè)public的無參構(gòu)造方法以方便Robolectric框架可以實(shí)例化它砸脊,通過@Implements注解與原始類關(guān)聯(lián)在一起
- 若原始類有
有參構(gòu)造方法
具篇,在Shadow類中定義public void類型的名為__constructor__
的方法,且方法參數(shù)與原始類的構(gòu)造方法參數(shù)一直 - 定義與原始類方法簽名一致的方法凌埂,在里面重寫實(shí)現(xiàn)驱显,Shadow方法需用@Implementation進(jìn)行注解
下面我們來創(chuàng)建RobolectricBean的Shadow類
原始類:
public class RobolectricBean {
String name;
int color;
public RobolectricBean(String name) {
this.name = name;
}
public String getName() {
return name;
}
public int getColor() {
return color;
}
public void setColor(int color) {
this.color = color;
}
}
Shadow類:
/**
* 創(chuàng)建{@link RobolectricBean}的影子類
*
* @author HansChen
*/
@Implements(RobolectricBean.class)
public class ShadowRobolectricBean {
/**
* 通過@RealObject注解可以訪問原始對象,但注意瞳抓,通過@RealObject注解的變量調(diào)用方法埃疫,依然會(huì)調(diào)用Shadow類的方法,而不是原始類的方法
* 只能用來訪問原始類的field
*/
@RealObject
RobolectricBean realBean;
/**
* 需要一個(gè)無參構(gòu)造方法
*/
public ShadowRobolectricBean() {
}
/**
* 對應(yīng)原始類的構(gòu)造方法
*
* @param name 對應(yīng)原始類構(gòu)造方法的傳入?yún)?shù)
*/
public void __constructor__(String name) {
realBean.name = name;
}
/**
* 原始對象的方法被調(diào)用的時(shí)候孩哑,Robolectric會(huì)根據(jù)方法簽名查找對應(yīng)的Shadow方法并調(diào)用
*/
@Implementation
public String getName() {
return "Hello, I ma shadow of RobolectricBean: " + realBean.name;
}
@Implementation
public int getColor() {
return realBean.color;
}
@Implementation
public void setColor(int color) {
realBean.color = color;
}
}
Shadow類中訪問原始類的field
Shadow類中可以定義一個(gè)原始類的成員變量栓霜,并用@RealObject注解,這樣横蜒,Shadow類就能訪問原始類的field了叙淌,但是注意秤掌,通過@RealObject注解的變量調(diào)用方法,依然會(huì)調(diào)用Shadow類的方法鹰霍,而不是原始類的方法闻鉴,只能用它來訪問原始類的field。
@Implements(Point.class)
public class ShadowPoint {
@RealObject private Point realPoint;
...
public void __constructor__(int x, int y) {
realPoint.x = x;
realPoint.y = y;
}
}
如何在測試用例中讓Shadow生效
在Config注解中添加shadows
參數(shù)茂洒,指定對應(yīng)的Shadow生效
@RunWith(RobolectricTestRunner.class)
@Config(shadows = ShadowRobolectricBean.class)
public class RobolectricBeanTest {
... ...
}
注意孟岛,自定義的Shadow類不能通過Shadows.shadowOf()
獲取,需要用ShadowExtractor.extract()
來獲取督勺,獲取之后進(jìn)行類型轉(zhuǎn)換:
ShadowRobolectricBean shadowBean = (ShadowRobolectricBean) ShadowExtractor.extract(bean);
常用測試場景
頁面跳轉(zhuǎn)驗(yàn)證
/**
* 驗(yàn)證點(diǎn)擊事件是否觸發(fā)了頁面跳轉(zhuǎn)渠羞,驗(yàn)證目標(biāo)頁面是否預(yù)期頁面
*
* @throws Exception
*/
@Test
public void testJump() throws Exception {
// 默認(rèn)會(huì)調(diào)用Activity的生命周期: onCreate->onStart->onResume
MainActivity activity = Robolectric.setupActivity(MainActivity.class);
// 觸發(fā)按鈕點(diǎn)擊
activity.findViewById(R.id.activity_main_jump).performClick();
// 獲取對應(yīng)的Shadow類
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
// 借助Shadow類獲取啟動(dòng)下一Activity的Intent
Intent nextIntent = shadowActivity.getNextStartedActivity();
// 校驗(yàn)Intent的正確性
assertEquals(nextIntent.getComponent().getClassName(), SecondActivity.class.getName());
}
UI組件狀態(tài)驗(yàn)證
/**
* 驗(yàn)證UI組件狀態(tài)
*
* @throws Exception
*/
@Test
public void testCheckBoxState() throws Exception {
MainActivity activity = Robolectric.setupActivity(MainActivity.class);
CheckBox checkBox = (CheckBox) activity.findViewById(R.id.activity_main_check_box);
// 驗(yàn)證CheckBox初始狀態(tài)
assertFalse(checkBox.isChecked());
// 點(diǎn)擊按鈕反轉(zhuǎn)CheckBox狀態(tài)
activity.findViewById(R.id.activity_main_switch_check_box).performClick();
// 驗(yàn)證狀態(tài)是否正確
assertTrue(checkBox.isChecked());
// 點(diǎn)擊按鈕反轉(zhuǎn)CheckBox狀態(tài)
activity.findViewById(R.id.activity_main_switch_check_box).performClick();
// 驗(yàn)證狀態(tài)是否正確
assertFalse(checkBox.isChecked());
}
驗(yàn)證Dialog
/**
* 驗(yàn)證Dialog是否正確彈出
*
* @throws Exception
*/
@Test
public void testDialog() throws Exception {
MainActivity activity = Robolectric.setupActivity(MainActivity.class);
AlertDialog dialog = ShadowAlertDialog.getLatestAlertDialog();
// 判斷Dialog尚未彈出
assertNull(dialog);
activity.findViewById(R.id.activity_main_show_dialog).performClick();
dialog = ShadowAlertDialog.getLatestAlertDialog();
// 判斷Dialog已經(jīng)彈出
assertNotNull(dialog);
// 獲取Shadow類進(jìn)行驗(yàn)證
ShadowAlertDialog shadowDialog = shadowOf(dialog);
assertEquals("AlertDialog", shadowDialog.getTitle());
assertEquals("Oops, now you see me ~", shadowDialog.getMessage());
}
驗(yàn)證Toast
/**
* 驗(yàn)證Toast是否正確彈出
*
* @throws Exception
*/
@Test
public void testToast() throws Exception {
MainActivity activity = Robolectric.setupActivity(MainActivity.class);
Toast toast = ShadowToast.getLatestToast();
// 判斷Toast尚未彈出
assertNull(toast);
activity.findViewById(R.id.activity_main_show_toast).performClick();
toast = ShadowToast.getLatestToast();
// 判斷Toast已經(jīng)彈出
assertNotNull(toast);
// 獲取Shadow類進(jìn)行驗(yàn)證
ShadowToast shadowToast = shadowOf(toast);
assertEquals(Toast.LENGTH_SHORT, shadowToast.getDuration());
assertEquals("oops", ShadowToast.getTextOfLatestToast());
}
驗(yàn)證Fragment
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, application = CustomApplication.class)
public class MyFragmentTest {
private MyFragment myFragment;
@Before
public void setUp() throws Exception {
myFragment = new MyFragment();
// 把Fragment添加到Activity中
FragmentTestUtil.startFragment(myFragment);
}
@Test
public void testFragment() throws Exception {
assertNotNull(myFragment.getView());
}
}
驗(yàn)證BroadcastReceiver
首先看下廣播接收器:
public class MyReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
// do something
}
}
廣播的測試點(diǎn)可以包含兩個(gè)方面
- 驗(yàn)證應(yīng)用程序是否注冊了該廣播
- 驗(yàn)證廣播接收器的處理邏輯是否正確,關(guān)于邏輯是否正確智哀,可以直接人為的觸發(fā)onReceive()方法次询,讓然后進(jìn)行驗(yàn)證
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, application = CustomApplication.class)
public class MyReceiverTest {
@Test
public void restRegister() throws Exception {
ShadowApplication shadowApplication = ShadowApplication.getInstance();
String action = "ut.cn.unittestdemo.receiver";
Intent intent = new Intent(action);
// 驗(yàn)證是否注冊了相應(yīng)的Receiver
assertTrue(shadowApplication.hasReceiverForIntent(intent));
}
@Test
public void restReceive() throws Exception {
String action = "ut.cn.unittestdemo.receiver";
Intent intent = new Intent(action);
intent.putExtra("EXTRA_USERNAME", "HansChen");
MyReceiver myReceiver = new MyReceiver();
myReceiver.onReceive(RuntimeEnvironment.application, intent);
// verify something
}
}
驗(yàn)證Service
Service和Activity一樣,都有生命周期瓷叫,Robolectric也提供了Service的生命周期控制器屯吊,使用方式和Activity類似,這里就不做詳細(xì)解釋了
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, application = CustomApplication.class)
public class TestServiceTest {
private ServiceController<TestService> controller;
private TestService testService;
@Before
public void setUp() throws Exception {
controller = Robolectric.buildService(TestService.class);
testService = controller.get();
}
/**
* 控制Service生命周期進(jìn)行驗(yàn)證
*
* @throws Exception
*/
@Test
public void testLifecycle() throws Exception {
controller.create();
// verify something
controller.startCommand(0, 0);
// verify something
controller.bind();
// verify something
controller.unbind();
// verify something
controller.destroy();
// verify something
}
}