android Robolectric 运用实践

前言

对于Android app来说,写起单元测试来瞻前顾后,一方面单元测试需要运行在模拟器上或者真机上,麻烦而且缓慢,另一方面,一些依赖Android SDK的对象(如Activity,TextView等)的测试非常头疼,Robolectric可以解决此类问题,它的设计思路便是通过实现一套JVM能运行的Android代码,从而做到脱离Android环境进行测试。本文对Robolectric3.0做了简单介绍,并列举了如何对Android的组件和常见功能进行测试的示例。

一、完整的一个测试类

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 21,
        shadows = {CustomShadowApplication.class,
                CustomShadowOkHttpClient.class, CustomShadowXYJHttpUtils.class})
public class LoginActivityTest {

    private LoginActivity loginActivity;

    /**
     * 执行初始化的操作
     *
     * @throws Exception
     */
    @Before
    public void setUp() throws Exception {
        loginActivity = Robolectric.setupActivity(LoginActivity.class);
        loginActivity.onCreate(null);
    }

    @After
    public void tearDown() throws Exception {
        CustomShadowXYJHttpUtils.reset();
    }

    @Test
    public void should_show_message_when_account_is_empty() {
        //given  --准备条件
        TextView userNameEditText = field("loginUsernameEdt").ofType(TextView.class).in(loginActivity).get();
        userNameEditText.setText("");

        //when  --函数执行
        TextView loginButton = (TextView) loginActivity.findViewById(R.id.login_button);
        clickOn(loginButton);

        //then  -- 结果的返回值
        assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo("请输入用户名");
    }
}

代码覆盖率:
1>.语句覆盖:保证每一个语句都执行到了
2>.判定覆盖(分支覆盖):保证每一个分支都执行到
3>.条件覆盖:保证每一个条件都覆盖到true和false(即if、while中的条件语句)
4>.路径覆盖:保证每一个路径都覆盖到

代码测试覆盖率查看:
在Android Studio 开发工具中配置查看
1.选中并运行编写的所有测试用例.

步骤1.jpg

2、配置被测试对象

操作2.png
操作3.png

3.选中测试类--->点击Code Coverage--->点击加号添加被测试类--->完成

4.运行测试,选择Run 'Suites' with Coverage

操作4.png

5 Coverage Suites窗口会生成测试报告

操作5.png

6 下载测试报告到本地,选择绿色向上箭头选择路径下载

操作6.png

操作7.png

可以使用jacoco得到测试的代码覆盖率.
1.环境配置:

buildTypes {
        debug {
            testCoverageEnabled = true
        }
    }

2.在命令行执行,获得代码覆盖率的报告命令为createDebugCoverageReport

F:\Robolectric\Youdu_UnitTest>gradle clean createDebugCoverageReport
Observed package id 'build-tools;23.0.0-preview' in inconsistent location 'E:\tools\android-sdk\android-sdk\build-tools\23.0.0_rc2' (Expected 'E:\tools\android-sdk\android-sdk\build-tools\23.0.0-preview')
Observed package id 'build-tools;20.0.0' in inconsistent location 'E:\tools\android-sdk\android-sdk\build-tools\android-4.4W' (Expected 'E:\tools\android-sdk\android-sdk\build-tools\20.0.0')
Incremental java compilation is an incubating feature.                      
Download https://jcenter.bintray.com/org/jacoco/org.jacoco.agent/0.7.4.201502262128/org.jacoco.agent-0.7.4.201502262128.pom
Download https://jcenter.bintray.com/org/jacoco/org.jacoco.agent/0.7.4.201502262128/org.jacoco.agent-0.7.4.201502262128.jar
:clean                                                 
:app:clean                
:app:preBuild UP-TO-DATE     
:app:preDebugBuild UP-TO-DATE     
:app:checkDebugManifest                
:app:preReleaseBuild UP-TO-DATE     
:app:prepareComAndroidSupportAnimatedVectorDrawable2511Library                
:app:prepareComAndroidSupportAppcompatV72511Library                 
:app:prepareComAndroidSupportSupportCompat2511Library                 
:app:prepareComAndroidSupportSupportCoreUi2511Library                 
:app:prepareComAndroidSupportSupportCoreUtils2511Library                 
:app:prepareComAndroidSupportSupportFragment2511Library                 
:app:prepareComAndroidSupportSupportMediaCompat2511Library                 
:app:prepareComAndroidSupportSupportV42511Library                 
:app:prepareComAndroidSupportSupportVectorDrawable2511Library                 
:app:prepareDebugDependencies                 
:app:compileDebugAidl                 
:app:compileDebugRenderscript                 
:app:generateDebugBuildConfig                 
:app:generateDebugAssets UP-TO-DATE      
:app:mergeDebugAssets                 
:app:generateDebugResValues UP-TO-DATE      
:app:generateDebugResources                 
:app:mergeDebugResources                 
:app:processDebugManifest                 
:app:processDebugResources                 
:app:generateDebugSources                 
:app:compileDebugJavaWithJavac                 
:app:compileDebugNdk UP-TO-DATE      
:app:compileDebugSources                 
:app:prePackageMarkerForDebug                 
:app:unzipJacocoAgent                 
:app:transformClassesWithJacocoForDebug                 
:app:transformClassesWithDexForDebug                 
:app:mergeDebugJniLibFolders                 
:app:transformNative_libsWithMergeJniLibsForDebug                 
:app:processDebugJavaRes UP-TO-DATE      
:app:transformResourcesWithMergeJavaResForDebug                 
:app:validateDebugSigning                 
:app:packageDebug                 
:app:zipalignDebug                 
:app:assembleDebug                 
:app:preDebugAndroidTestBuild UP-TO-DATE      
:app:prepareDebugAndroidTestDependencies                 
:app:compileDebugAndroidTestAidl                 
:app:processDebugAndroidTestManifest                 
:app:compileDebugAndroidTestRenderscript                 
:app:generateDebugAndroidTestBuildConfig                 
:app:generateDebugAndroidTestAssets UP-TO-DATE      
:app:mergeDebugAndroidTestAssets                 
:app:generateDebugAndroidTestResValues UP-TO-DATE      
:app:generateDebugAndroidTestResources                 
:app:mergeDebugAndroidTestResources                 
:app:processDebugAndroidTestResources                 
:app:generateDebugAndroidTestSources                 
:app:compileDebugAndroidTestJavaWithJavac                 
注: F:\Robolectric\Youdu_UnitTest\app\src\androidTest\java\xyj\com\youdu_unittest\ApplicationTest.java使用或覆盖了已过时的 API。                                                                                                    
注: 有关详细信息, 请使用 -Xlint:deprecation 重新编译。                                                                                                                                                                                             
:app:compileDebugAndroidTestNdk UP-TO-DATE                
:app:compileDebugAndroidTestSources                 
:app:prePackageMarkerForDebugAndroidTest                 
:app:transformClassesWithDexForDebugAndroidTest                 
:app:mergeDebugAndroidTestJniLibFolders                 
:app:transformNative_libsWithMergeJniLibsForDebugAndroidTest                 
:app:processDebugAndroidTestJavaRes UP-TO-DATE      
:app:transformResourcesWithMergeJavaResForDebugAndroidTest                 
:app:packageDebugAndroidTest                 
:app:assembleDebugAndroidTest                 
:app:connectedDebugAndroidTest                 
:app:createDebugAndroidTestCoverageReport                                                                  
Download https://jcenter.bintray.com/org/jacoco/org.jacoco.ant/0.7.4.201502262128/org.jacoco.ant-0.7.4.201502262128.pom
Download https://jcenter.bintray.com/org/jacoco/org.jacoco.report/0.7.4.201502262128/org.jacoco.report-0.7.4.201502262128.pom     
Download https://jcenter.bintray.com/org/jacoco/org.jacoco.ant/0.7.4.201502262128/org.jacoco.ant-0.7.4.201502262128.jar           
Download https://jcenter.bintray.com/org/jacoco/org.jacoco.report/0.7.4.201502262128/org.jacoco.report-0.7.4.201502262128.jar
:app:createDebugCoverageReport                                                       
               
BUILD SUCCESSFUL
               
Total time: 1 mins 3.45 secs

二、Shadow的使用

Shadow是Robolectric的立足之本,如其名,作为影子,一定是变幻莫测,时有时无,且依存于本尊。因此,框架针对Android SDK中的对象,提供了很多影子对象(如Activity和ShadowActivity、TextView和ShadowTextView等),这些影子对象,丰富了本尊的行为,能更方便的对Android相关的对象进行测试。

上述的实例中有如:CustomShadowOkHttpClient,CustomShadowXYJHttpUtils等类,这些类是为了模拟那些不好编写测试用例而作为一个影子,提供方便我们用于模拟业务场景进行测试的api。

  1. 使用框架提供的Shadow对象
@Test
    public void testActivityShadow() {
        MainActivity mainActivity = Robolectric.setupActivity(MainActivity.class);
        TextView textView = field("textView").ofType(TextView.class).in(mainActivity).get();
        Intent expectedIntent = new Intent(mainActivity, LoginActivity.class);

        clickOn(textView);

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

        assertThat(shadowActivity.getNextStartedActivity()).isEqualTo(expectedIntent);
        assertThat(shadowApplication.getNextStartedActivity()).isNull();

    }
  1. 如何自定义Shadow对象
    首先,创建原始对象UserInfo
public class UserInfo {
    private String userName;
    private String password;
    
    public String getUserName() {
        return userName;
    }
    public void setUserName(String userName) {
        this.userName = userName;
    }
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
}

其次,创建UserInfo 的Shadow对象

@Implements(UserInfo.class)
public class ShadowUserInfo {
    @Implementation
    public String getPassword() {
        return "123456";
    }

    @Implementation
    public String getUserName() {
        return "admin";
    }
}

接下来,需自定义TestRunner,添加UserInfo对象为要进行Shadow的对象

public class YouduTestRunner extends RobolectricGradleTestRunner {
    public YouduTestRunner(Class<?> klass) throws InitializationError {
        super(klass);
    }

    @Override
    public InstrumentationConfiguration createClassLoaderConfig() {
        InstrumentationConfiguration.Builder builder = InstrumentationConfiguration.newBuilder();
        /**
         * 添加要进行Shadow的对象
         */
        builder.addInstrumentedClass(UserInfo.class.getName());

        return builder.build();
    }

//    @Override
//    protected AndroidManifest getAppManifest(Config config) {
//        String manifestPath = BUILD_OUTPUT + "manifests/full/debug/AndroidManifest.xml";
//        String resDir = BUILD_OUTPUT + "res/merged/debug";
//        String assetsDir = BUILD_OUTPUT + "assets/debug";
//
//        AndroidManifest manifest = createAppManifest(Fs.fileFromPath(manifestPath),
//                Fs.fileFromPath(resDir),
//                Fs.fileFromPath(assetsDir),"com.uthing");
//        return manifest;
//    }

最后,在测试用例中,ShadowPerson对象将自动代替原始对象,调用Shadow对象的数据和行为

@RunWith(YouduTestRunner.class)
@Config(constants = BuildConfig.class,
        sdk = 21, shadows = {ShadowUserInfo.class})
public class ShadowTest {
    @Before
    public void setUp() throws Exception {
    }
    @After
    public void tearDown() throws Exception {
    }

    @Test
    public void testCustomShadow() throws Exception {
        UserInfo userInfo = new UserInfo();
        //getName()实际上调用的是ShadowPerson的方法
        assertThat(userInfo.getUserName()).isEqualTo("admin");
        //获取userInfo对象对应的Shadow对象
        ShadowUserInfo shadowPerson = (ShadowUserInfo) ShadowExtractor.extract(userInfo);
        assertThat("123456").isEqualTo(shadowPerson.getPassword());
    }
}

以上就是shadow一个对象的完成过程。在业务逻辑中可根据具体场景来shadow来模拟想要的数据,编写相应的测试用例。

三、Mockito 的使用

所谓的mock就是创建一个类的虚假的对象,在测试环境中,用来替换掉真实的对象,以达到两大目的:

  1. 验证这个对象的某些方法的调用情况,调用了多少次,参数是什么等等
  2. 指定这个对象的某些方法的行为,返回特定的值,或者是执行特定的动作

要使用Mock,一般需要用到mock框架,我们使用 Mockito 这个框架,这个是Java中使用最广泛的一个mock框架。

例如:
Mock一个List类型的对象实例,可以采用如下方式:

List list = mock(List.class);   //mock得到一个对象,也可以用@mock注入一个对象

所得到的list对象实例便是List类型的实例,如果不采用mock,List其实只是个接口,我们需要构造或者借助ArrayList才能进行实例化。与Shadow不同,Mock构造的是一个虚拟的对象,用于解耦真实对象所需要的依赖。Mock得到的对象仅仅是具备测试对象的类型,并不是真实的对象,也就是并没有执行过真实对象的逻辑。

四、测试实例

  1. 创建Activity实例
  @Test
    public void testActivity() {
        MainActivity sampleActivity = Robolectric.setupActivity(MainActivity.class);
        assertNotNull(sampleActivity);
        assertEquals(sampleActivity.getTitle(), "首页");
    }
  1. 生命周期
@Test
public void testLifecycle() {
     ActivityController<SampleActivity> activityController = Robolectric.buildActivity(SampleActivity.class).create().start();
     Activity activity = activityController.get();
     TextView textview = (TextView) activity.findViewById(R.id.tv_lifecycle_value);
     assertEquals("onCreate",textview.getText().toString());
     activityController.resume();
     assertEquals("onResume", textview.getText().toString());
     activityController.destroy();
     assertEquals("onDestroy", textview.getText().toString());
 }

3.UI组件状态

    @Test
    public void should_update_ui_when_click_login_button() {
        //given
        CheckBox checkBox = (CheckBox) loginActivity.findViewById(R.id.remember_passWord_checkbox);
        Button userNameButton = field("loginButton").ofType(Button.class).in(loginActivity).get();
        EditText userNameEditText = field("userNameEditText").ofType(EditText.class).in(loginActivity).get();
        EditText passwordEditText = field("passwrodEditText").ofType(EditText.class).in(loginActivity).get();

        //when  --函数执行
        userNameEditText.setText("admin");
        passwordEditText.setText("123");
        assertTrue(userNameButton.isEnabled());
        checkBox.setChecked(true);

        //then  -- 结果的返回值
        clickOn(checkBox);
        assertThat(checkBox.isChecked()).isFalse();
        userNameButton.performClick();
        assertThat(checkBox.isChecked()).isTrue();
    }

4.跳转

@Test
    public void testStartActivity() {
        Button nextButton = (Button) sampleActivity.findViewById(R.id.main_button);
        nextButton.performClick(); //按钮点击后跳转到下一个Activity
        Intent expectedIntent = new Intent(sampleActivity, LoginActivity.class);
        Intent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();
        assertEquals(expectedIntent, actualIntent);
    }

5.Dialog

@Test
public void testDialog(){
  
    dialogBtn.performClick();
     AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
     assertNotNull(latestAlertDialog);
 }

6.Toast

   @Test
    public void should_show_message_when_account_is_empty() {
        //given  --准备条件
        EditText userNameEditText = field("userNameEditText").ofType(EditText.class).in(loginActivity).get();
        userNameEditText.setText("");

        //when  --函数执行
        TextView loginButton = (TextView) loginActivity.findViewById(R.id.btn_login);
        clickOn(loginButton);

        //then  -- 结果的返回值
        assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo("请输入用户名");

    }


7.Fragment的测试
Fragment是Activity的一部分,在Robolectric模拟执行Activity过程中,如果触发了被测试的代码中的Fragment添加逻辑,Fragment会被添加到Activity中。需要注意Fragment出现的时机,如果目标Activity中的Fragment的添加是执行在onResume阶段,在Activity被Robolectric执行resume()阶段前,该Activity中并不会出现该Fragment。采用Robolectric主动添加Fragment的方法如下:

@Test
public void addfragment(Activity activity, int fragmentContent){
    FragmentTestUtil.startFragment(activity.getSupportFragmentManager().findFragmentById(fragmentContent));
    Fragment fragment = activity.getSupportFragmentManager().findFragmentById(fragmentContent);
    assertNotNull(fragment);
}

startFragment()函数的主体便是常用的添加fragment的代码。切换一个Fragment往往由Activity中的代码逻辑完成,需要Activity的引用。

总结
单元测试并不是一个能直接产生回报的工程,它的运行以及覆盖率也不能直接提升代码质量,但其带来的代码控制力能够大幅度降低大规模协同开发的风险。现在的商业App开发都是大型团队协作开发,不断会有新人加入,无论新人是刚入行的应届生还是工作多年,在代码存在一定业务耦合度的时候,修改代码就有一定风险,可能会影响之前比较隐蔽的业务逻辑,或者是丢失曾经的补丁,如果有高覆盖率的单元测试工程,就能很快定位到新增代码对现有项目的影响,与QA验收不同,这种影响是代码级的。

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

推荐阅读更多精彩内容

  • 一.基本介绍 背景: 目前处于高速迭代开发中的Android项目往往需要除黑盒测试外更加可靠的质量保障,这正是单元...
    anmi7阅读 2,012评论 0 6
  • Android单元测试介绍 处于高速迭代开发中的Android项目往往需要除黑盒测试外更加可靠的质量保障,这正是单...
    东经315度阅读 3,086评论 6 37
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,373评论 25 707
  • afinalAfinal是一个android的ioc,orm框架 https://github.com/yangf...
    passiontim阅读 15,392评论 2 45
  • 我是日记星球226号星宝宝婷婷,我在参加日记星球第五期的21天蜕变之旅,这是我在日记星球写的第21篇原创日记。...
    天鸣老师阅读 744评论 2 3