JUnit之扩展IntrumentationTest框架

在上一篇文章“JUnit之TestCase和TestSuite详解”中着重介绍了TestSuite和TestCase,本篇主要介绍JUnit在Android中的使用,以JUnit3执行引擎为例,介绍Intrumentation测试框架的使用。

在介绍Intrumenttation测试框架之前,需要先粘一下上一篇中的TestCase和TestSuite的结构图(图1),并附上InstrumentationTestCase要介绍的结构图(图2):


(图1)


(图2)

如图2所示,Android中的InstrumentationTestCase其实是TestCase的一个子类,如果对TestCase和TestSuite概念比较熟悉的话,Android中的InstrumentationTestCase框架便可以很好的上手了,InstrumentationTestCase的源码位于/source/frameworks/base/core/java/android/test中。

InstrumentationTestCase和ActivityTestCase以及AcitvityInstrumenttationTestCase的类图如图3所示,InstrumentationTestCase的核心是使用了Andoid的Instrumentation,Instrumentation是ActivityThread中调用Activity的一个中间层,我们可以暂且认为Instrumentation在测试过程中起到的作用是Android系统的hook,正如ActivityThread在调用Acitvity的一些生命周期方法的时候会通过Instrumentation对象调用相应的Activity方法一样,我们可以在我们的InstrumentationTestCase中通过其封装的mInstrumentation属性去模拟调用Activity的相关方法,如Activity的生命周期方法:onCreate、onStart、onResume、onPause、onStop、onDestroy,除此以外,还可以通过Instrumentation模拟启动相应的Activiry,并通过Activity的findViewById获取到相应的View控件,甚至可以通过Instrumentation去模拟向系统发送点击或者按键事件:

(图3)

正如前面所讲述的内容,AndroidJunit和Java的JUnit的扩展在于AndroidJunit集成了Instrumentation,因此在使用Eclipse进行单元测试的时候需要在清单文件中进行声明,声明格式如下:

<instrumentation

android:name ="android.test.InstrumentationTestRunner"

android:targetPackage ="com.android.example"

android:label ="Test"

/>

这样当开始执行测试的时候Android系统会根据配置选择所要使用的测试程序执行引擎,并通过测试执行引擎InstrumentationTestRunner执行测试,应用在启动的时候把将要使用的InstrumentationTestRunner传递到ActivityThread中,并调用ActivityThread中的handleBindApplication方法对Instrumentation进行初始化,注意在Android Studio中并不需要进行额外的instrumentation标签声明。

相比于JAVA中的JUnit框架,在Android的InstrumentationTest中最核心的便是对于Instrumentation的封装,如4所示InstrumentationTest框架的基类InstrumentationTestCase中封装了Instrumentation这个属性,并通过这个属性来启动具体的Activity(调用launchActivity方法),控制相关的代码在UI线程中执行,以及模拟按键向测试应用发送相应的按键等。

(图4)

通过getInstrumentation方法我们可以获取到这个Instrumentation属性,这个属性是在InstrumentTestSuite中被初始化的,如图5,在InstrumentationTestSuite的构造方法中会得到具体的Instrumentation对象,并将构造方法中传入的这个对象作为该类的属性mInstrumentation的值,在执行重写自父类的runTest方法的时候会将这个值传入到InstrumentationTestCase中:

(图5)

//runTest方法

@Override

public void runTest(Test test, TestResult result) {

if (test instanceof InstrumentationTestCase) {

((InstrumentationTestCase) test).injectInstrumentation(mInstrumentation);

}

// run the test as usual

super.runTest(test, result);

}

获取到Instrumentation以后,我们便可以模仿系统执行界面的相关操作,包括Activity的生命周期控制、按键事件等,具体的Instrumentation的方法可以参照Android提供的Instrumentation API文档,文档目录:/android-sdk/docs/reference/android/app/Instrumentation.html,如图6是常见的Activity的生命周期的控制方法,在这些方法中会通过回调调用Activity的相应的生命周期方法,如果注意过ActivityThread源码,ActivityThread正式通过执行Instrumentation的相关方法,来进一步执行Activity的相关生命周期方法,因此Instrumentation更像是一个接口,将代码进行分层,这样我们在做单元测试的时候需要执行到Activity的相关周期方法时,我们可以直接调用Instrumentation的相关方法,达到让Activity其相应方法的目的:

(图6)

InstrumentationTestCase中的另外一个比较核心的方法便是launchActivity方法,该方法的源码如下:

public finalTlaunchActivity(

String pkg,

Class activityCls,

Bundle extras) {

Intent intent =newIntent(Intent.ACTION_MAIN);

if(extras !=null) {

intent.putExtras(extras);

}

returnlaunchActivityWithIntent(pkg, activityCls, intent);

}

public finalTlaunchActivityWithIntent(

String pkg,

Class activityCls,

Intent intent) {

intent.setClassName(pkg, activityCls.getName());

intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

Tactivity = (T) getInstrumentation().startActivitySync(intent);

getInstrumentation().waitForIdleSync();

returnactivity;

}

该方法的主要作用如名字那样,就是模拟启动一个Activity,如上面的代码,要启动这个Activity,借助的还是Instrumentation,正如我们在开发过程中要启动一个Activity需要传递Intent一样,在测试启动Activity的时候我们也应当传入一个Intent,然而这个Intent调用了它的addFrags方法,熟悉AMS中的Activity的Stack概念的应该比较清楚,当我们使用adb命令查看activity的时候会以stack概念和record的概念,系统的如Launcher(桌面)的activity会在一个单独的stack中,其他应用会在另外一个stack中,如果没有指明Activity的启动类型(如singleInstance),则每个应用的activity都会在一个task中,因此根据代码我们可以认为在执行单元测试的时候应用的Activity应该会在一个新的task中。

除此之外,如果需要测试的Activity在执行的时候需要传入数据进来才能达到才是目的的话,我们需要在这个Intent中加入,在后面介绍ActivityInstrumentationTest2中的方法时我们会进一步强调Intent的作用。

runTestOnUiThread方法牵扯到一个比较核心的概念,那就是我们单元测试程序所在的线程,并非Android应用程序的主线程(UI线程),而是一个单独的线程,但是根据线程和进程的概念,为了能够访问到UI线程中的数据,测试线程又必须和主线程在同一个进程中,因此,所有需要在Android的UI线程中执行的任务,需要放在runTestOnUiThread中才能够正常的执行,当然,除了可以调用此方法外,还可以在具体待测试的方法上加UiThreadTest注解,这样也可以保证整个方法能够在UI线程中执行。

sendKeys和sendRepeatedKeys则是在模仿用户的按键输入,比如按下返回键,按下菜单键等操作,一般在测试的时候都应该将触摸事件禁止掉,比如直接调用Instrumentation中的setTouchMode,并传入false,以便应用能够接受到键盘消息。这是因为如果触控模式打开,Android系统中有些控件是不能通过代码的方式设置输入焦点的,手指戳到一个控件后该控件就自然而然的获取到输入焦点了。例如,戳一个Button控件,除了导致其获取到焦点以外,还会触发它的点击事件。

(图7)

ActivityTestCase的主要方法如上图所示,ActivityTestCase中主要是对以组合的形式对activity进行了简单的封装,并没有做太多的改动。

(图8)

ActivityInstrumentationTestCase2中构造方法中会传入一个指定的Activity的class,如果这个Activity不需要特殊的初始化数据就可以工作,此时我们可以在需要使用Activity的地方直接调用getActivity方法获取这个Activity对象,在通过这个Activity对象去进行相应的操作,如获取控件,传递参数测试某一个方法等等。在前面介绍launchActivity的时候我们说过如果需要访问的Activity需要传递相应的信息的才能工作的时候,我们必须将数据放入新的Intent中,并调用setActivityIntent去初始化为要启动的Activity设置Intent,并且注意,只有先设置了Intent再去调用getActivity才能正常的获取到传入初始化数据的Activity。看ActivityInstrumentationTestCase2重写的getActivity源码,在这个getActivity中会启动相应的Activity,并将Activity对象返回。代码如下:

@Override

publicTgetActivity() {

Activity a =super.getActivity();

if(a ==null) {

// set initial touch mode

getInstrumentation().setInTouchMode(mInitialTouchMode);

finalString targetPackage = getInstrumentation().getTargetContext().getPackageName();

// inject custom intent, if provided

if(mActivityIntent==null) {

a = launchActivity(targetPackage,mActivityClass,null);

}else{

a = launchActivityWithIntent(targetPackage,mActivityClass,mActivityIntent);

}

setActivity(a);

}

return(T) a;

}

介绍了这么多,最后通过一个简单的Demo来介绍InstrumentationTestCase框架的使用,在这个Demo中,我们模仿测试一个应用的启动页面,这个页面很简单,只有一个图片,并且要求是点击返回按键并不能销毁页面,过3s后自动跳转到MainActivity,并销毁此页面,布局如下:


android:layout_width="match_parent"

android:layout_height="match_parent"

android:background="#FFFFFF"

android:orientation="vertical">

android:id="@+id/iv_launcher"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:scaleType="centerCrop"

android:src="@mipmap/ic_launcher"/>

Activity代码如下:

importandroid.app.Activity;

importandroid.content.Intent;

importandroid.os.Bundle;

importandroid.os.Handler;

importandroid.os.Looper;

importandroid.view.WindowManager;

importcom.android.testdemo.R;

public classEntryActivityextendsActivity {

private static final intLAUNCHING_DURATION=3000;// Stay here for 3s.

@Override

protected voidonCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_entry);

newHandler(Looper.getMainLooper()).postDelayed(newRunnable() {

@Override

public voidrun() {

Intent intent =newIntent(EntryActivity.this, MainActivity.class);

startActivity(intent);

finish();

}

},LAUNCHING_DURATION);

}

@Override

public voidonBackPressed() {

//        启动页不允许返回

//        super.onBackPressed();

}

}

Activity测试代码如下:

importandroid.app.Activity;

importandroid.os.Process;

importandroid.test.ActivityInstrumentationTestCase2;

importandroid.util.Log;

importandroid.view.KeyEvent;

importandroid.view.View;

importandroid.widget.ImageView;

importcom.android.testdemo.R;

importjunit.framework.TestCase;

importjava.util.Locale;

public classEntryActivityTestextendsActivityInstrumentationTestCase2 {

privateActivitymActivity;

public static finalStringTAG="EntryActivityTest";

publicEntryActivityTest() {

super(EntryActivity.class);

}

public voidsetUp()throwsException {

super.setUp();

setActivityInitialTouchMode(false);

mActivity= getActivity();

}

public voidtearDown()throwsException {

Log.e(TAG,"tearDown");

}

public voidtestOnResume(){

finalImageView ivLauncher = (ImageView)mActivity.findViewById(R.id.iv_launcher);

Log.e(TAG,String.format(Locale.getDefault(),"Test ThreadId = %s , Process ID = %s",Thread.currentThread().getId(), Process.myPid()));

mActivity.runOnUiThread(newRunnable() {

@Override

public voidrun() {

ivLauncher.performClick();

Log.e(TAG,String.format(Locale.getDefault(),"UI ThreadId = %s ,Process ID = %s",Thread.currentThread().getId(), Process.myPid()));

}

});

try{

if(ivLauncher.getVisibility()!= View.VISIBLE){

fail("ivLauncher is not visible");

}

if(mActivity.isFinishing()){

fail("UnExcept click events on ImageView iv_launcher");

}

}catch(Exception e){

}

}

public voidtestOnBackPressed()throwsException {

sendKeys(KeyEvent.KEYCODE_BACK);

try{

Thread.sleep(500);

if(mActivity.isFinishing()){

fail("onBackPressed is validate");

}

}catch(Exception e){

}

}

}

经过运行测试用例,可以测试通过,并且测试的Log日志如图9:

(图9)

通过对比日志,两个测试线程确实不在UI线程当中,但是测试线程和执行线程在同一个进程当中。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容