Android單元測試:測試RxJava的同步及異步操作

簡述

在您開發(fā)的項目中险毁,您使用了RxJava+Retrofit的網(wǎng)絡(luò)請求框架,在RxJava強(qiáng)大的操作符下钱反,您理所當(dāng)然的認(rèn)為您已經(jīng)能夠游刃有余地處理Android客戶端開發(fā)中的聯(lián)網(wǎng)請求需求僻焚,比如這樣:

//Model層的網(wǎng)絡(luò)請求
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層的網(wǎng)絡(luò)處理
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("請求出現(xiàn)異常"));
    }
}

這是一個非常簡單的MVP開發(fā)模式下的一個聯(lián)網(wǎng)請求代碼蜒秤,用于請求用戶的信息數(shù)據(jù),我們不需要強(qiáng)制性理解每一行代碼贝咙,我們需要關(guān)注的問題是样悟,我們?nèi)绾伪WC這些代碼的正確性,即使在一些邊界條件的限制下庭猩?

單元測試能夠讓項目中自己的代碼更加健壯窟她,但是無論是RxJava+Retrofit,亦或是okHttp3,異步操作的測試都是一個較高的門檻眯娱,加上目前國內(nèi)開發(fā)環(huán)境的惡劣環(huán)境(周期短礁苗,需求變動反復(fù)),國內(nèi)的相關(guān)技術(shù)文檔相當(dāng)匱乏徙缴。

Espresso的異步測試

筆者曾經(jīng)花費一些時間研究Espresso的異步測試试伙,并根據(jù)google官方的demo嘗試總結(jié)在設(shè)備上進(jìn)行測試異步操作的方案:

Android 自動化測試 Espresso篇:異步代碼測試

這是可行的嘁信,但是有一些問題需要注意:

1.Espresso本質(zhì)是依賴設(shè)備所進(jìn)行的集成測試,這種測試是業(yè)務(wù)級的疏叨,測試的內(nèi)容是一個整體的功能潘靖,相對于代碼(方法)級別的單元測試,覆蓋范圍太大蚤蔓。
2.Espresso的測試依賴設(shè)備卦溢,所以進(jìn)行一次完整的測試所消耗的時間更長。
3.Espresso更擅長UI的測試秀又,而對于業(yè)務(wù)代碼來說单寂,Espresso并不擅長。

現(xiàn)在我們需要一種新的方式去測試業(yè)務(wù)代碼吐辙,比如Junit+Mockito.

異步測試的難題

在嘗試RxJava的單元測試時宣决,不可避免的,你會遇到兩個問題:

1.如何測試異步代碼昏苏?
2.如何測試AndroidSchedulers.mainThread()尊沸?

1.異步操作如何轉(zhuǎn)為同步進(jìn)行單元測試?

前者的問題我們已經(jīng)在之前的Android 自動化測試 Espresso篇:異步代碼測試這篇文章中提到過了贤惯,那就是:

在我們還沒有獲取到數(shù)據(jù)進(jìn)行驗證時洼专,測試代碼已經(jīng)跑完了。

以下面一段代碼為例:

//開啟一個子線程孵构,每隔一秒發(fā)送一個數(shù)據(jù)屁商,共發(fā)射10次,結(jié)果輸出 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))
}

理所當(dāng)然的認(rèn)為,我們能看到輸出臺上輸出0~9浦译,事實上棒假,數(shù)據(jù)還沒有輸出一個,測試代碼就已經(jīng)跑完了精盅,所以結(jié)果是帽哑,輸出臺空空如也。

單純的使用Thread.sleep(10000)使當(dāng)前的線程停留10秒不一定是最簡單叹俏,但一定是最差的選擇妻枕,因為這意味著我們需要花費更長時間去等待測試結(jié)果,更重要的問題是粘驰,并非每次我們都能直到異步操作的所需要的準(zhǔn)確時間屡谐。

RxJavaPlugins

很快我找到了這篇文章:

Rxjava2單元測試的異步和同步轉(zhuǎn)換

這篇文章是為數(shù)不多對我測試RxJava有用的文章之一,作者使用RxJavaPlugins蝌数,通過將RxJava所使用的線程換成Schedulers.trampoline()的方式愕掏,強(qiáng)制立即進(jìn)行當(dāng)前的任務(wù),從而得到單元測試的結(jié)果顶伞。

代碼如下:

    /**
     * 單元測試的時候饵撑,利用RxJavaPlugins將io線程轉(zhuǎn)換為trampoline
     * trampoline應(yīng)該是立即執(zhí)行的意思(待商榷)剑梳,替代了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異步轉(zhuǎn)同步
     RxjavaFactory.asyncToSync();
 } 

令我振奮的是滑潘,我成功得到了預(yù)期的結(jié)果垢乙。

TestScheduler

我們通過上面的代碼,解決了一大部分的異步測試需求语卤,但是還有一個問題追逮,那就是測試的時間,我們不希望這個測試花費這么長時間粹舵。

RxJava2的開發(fā)人員已經(jīng)為我們考慮到了這個問題钮孵,對此他們提供了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()以絕對的方式調(diào)整時間
    fun advanceTimeTo(delayTime: Long, timeUnit: TimeUnit) {
        testScheduler.advanceTimeTo(delayTime, timeUnit)
    }
    
    //advanceTimeBy()方法將調(diào)度器的時間調(diào)整為相對于當(dāng)前位置的時間
    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中的一個屬性电爹。

現(xiàn)在 我們再次進(jìn)行測試:

class RxSchedulerRule_Test {

    @Rule
    @JvmField
    val rxRule = RxSchedulerRule()

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

        //將時間軸跳到第10秒蔫仙,模擬執(zhí)行發(fā)生的事件
        rxRule.advanceTimeTo(10, TimeUnit.SECONDS)

        assertComplete(observer)
    }

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

    //開啟一個子線程,每隔一秒發(fā)送一個數(shù)據(jù)丐箩,共發(fā)射10次,結(jié)果 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)
    }
}

這樣我們僅僅需要數(shù)十或者數(shù)百毫秒摇邦,就能準(zhǔn)確測試這個異步操作,并且驗證我們預(yù)期的結(jié)果屎勘。

如何測試AndroidSchedulers.mainThread()

單純的直接測試會導(dǎo)致報錯:找不到AndroidSchedulers這個類施籍。

我們將RxSchedulerRule 中的兩行注解代碼打開,這意味著當(dāng)我們要使用AndroidSchedulers這個類時概漱,RxAndroidPlugins已經(jīng)用TestScheduler替換了AndroidSchedulers.mainThread()丑慎。

這樣就解決了這個問題。

但是我很快遇到了一個奇怪的現(xiàn)象瓤摧,那就是

當(dāng)我一次運行整個測試類時竿裂,只有第一個測試通過,其余的測試通常會失敗照弥。

這個現(xiàn)象困擾我很久腻异,最終我找到的解決方式是,通過依賴注入解決問題:

參考 【譯】使用Kotlin和RxJava測試MVP架構(gòu)的完整示例 - 第2部分 @ditclear

首先要做到這一點这揣,我們需要創(chuàng)建一個SchedulerProvider接口悔常,并提供兩個實現(xiàn)影斑。

  • AppSchedulerProvider - 這將為我們提供真正的調(diào)度器。 我們將把這個類注入所有的presenter这嚣,這將為我們的Rx訂閱提供調(diào)度器鸥昏。
  • TestSchedulerProvide - 這個類將為我們提供一個TestScheduler而不是真正的scheduler。 當(dāng)我們在測試中實例化我們的presenter時姐帚,我們將使用它作為它的構(gòu)造函數(shù)參數(shù)吏垮。
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
}

通過依賴注入,在生產(chǎn)代碼中使用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("用戶信息為空");
    }
}

小結(jié)

文筆所限九秀,只是把思路整理一遍寫了出來遗嗽,很多類都沒有詳細(xì)去講解,比如TestScheduler鼓蜒,比如TestObserver,這都是RxJava提供好的工具痹换,我們一定要好好利用。

雖然思路有些天馬行空都弹,但是筆者最近數(shù)日整理出來的一套Kotlin的RxJava2+Retrofit的單元測試腳手架已經(jīng)搭建完畢,并且作為筆者個人MVP架構(gòu)下的測試框架娇豫,配以詳細(xì)的單元測試代碼,上傳到了Github上畅厢,有需要的朋友可以參考:

MvpArchitecture-Android

參考文章

測試RxJava2
http://www.infoq.com/cn/articles/Testing-RxJava2

【譯】使用Kotlin和RxJava測試MVP架構(gòu)的完整示例 - 第2部分:
http://www.reibang.com/p/0a845ae2ca64

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末冯痢,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子框杜,更是在濱河造成了極大的恐慌浦楣,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,290評論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件咪辱,死亡現(xiàn)場離奇詭異振劳,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)梧乘,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,107評論 2 385
  • 文/潘曉璐 我一進(jìn)店門澎迎,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人选调,你說我怎么就攤上這事夹供。” “怎么了仁堪?”我有些...
    開封第一講書人閱讀 156,872評論 0 347
  • 文/不壞的土叔 我叫張陵哮洽,是天一觀的道長。 經(jīng)常有香客問我弦聂,道長鸟辅,這世上最難降的妖魔是什么氛什? 我笑而不...
    開封第一講書人閱讀 56,415評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮匪凉,結(jié)果婚禮上枪眉,老公的妹妹穿的比我還像新娘。我一直安慰自己再层,他們只是感情好贸铜,可當(dāng)我...
    茶點故事閱讀 65,453評論 6 385
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著聂受,像睡著了一般蒿秦。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上蛋济,一...
    開封第一講書人閱讀 49,784評論 1 290
  • 那天棍鳖,我揣著相機(jī)與錄音,去河邊找鬼碗旅。 笑死渡处,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的祟辟。 我是一名探鬼主播骂蓖,決...
    沈念sama閱讀 38,927評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼川尖!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起茫孔,我...
    開封第一講書人閱讀 37,691評論 0 266
  • 序言:老撾萬榮一對情侶失蹤叮喳,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后缰贝,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體馍悟,經(jīng)...
    沈念sama閱讀 44,137評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,472評論 2 326
  • 正文 我和宋清朗相戀三年剩晴,在試婚紗的時候發(fā)現(xiàn)自己被綠了锣咒。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,622評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡赞弥,死狀恐怖毅整,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情绽左,我是刑警寧澤悼嫉,帶...
    沈念sama閱讀 34,289評論 4 329
  • 正文 年R本政府宣布,位于F島的核電站拼窥,受9級特大地震影響戏蔑,放射性物質(zhì)發(fā)生泄漏蹋凝。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,887評論 3 312
  • 文/蒙蒙 一总棵、第九天 我趴在偏房一處隱蔽的房頂上張望鳍寂。 院中可真熱鬧,春花似錦情龄、人聲如沸迄汛。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,741評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽隔心。三九已至,卻和暖如春尚胞,著一層夾襖步出監(jiān)牢的瞬間硬霍,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評論 1 265
  • 我被黑心中介騙來泰國打工笼裳, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留唯卖,地道東北人。 一個月前我還...
    沈念sama閱讀 46,316評論 2 360
  • 正文 我出身青樓躬柬,卻偏偏與公主長得像拜轨,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子允青,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,490評論 2 348

推薦閱讀更多精彩內(nèi)容