Android单元测试之Robolectric

前言

在博客Android单元测试之PowerMockito,主要介绍PowerMockito的使用和对Java测试用例的强大支持。但对于Android app开发来说,写起单元测试很痛苦:一方面单元测试需要运行在模拟器上或者真机上,不仅麻烦而且缓慢;另一方面,一些依赖Android SDK的对象(如Activity,Button等)的测试非常头疼。Robolectric可以解决此类问题,它的设计思路便是通过实现一套JVM能运行的Android代码,从而做到脱离Android环境进行测试。本文将结合项目对Robolectric做一个简单介绍,并列举在实践踩的各种坑。

Robolectric简介

我们可以使用Android提供的Instrumentation系统如ActivityUnitTestCase、ActivityInstrumentationTestCase2,将单元测试代码运行在模拟器或者是真机上。虽然这种方式可以work,但是速度非常慢,因为每次运行一次单元测试,都需要将整个项目打包成apk,上传到模拟器或真机上,就跟运行了一次app似得,这个显然不是单元测试该有的速度。此外,Google开源的测试框架如UIAutomatorEspresso也是基于Instrumentation的,更偏向于UI方面的自测化测试,要是应用在单元测试上速度也是不敢恭维的。

对了,说一句题外话,感兴趣的同学可以看一下ActivityUnitTestCase和ActivityInstrumentationTestCase2的源码,你会惊奇地发现,它们的实现方式还是有所区别,虽然都是依赖Instrumentation把Activity加载起来,运行在同一个进程中,但ActivityUnitTestCase是运行在UI主线程中的,而ActivityInstrumentationTestCase2是运行在子线程中的,所以在实际的使用中还是有区别的,ActivityUnitTestCase可以直接操控UI,而ActivityInstrumentationTestCase2则是不行,需要借助于runOnUiThread()方法来更新UI,否则会抛异常。

言归正传吧,我们还是接着说Robolectric。Robolectric通过实现一套JVM能运行的Android代码,然后在unit test运行的时候去截取android相关的代码调用,然后转到自己实现的代码去执行这个调用的过程。举个例子说明一下,比如Android里面有个类叫Button,Robolectric则实现了一个叫ShadowButton类。这个类基本上实现了Button的所有公共接口。假设你在unit test里面写到String text = button.getText().toString();,在这个unit test运行时,Robolectric会自动判断你调用了Android相关的代码button.getText(),在底层截取这个调用过程,转到ShadowButton的getText方法来执行。而ShadowButton是真正实现了getText这个方法的,所以这个过程便可以正常执行。

除了实现Android里面的类的现有接口,Robolectric还做了另外一件事情,极大地方便了unit testing的工作。那就是他们给每个Shadow类额外增加了很多接口,方便我们读取对应Android类的一些状态。比如ImageView有一个方法叫setImageResource(resourceId),然而并没有一个对应的getter方法叫getImageResourceId(),这样你是没有办法测试这个ImageView是不是显示了你想要的image。而在Robolectric实现的ShadowImageView里面,则提供了getImageResourceId()这个接口,你可以用来测试它是否正确的显示了你想要的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之前的版本是有这个RobolectricGradleTestRunner,但在最新的版本上却没有了,也不知道是为什么。但是有一点,使用最新版本后,倒是没有出现找不到资源文件res的警告。最新的Robolectric最高可支持Android API 23。

Android Studio环境配置:

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

Test Artifact.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 Configurations.png

第二步设置如下:


Run/Debug Configurations.png

设置完毕后,再次run就可以了。

Robolectric实战

首先在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'

从配置中,可以看出在实际运用中,我们是使用JUnit4+Mockito+PowerMockito+Robolectric,这是一个牛逼的组合,在写单元测试用例时简直溜得飞起,通过PowerMockito弥补Mockito测试框架不能mock静态方法、final方法和private方法的不足,还可以在JVM中就可以很方便的调用Android相关的类和方法,速度也比较快。

然后定义抽象类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();
            }
        });
    }

}

这个抽象类代码比较多,主要是设置Robolectric单元测试的运行环境,方便在单元测试用例代码中进行复用。具体分下一下:

  1. @RunWith(RobolectricTestRunner.class)通过注解定义Robolectric运行的TestRunner;
  2. @Config(shadows = {ShadowLog.class}, constants = BuildConfig.class, sdk = 23)通过配置shadows = {ShadowLog.class}ShadowLog.stream = System.out;来设置Android log输出方式,使得单元测试运行时在控制台中可以看到Android代码中打印出的log日志;
  3. @PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*", "org.json.*", "sun.security.*", "javax.net.*"})通过PowerMockIgnore注解定义所忽略的package路劲,防止所定义的package路径下的class类被PowerMockito测试框架mock;
  4. 在setUp()方法中调用MockitoAnnotations.initMocks(this);初始化PowerMockito注解,为@PrepareForTest(YourStaticClass.class)注解提供支持;
  5. 在代码中,我们可以看到定义了两个基本方法getApplication()和getContext(),在写测试代码中使用起来很方便,就像在Activity一样,增加测试的可读性;
  6. 如果项目中使用了rxjava框架,在对rxjava相关的代码进行单元测试时,通过initRxJava()方法将异步处理转化为同步处理,如此一来方便单元测试验证;
最后编写Activity测试用例代码:
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());
    }

}

上面前一部分代码主要设置ComplaintActivity运行所依赖的属性,这也是在单元测试最为繁琐的地方,因为不是运行在真实的Android环境中。具体分析如下:

  1. 通过注解@PrepareForTest({AppUtil.class, OAuthManager.class, NetUtil.class})定义PowerMockito要mock的类;
  2. 在Robolectric中读取不到apk的版本号,通过PowerMockito.when(AppUtil.getVersionName()).thenReturn("1.4.0");mock指定AppUtil.getVersionName()的返回值"1.4.0",即版本号;
  3. 通过AppApplication.mInstance = getApplication();使用Robolectric运行环境中的application对AppApplication.mInstance进行依赖注入,因为在很多类中都会用到AppApplication.mInstance进行初始化,例如SharedPreference、SQlite、单例类等,
PreferenceUtil.init();
PersistentPreferenceUtil.init();

上面代码就需要依赖AppApplication.mInstance进行初始化;

  1. ComplaintActivity complaintActivity = Robolectric.buildActivity(ComplaintActivity.class).create().get();使用Robolectric创建ComplaintActivity对象,其中create()方法就是对应于调用Activity生命周期的onCreate()方法,此外Robolectric支持链式调用如:Robolectric.buildActivity(ComplaintActivity.class).create().resume().get();
  2. assertNotNull(complaintActivity);验证complaintActivity是否跑起来;
  3. 最后一部分代码就是调用jumpCompensate方法进行跳转,验证跳转的Intent是否符合预期;

至于其他的一些如Fragment、Dialog、Toast等验证,可以参考这篇博客,这里就不展开。

Robolectric常见的坑

1.Application空指针问题

这是因为SharedPreferences和单例等类初始化时需要依赖Application对象,我们常见的用法是使用Application.getApplication()方法来获取,在Robolectric中则是需要使用RuntimeEnvironment.application来进行替换,上面就是通过依赖的方式进行替换。

2. AppCompatActivity错误

假如你在Robolectric的@Config注解中配置了manifest = Config.NONE,那就完蛋了,因为在网上根本找不解决的方法,你遇到如下异常不能使用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配置,这是坑爹的,我就遇到这个错误,花了好长一段时间才发现是这个配置导致的。

3.Asset文件路径错误

需要用到context.getAssets().open("XXX")加载asset目录下的文件时,要是遇到以下错误:

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"));

这个方式有点丑,需要用到你要加载的文件的绝对路径,灵活性低,不方便移植,不过这是我目前想到的解决方式。

4.找不到android.net.http.AndroidHttpClient的类文件

在Android API23开始,google就移除了HttpClient相关的类,有两种方法解决上述问题。
方法一:在build.gradle添加应用useLibrary ‘org.apache.http.legacy’
方法二:在test目录下添加HttpClient类(记得包名为android.net.http),如下:


AndroidHttpClient.png

说明:推荐使用第二种方式,第二种方法正式打包并不会把HttpClient的类加入,减少了包中无用的资源。

小结

在实际的使用中,Robolectric需要踩很多坑的,不过贵在尝试。至此,单元测试系列博客已经完结,主要分了四篇博客来讲述。非常感谢您对本篇博客的支持,要是有什么不足欢迎指正!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,497评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,599评论 18 139
  • 背景 Mock、PowerMock、Junit等都只是在java层面的单元测试。但对于android app开发来...
    johnnycmj阅读 2,931评论 1 2
  • 我是可诺妈,养育一对可爱双胞胎兄妹,这本书从怀孕开始就读了,断断续续到今天两娃娃2周岁,当双胞胎兄妹出生头一年,我...
    绘爱正面管教阅读 2,333评论 1 2
  • 一:简介 Retrofit是Square公司开发的一款针对Android网络请求的框架,Retrofit2底层基于...
    往事一块六毛八阅读 526评论 0 0