Android 单元测试之Robolectric

背景

Mock、PowerMock、Junit等都只是在java层面的单元测试。但对于android app开发来说,单元测试需要运行在模拟器上或者真机上,不仅麻烦而且缓慢,而且一些依赖Android SDK的对象(如Activity,Button等)的测试非常头疼。那么Robolectric可以解决这些问题。

简介

我们可以使用Android提供的Instrumentation系统如ActivityUnitTestCase、ActivityInstrumentationTestCase2,将单元测试代码运行在模拟器或者是真机上。Google开源的测试框架如UIAutomator和Espresso也是基于Instrumentation的,更偏向于UI方面的自测化测试,要是应用在单元测试上速度也是很慢的。Robolectric通过实现一套JVM能运行的Android代码,然后在unit test运行的时候去截取android相关的代码调用,然后转到自己实现的代码去执行这个调用的过程。

Robolectric入门

依赖配置

在build.gradle 中配置Robolectric的依赖包

testCompile "org.robolectric:robolectric:3.2.1"
testCompile "org.robolectric:robolectric-annotations:3.2.1"
//robolectric针对support-v4的shadows
testCompile "org.robolectric:shadows-support-v4:3.2.1"

AndroidStudio 配置

1、在Build Variants面板中,将Test Artifact切换成Unit Tests模式(注:新版本的as已经不需要做这项配置),如下图:

rob_as.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 conf.jpg
working dir.jpg

Activity的测试

我们写了一个Activity

public class DemoActivity extends BaseActivity implements View.OnClickListener{

    private TextView mTextView;
    private Button mForwardBtn;
    private Button mDialogBtn;
    private Button mAlertDialogBtn;
    private Button mToastBtn;
    private StudentDialog mAddDailog;

    private Button mInverseBtn;
    private CheckBox mCheckbox;
    private Button mDelay;

    public boolean isTaskFinish = false;

    @Override
    protected void loadViewLayout() {
        setContentView(R.layout.test_demo_activity);
    }

    @Override
    protected void loadIntent() {

    }

    @Override
    protected void bindViews() {
        mTextView = (TextView) findViewById(R.id.tv_lifecycle_label);
        mForwardBtn = (Button) findViewById(R.id.forward);
        mDialogBtn = (Button) findViewById(R.id.dialog);
        mAlertDialogBtn = (Button) findViewById(R.id.alertdialog);
        mToastBtn = (Button) findViewById(R.id.toast);
        mInverseBtn = (Button) findViewById(R.id.btn_inverse);
        mDelay = (Button) findViewById(R.id.delay);
        mCheckbox = (CheckBox) findViewById(R.id.checkbox);
    }

    @Override
    protected void processLogic(Bundle savedInstanceState) {

    }

    @Override
    protected void setListener() {
        mForwardBtn.setOnClickListener(this);
        mDialogBtn.setOnClickListener(this);
        mToastBtn.setOnClickListener(this);
        mInverseBtn.setOnClickListener(this);
        mDelay.setOnClickListener(this);
        mAlertDialogBtn.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        if(v.getId() == R.id.forward){
            forward();
        }else if(v.getId() == R.id.dialog){
            showDialog();
        }else if(v.getId() == R.id.toast){
            showToast("toast");
        }else if(v.getId() == R.id.btn_inverse){
            inverse();
        }else if(v.getId() == R.id.delay) {
            executeDelayedTask();
        }else if(v.getId() == R.id.alertdialog) {
            showAlertDialog();
        }
    }
    @Override
    protected void onStart() {
        super.onStart();
        mTextView.setText("onStart");
    }

    @Override
    protected void onResume() {
        super.onResume();
        mTextView.setText("onResume");
    }

    @Override
    protected void onPause() {
        super.onPause();
        mTextView.setText("onPause");
    }

    @Override
    protected void onStop() {
        super.onStop();
        mTextView.setText("onStop");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mTextView.setText("onDestroy");
    }

    private void forward(){
        intent2Activity(PageActivity.class);
    }

    private void showDialog(){
        mAddDailog = new StudentDialog(this).setOnDialogClickListener(onDialogClickListener);
        mAddDailog.show();
    }

    private void showToast(String message){
        ToastUtils.showShort(message);
    }

    private void inverse(){
        mCheckbox.setChecked(!mCheckbox.isChecked());
    }

    private void executeDelayedTask(){
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                isTaskFinish = true;
            }
        },2000);
    }

    public void showAlertDialog(){
        AlertDialog alertDialog = new AlertDialog.Builder(this).setMessage("dialog")
                .setTitle("dialog").create();
        alertDialog.show();
    }

    private OnDialogClickListener onDialogClickListener = new OnDialogClickListener() {
        @Override
        public void onDialogClick(Dialog dialog, @ClickPosition String clickPosition) {
            if(clickPosition.equals(ClickPosition.CANCEL)){
                mAddDailog.cancel();
            }else if(clickPosition.equals(ClickPosition.SUBMIT)){
                showToast("添加成功");
            }
        }
    };
}

DemoActivityTest类的配置

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class , sdk = 25)
public class DemoActivityTest {
    private DemoActivity demoActivity;
    private Button mForwardBtn;
    private Button mDialogBtn;
    private Button mToastBtn;
    private Button mInverseBtn;
    private Button mDelay;
    private Button mAlertDialogBtn;
    private CheckBox mCheckbox;
    
    @Before
    public void setUp() throws Exception {
        demoActivity = Robolectric.setupActivity(DemoActivity.class);
        mForwardBtn = (Button) demoActivity.findViewById(R.id.forward);
        mDialogBtn = (Button) demoActivity.findViewById(R.id.dialog);
        mToastBtn = (Button) demoActivity.findViewById(R.id.toast);
        mInverseBtn = (Button) demoActivity.findViewById(R.id.btn_inverse);
        mAlertDialogBtn = (Button) demoActivity.findViewById(R.id.alertdialog);
        mDelay = (Button) demoActivity.findViewById(R.id.delay);
        mCheckbox = (CheckBox) demoActivity.findViewById(R.id.checkbox);

    }
}

在setUp()中将view初始化,和启动DemoActivity。

Config注解:

  1. 配置SDK版本: Robolectric会使用你在manifest中指定的targetSdkVersion版本来运行测试代码。如果你想测试在其它指定版本的表现,可以使用 sdk = 25。
  2. 配置Application类: Robolectric会根据manifest的配置自动帮你创建一个Application类,如果你希望提供一个自己实现的类,@Config(application = CustomApplication.class)
  3. 配置Resource路径:Robolectric为Gradle和Maven提供了默认的设置,但是也允许你修改这些资源的路径,包括manifest, resource目录,assets目录。如果你有一个自定义的生成脚本这会非常有用:@Config(manifest = "some/build/path/AndroidManifest.xml")

测试Activity.

@Test
public void testActivity() throws Exception {
    //判断demoActivity不为空,启动成功
    assertNotNull(demoActivity);
    assertThat("true",demoActivity,is(notNullValue()));
}

测试Activity的生命周期

@Test
public void testActivityLifecycle() throws Exception{
    ActivityController<DemoActivity> activityController = Robolectric.buildActivity(DemoActivity.class).create();
    Activity activity = activityController.get();
    TextView mTextView = (TextView) activity.findViewById(R.id.tv_lifecycle_label);

    //调用start()方法,则DemoActivity中mTextView值设置成onStart
    activityController.start();
    //判断mTextView值是否是onStart
    assertEquals("onStart",mTextView.getText().toString());

    //onResume
    activityController.resume();
    assertEquals("onResume",mTextView.getText().toString());

    //onPause
    activityController.pause();
    assertEquals("onPause",mTextView.getText().toString());

    //onStop
    activityController.stop();
    assertEquals("onStop",mTextView.getText().toString());

    //onStop
    activityController.destroy();
    assertEquals("onDestroy",mTextView.getText().toString());
}

测试Activity跳转

@Test
public void testStartActivity() throws Exception {
    //点击,则跳转
    mForwardBtn.performClick();

    //目标Intent
    Intent expectedIntent = new Intent(demoActivity, PageActivity.class);
    //Robolectric启动的Intent
    Intent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();

    assertEquals(expectedIntent.getComponent(),actualIntent.getComponent());
}

Dialog 测试.

@Test
public void testAlertDialog() throws Exception {
    mAlertDialogBtn.performClick();
    AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
    assertNotNull(latestAlertDialog);
}

Toast 测试

@Test
public void testToast() throws Exception {
    mToastBtn.performClick();
    Toast toast =  ShadowToast.getLatestToast();

    assertNotNull(toast);
    assertEquals("toast",ShadowToast.getTextOfLatestToast());
}

测试View状态

@Test
public void testViewState() throws Exception {
    assertTrue(mInverseBtn.isEnabled());

    mCheckbox.setChecked(true);
    //点击按钮,CheckBox反选
    mInverseBtn.performClick();
    assertTrue(!mCheckbox.isChecked());
    mInverseBtn.performClick();
    assertTrue(mCheckbox.isChecked());
}

测试资源文件访问

@Test
public void testResource() throws Exception {
    Application application = RuntimeEnvironment.application;
    String app_name = application.getString(R.string.app_name);

    assertEquals("test-component",app_name);
}

测试延迟.

@Test
public void testDelay() throws Exception {
    mDelay.performClick();
    assertFalse(demoActivity.isTaskFinish);

    //延迟执行完到UI主线程
    ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
    assertTrue(demoActivity.isTaskFinish);
}

广播的测试

广播的测试点可以包含两个方面:

  1. 一是应用程序是否注册了该广播
  2. 二是广播接受者的处理逻辑是否正确

首先定义广播接收:

public class StaticReceiver extends BroadcastReceiver {

    private String msg;

    @Override
    public void onReceive(Context context, Intent intent) {
        //获取广播的数据.
        msg = intent.getStringExtra("test");
        LogHelper.i("静态广播接收消息....." + msg);
    }

    public String getMsg(){
        return msg;
    }
}
@Test
public void testBroadcase() throws Exception {
    ShadowApplication application = ShadowApplication.getInstance();
    Intent intent = new Intent();

    intent.setAction("com.broadcast.static");
    intent.putExtra("test","test data 123");

    //测试是否注册广播接收者
    assertTrue(application.hasReceiverForIntent(intent));

    //广播接受者的处理逻辑是否正确
    StaticReceiver receiver = new StaticReceiver();
    receiver.onReceive(RuntimeEnvironment.application,intent);

    assertEquals("test data 123",receiver.getMsg());

}

Service 测试

public class SimpleService extends Service {

    private String msg;
    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        LogHelper.i("-------onCreate-------");
    }
  
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        msg = intent.getStringExtra("test");
        LogHelper.i("-------test-------" + msg);
        LogHelper.i("-------flags-------" + flags);
        LogHelper.i("-------startId-------" + startId);
        LogHelper.i("-------onStartCommand-------");

        return super.onStartCommand(intent, flags, startId);
    }
    @Override
    public void onDestroy() {

        LogHelper.i("-------onDestroy-------");
        super.onDestroy();
    }

    public String getMsg(){
        return msg;
    }
}
@Test
public void testService() throws Exception {
    Application application = RuntimeEnvironment.application;

    Intent intent = new Intent(application,SimpleService.class);
    intent.putExtra("test","数据传输");

    SimpleService serviceController = Robolectric.setupService(SimpleService.class);
    serviceController.onStartCommand(intent,0,1);

    assertEquals("数据传输",serviceController.getMsg());
}

默认Shadow测试

@Test
public void testDefaultShadow() throws Exception{
    DemoActivity demoActivity = Robolectric.setupActivity(DemoActivity.class);

    //通过Shadows.shadowOf()可以获取很多Android对象的Shadow对象
    ShadowActivity shadowActivity = Shadows.shadowOf(demoActivity);
    ShadowApplication application = Shadows.shadowOf(RuntimeEnvironment.application);

    Bitmap bitmap = BitmapFactory.decodeFile("Path");
    ShadowBitmap shadowBitmap = Shadows.shadowOf(bitmap);

    //Shadow对象提供方便我们用于模拟业务场景进行测试的api
    assertNull(shadowActivity.getNextStartedActivity());
    assertNull(application.getNextStartedActivity());
    assertNotNull(shadowBitmap);
}

自定义Shadow对象

我们有一个Person对象

public class Person {
    private int id;
    private String name;

    public Person(int id,String name){
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

其次,创建Person的Shadow对象

@Implements(Person.class)
public class ShadowPerson {

    @Implementation
    public String getName() {
        return "shadowPerson";
    }
}

在测试用例中,ShadowPerson对象将自动代替原始对象,调用Shadow对象的数据和行为。要记得在DemoActivityTest类上面@Config中加shadows = {ShadowPerson.class}

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class , sdk = 25,shadows = {ShadowPerson.class})
public class DemoActivityTest {

}
@Test
public void testCustomShadow() throws Exception {
    Person person = new Person(1,"genius");
    //getName()实际上调用的是ShadowPerson的方法
    assertEquals("shadowPerson", person.getName());

    //获取Person对象对应的Shadow对象
    ShadowPerson shadowPerson = (ShadowPerson) ShadowExtractor.extract(person);
    assertEquals("shadowPerson", shadowPerson.getName());
}

测试自定义Dialog

比如我们有一个StudentDialog继承Dialog,要怎么测试这个自定义Dialog。
首先自定义一个Shadow继承于ShadowDialog,然后重写show()方法。

@Implements(StudentDialog.class)
public class ShadowStudentDialog extends ShadowDialog {

    @Implementation
    public void show() {
        super.show();
        shadowOf(RuntimeEnvironment.application).setLatestDialog(this);
    }
}
@Test
public void testDialog() throws Exception {
    mDialogBtn.performClick();
    Dialog latestDialog = ShadowDialog.getLatestDialog();

    assertNotNull(latestDialog);
}

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

推荐阅读更多精彩内容