Android单元测试:测试RxJava的同步及异步操作

简述

在您开发的项目中,您使用了RxJava+Retrofit的网络请求框架,在RxJava强大的操作符下,您理所当然的认为您已经能够游刃有余地处理Android客户端开发中的联网请求需求,比如这样:

//Model层的网络请求
public class HomeModel extends BaseModel<ServiceManager> implements HomeContract.Model {

 @Override
 public Maybe<UserInfo> requestUserInfo(final String userName) {
      return serviceManager.getUserInfoService()
              .getUserInfo(userName)
              .subscribeOn(Schedulers.io())
              .observeOn(AndroidSchedulers.mainThread());
  }
}

//Presenter层的网络处理
public class HomePresenter extends BasePresenter<HomeContract.View, HomeContract.Model> implements HomeContract.Presenter {

    @Inject
    public HomePresenter(HomeContract.View rootView, HomeContract.Model model) {
        super(rootView, model);
    }

    @SuppressLint("VisibleForTests")
    @Override
    public void requestUserInfo(String userName) {
        mModel.requestUserInfo(userName)
                .subscribe(info -> Optional.ofNullable(info)
                                .map(UserInfo::getLogin)
                                .ifPresentOrElse(image -> mRootView.onGetUserInfo(info)
                                        , () -> mRootView.onError("用户信息为空"))
                        , e -> mRootView.onError("请求出现异常"));
    }
}

这是一个非常简单的MVP开发模式下的一个联网请求代码,用于请求用户的信息数据,我们不需要强制性理解每一行代码,我们需要关注的问题是,我们如何保证这些代码的正确性,即使在一些边界条件的限制下?

单元测试能够让项目中自己的代码更加健壮,但是无论是RxJava+Retrofit,亦或是okHttp3,异步操作的测试都是一个较高的门槛,加上目前国内开发环境的恶劣环境(周期短,需求变动反复),国内的相关技术文档相当匮乏。

Espresso的异步测试

笔者曾经花费一些时间研究Espresso的异步测试,并根据google官方的demo尝试总结在设备上进行测试异步操作的方案:

Android 自动化测试 Espresso篇:异步代码测试

这是可行的,但是有一些问题需要注意:

1.Espresso本质是依赖设备所进行的集成测试,这种测试是业务级的,测试的内容是一个整体的功能,相对于代码(方法)级别的单元测试,覆盖范围太大。
2.Espresso的测试依赖设备,所以进行一次完整的测试所消耗的时间更长。
3.Espresso更擅长UI的测试,而对于业务代码来说,Espresso并不擅长。

现在我们需要一种新的方式去测试业务代码,比如Junit+Mockito.

异步测试的难题

在尝试RxJava的单元测试时,不可避免的,你会遇到两个问题:

1.如何测试异步代码?
2.如何测试AndroidSchedulers.mainThread()?

1.异步操作如何转为同步进行单元测试?

前者的问题我们已经在之前的Android 自动化测试 Espresso篇:异步代码测试这篇文章中提到过了,那就是:

在我们还没有获取到数据进行验证时,测试代码已经跑完了。

以下面一段代码为例:

//开启一个子线程,每隔一秒发送一个数据,共发射10次,结果输出 0~9
@Test
fun test() {
Observable.intervalRange(0, 10, 0, 1, TimeUnit.SECONDS)
                .subscribeOn(Schedulers.io())
                .observeOn(Schedulers.io())
                .subscribe(i -> System.out.println(i))
}

理所当然的认为,我们能看到输出台上输出0~9,事实上,数据还没有输出一个,测试代码就已经跑完了,所以结果是,输出台空空如也。

单纯的使用Thread.sleep(10000)使当前的线程停留10秒不一定是最简单,但一定是最差的选择,因为这意味着我们需要花费更长时间去等待测试结果,更重要的问题是,并非每次我们都能直到异步操作的所需要的准确时间。

RxJavaPlugins

很快我找到了这篇文章:

Rxjava2单元测试的异步和同步转换

这篇文章是为数不多对我测试RxJava有用的文章之一,作者使用RxJavaPlugins,通过将RxJava所使用的线程换成Schedulers.trampoline()的方式,强制立即进行当前的任务,从而得到单元测试的结果。

代码如下:

    /**
     * 单元测试的时候,利用RxJavaPlugins将io线程转换为trampoline
     * trampoline应该是立即执行的意思(待商榷),替代了Rx1的immediate。
     */
    public static void asyncToSync() {
        RxJavaPlugins.reset();
        RxJavaPlugins.setIoSchedulerHandler(new Function<Scheduler, Scheduler>() {
            @Override
            public Scheduler apply(Scheduler scheduler) throws Exception {
                return Schedulers.trampoline();
            }
        });

然后再在测试类里加这么一句:

@Before
 public void setUp(){
     //将rx异步转同步
     RxjavaFactory.asyncToSync();
 } 

令我振奋的是,我成功得到了预期的结果。

TestScheduler

我们通过上面的代码,解决了一大部分的异步测试需求,但是还有一个问题,那就是测试的时间,我们不希望这个测试花费这么长时间。

RxJava2的开发人员已经为我们考虑到了这个问题,对此他们提供了TestScheduler类,很明显,这也是一个Scheduler.

我们定义一个Rule,用来配置TestScheduler代替我们所使用的其他Scheduler,这是一个特殊的scheduler,它允许我们手动的将一个虚拟时间提前:

class RxSchedulerRule : TestRule {

    private var testScheduler: TestScheduler = TestScheduler()

    override fun apply(base: Statement?, description: Description?): Statement {
        return object : Statement() {
            @Throws(Throwable::class)
            override fun evaluate() {
                RxJavaPlugins.setIoSchedulerHandler { testScheduler }
                RxJavaPlugins.setComputationSchedulerHandler { testScheduler }
                RxJavaPlugins.setNewThreadSchedulerHandler { testScheduler }
                RxJavaPlugins.setSingleSchedulerHandler { testScheduler }
//                RxAndroidPlugins.setInitMainThreadSchedulerHandler { testScheduler }
                try {
                    base?.evaluate()
                } finally {
                    RxJavaPlugins.reset()
//                    RxAndroidPlugins.reset()
                }
            }
        }
    }
    
    //advanceTimeTo()以绝对的方式调整时间
    fun advanceTimeTo(delayTime: Long, timeUnit: TimeUnit) {
        testScheduler.advanceTimeTo(delayTime, timeUnit)
    }
    
    //advanceTimeBy()方法将调度器的时间调整为相对于当前位置的时间
    fun advanceTimeBy(delayTime: Long, timeUnit: TimeUnit) {
        testScheduler.advanceTimeBy(delayTime, timeUnit)
    }

    fun getScheduler(): TestScheduler {
        return testScheduler
    }
}

对此,我们在每个测试类中添加这个Rule即可:

 @Rule
 @JvmField
 val rxRule = RxSchedulerRule()

在Kotlin的测试代码中,我们需要添加@JvmField注释,因为@Rule注释仅适用于字段和getter方法,但rxRule 是Kotlin中的一个属性。

现在 我们再次进行测试:

class RxSchedulerRule_Test {

    @Rule
    @JvmField
    val rxRule = RxSchedulerRule()

    @Test
    fun testAdvanceTo_complete() {
        val observer = startRangeLoopThread()

        //将时间轴跳到第10秒,模拟执行发生的事件
        rxRule.advanceTimeTo(10, TimeUnit.SECONDS)

        assertComplete(observer)
    }

    private fun startRangeLoopThread(): TestObserver<Long> {
        val observer = TestObserver<Long>()
        val observable = getThread()
        observable.subscribe(observer)
        return observer
    }

    //开启一个子线程,每隔一秒发送一个数据,共发射10次,结果 0~9
    private fun getThread(): Observable<Long> {
        return Observable.intervalRange(0, 10, 0, 1, TimeUnit.SECONDS)
                .subscribeOn(Schedulers.io())
                .observeOn(Schedulers.newThread())
    }
    
    //断言
    private fun assertComplete(observer: TestObserver<Long>) {
        observer.assertValueCount(10)
                .assertComplete()
                .assertNoErrors()
                .assertValueAt(0, 0)
                .assertValueAt(9, 9)
    }
}

这样我们仅仅需要数十或者数百毫秒,就能准确测试这个异步操作,并且验证我们预期的结果。

如何测试AndroidSchedulers.mainThread()

单纯的直接测试会导致报错:找不到AndroidSchedulers这个类。

我们将RxSchedulerRule 中的两行注解代码打开,这意味着当我们要使用AndroidSchedulers这个类时,RxAndroidPlugins已经用TestScheduler替换了AndroidSchedulers.mainThread()。

这样就解决了这个问题。

但是我很快遇到了一个奇怪的现象,那就是

当我一次运行整个测试类时,只有第一个测试通过,其余的测试通常会失败。

这个现象困扰我很久,最终我找到的解决方式是,通过依赖注入解决问题:

参考 【译】使用Kotlin和RxJava测试MVP架构的完整示例 - 第2部分 @ditclear

首先要做到这一点,我们需要创建一个SchedulerProvider接口,并提供两个实现。

  • AppSchedulerProvider - 这将为我们提供真正的调度器。 我们将把这个类注入所有的presenter,这将为我们的Rx订阅提供调度器。
  • TestSchedulerProvide - 这个类将为我们提供一个TestScheduler而不是真正的scheduler。 当我们在测试中实例化我们的presenter时,我们将使用它作为它的构造函数参数。
interface SchedulerProvider {
    fun uiScheduler() : Scheduler
    fun ioScheduler() : Scheduler
}

class AppSchedulerProvider : SchedulerProvider {
    override fun ioScheduler() = Schedulers.io()
    override fun uiScheduler(): Scheduler = AndroidSchedulers.mainThread()
}

class TestSchedulerProvider() : SchedulerProvider {

    val testScheduler: TestScheduler = TestScheduler()

    override fun uiScheduler() = testScheduler
    override fun ioScheduler() = testScheduler
}

通过依赖注入,在生产代码中使用AppSchedulerProvider ,在测试代码中使用TestSchedulerProvider。

以最初的MVP中的model和Presenter为例,测试代码如下:

  • Model
class HomeModelTest : BaseTestModel() {

    private var retrofit: MockRetrofit = MockRetrofit()

    private var homeModel: HomeModel = HomeModel(mock())

    val provider = TestSchedulerProvider()

    @Before
    fun setUp() {
        homeModel.schedulers = provider
        homeModel = spy(homeModel)

        val service = retrofit.create(UserInfoService::class.java)
        whenever(homeModel.serviceManager.userInfoService).thenReturn(service)
    }

    @Test
    fun requestUserInfo() {
        val testObserver = TestObserver<UserInfo>()
        retrofit.path = MockAssest.USER_DATA
        doReturn(RxTestTransformer<UserInfo>()).whenever(homeModel).getUserInfoCache(anyString(), anyBoolean())

        val maybe = homeModel.requestUserInfo("qingmei2")
        maybe.subscribe(testObserver)

        provider.testScheduler.triggerActions()

        testObserver.assertValue { it -> it.login == "login" && it.name == "name" }
    }
}
  • Presenter
class HomePresenterTest : BaseTestPresenter() {

    val view: HomeContract.View = mock()

    val model: HomeContract.Model = mock()

    var presenter: HomePresenter = HomePresenter(view, model)

    @Before
    fun setUp() {
        presenter = spy(presenter)
        doReturn(RxTestTransformer<Any>()).whenever(presenter).bindViewMaybe<Any>(view)
    }

    @Test
    fun requestUserInfo_success() {

        val s = MockAssest.readFile(MockAssest.USER_DATA)
        val user = Gson().fromJson(s, UserInfo::class.java)
        whenever(model.requestUserInfo(anyString())).thenReturn(Maybe.just(user))

        //testing
        presenter.requestUserInfo("qingmei2")

        val captor: ArgumentCaptor<UserInfo> = ArgumentCaptor.forClass(UserInfo::class.java)
        verify(view).onGetUserInfo(captor.capture());
        verify(view, never()).onError(anyString());

        assert(user.equals(captor.value))
    }

    @Test
    fun requestUserInfo_failed_error() {
        val s = MockAssest.readFile(MockAssest.error)
        val user = Gson().fromJson(s, UserInfo::class.java)
        whenever(model.requestUserInfo(anyString())).thenReturn(Maybe.just(user))

        presenter.requestUserInfo("qingmei2")

        verify(view, never()).onGetUserInfo(any());
        verify(view).onError("用户信息为空");
    }
}

小结

文笔所限,只是把思路整理一遍写了出来,很多类都没有详细去讲解,比如TestScheduler,比如TestObserver,这都是RxJava提供好的工具,我们一定要好好利用。

虽然思路有些天马行空,但是笔者最近数日整理出来的一套Kotlin的RxJava2+Retrofit的单元测试脚手架已经搭建完毕,并且作为笔者个人MVP架构下的测试框架,配以详细的单元测试代码,上传到了Github上,有需要的朋友可以参考:

MvpArchitecture-Android

参考文章

测试RxJava2
http://www.infoq.com/cn/articles/Testing-RxJava2

【译】使用Kotlin和RxJava测试MVP架构的完整示例 - 第2部分:
http://www.jianshu.com/p/0a845ae2ca64

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

推荐阅读更多精彩内容