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

原文鏈接:https://android.jlelse.eu/complete-example-of-testing-mvp-architecture-with-kotlin-and-rxjava-part-1-816e22e71ff4

最近我創(chuàng)建了一個playground項目來了解更多關(guān)于Kotlin和RxJava的信息揍魂。 這是一個非常簡單的項目逊彭,但有一部分,我進(jìn)行了一些嘗試:測試。

在kotlin的測試上可能會有一些陷阱,而且由于它是新出的,所以沒有太多的例子畔乙。 我認(rèn)為分享我的經(jīng)驗幫助你來避免踩坑是一個好主意。

關(guān)于架構(gòu)

該應(yīng)用程序遵循基本MVP架構(gòu)翩概。 它使用Dagger2進(jìn)行依賴注入牲距,RxJava2用于數(shù)據(jù)流返咱。

這些庫根據(jù)不同的條件提供來自網(wǎng)絡(luò)或本地存儲的數(shù)據(jù)。 我們使用Retrofit進(jìn)行網(wǎng)絡(luò)請求牍鞠,以及Room作為本地數(shù)據(jù)庫咖摹。

我不會詳細(xì)講解架構(gòu)和這些工具。 我想大多數(shù)人已經(jīng)熟悉了他們难述。 您可以在此提交中查看:

https://github.com/kozmi55/Kotlin-MVP-Testing/commit/ca29cad1973cd434ffb0b0d23c4465fc54e05c0b

我們將從測試數(shù)據(jù)庫開始萤晴,然后向上層測試。

測試數(shù)據(jù)庫

對于數(shù)據(jù)庫胁后,我們使用Android架構(gòu)組件中的Room Persistence Library店读。 它是SQLite上的抽象層,可以減少樣板代碼攀芯。

這是最簡單的部分屯断。 我們不需要對Kotlin或RxJava做任何具體的事情。 我們先來看看UserDao界面的代碼侣诺,以決定我們應(yīng)該測試什么殖演。

@Dao
interface UserDao {
    @Query("SELECT * FROM user ORDER BY reputation DESC LIMIT (:arg0 - 1) * 30, 30")
    fun getUsers(page: Int) : List<User>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertAll(users: List<User>)
}

getUsers函數(shù)根據(jù)頁碼從數(shù)據(jù)庫中請求下一個30個用戶。

insertAll插入列表中的所有用戶年鸳。

我們可以從這里發(fā)現(xiàn)幾件事情趴久,需要測試什么:

  • 檢查插入的用戶是否與檢索到的用戶相同。
  • 檢查檢索用戶正確排序阻星。
  • 檢查我們是否插入具有相同ID的用戶朋鞍,它將替換舊的記錄。
  • 檢查是否查詢頁面妥箕,最多可以有30個用戶滥酥。
  • 檢查我們是否查詢第二頁,我們將獲得正確數(shù)量的元素畦幢。

下面的代碼片段顯示了5例這樣的實現(xiàn)坎吻。

@RunWith(AndroidJUnit4::class)
class UserDaoTest {

    lateinit var userDao: UserDao
    lateinit var database: AppDatabase

    @Before
    fun setup() {
        val context = InstrumentationRegistry.getTargetContext()
        database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
        userDao = database.userDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun testInsertedAndRetrievedUsersMatch() {
        val users = listOf(User(1, "Name", 100, "url"), User())
        userDao.insertAll(users)

        val allUsers = userDao.getUsers(1)
        assertEquals(users, allUsers)
    }

    @Test
    fun testUsersOrderedByCorrectly() {
        val users = listOf(
                User(1, "Name", 100, "url"),
                User(2, "Name2", 500, "url"),
                User(3, "Name3", 300, "url"))
        userDao.insertAll(users)

        val allUsers = userDao.getUsers(1)
        val expectedUsers = users.sortedByDescending { it.reputation }
        assertEquals(expectedUsers, allUsers)
    }

    @Test
    fun testConflictingInsertsReplaceUsers() {
        val users = listOf(
                User(1, "Name", 100, "url"),
                User(2, "Name2", 500, "url"),
                User(3, "Name3", 300, "url"))

        val users2 = listOf(
                User(1, "Name", 1000, "url"),
                User(2, "Name2", 700, "url"),
                User(4, "Name3", 5500, "url"))
        userDao.insertAll(users)
        userDao.insertAll(users2)

        val allUsers = userDao.getUsers(1)
        val expectedUsers = listOf(
                User(4, "Name3", 5500, "url"),
                User(1, "Name", 1000, "url"),
                User(2, "Name2", 700, "url"),
                User(3, "Name3", 300, "url"))

        assertEquals(expectedUsers, allUsers)
    }

    @Test
    fun testLimitUsersPerPage_FirstPageOnly30Items() {
        val users = (1..40L).map { User(it, "Name $it", it *100, "url") }

        userDao.insertAll(users)

        val retrievedUsers = userDao.getUsers(1)
        assertEquals(30, retrievedUsers.size)
    }

    @Test
    fun testRequestSecondPage_LimitUsersPerPage_showOnlyRemainingItems() {
        val users = (1..40L).map { User(it, "Name $it", it *100, "url") }

        userDao.insertAll(users)

        val retrievedUsers = userDao.getUsers(2)
        assertEquals(10, retrievedUsers.size)
    }
}

在setup方法中,我們需要配置我們的數(shù)據(jù)庫宇葱。 在每次測試之前瘦真,我們使用Room的內(nèi)存數(shù)據(jù)庫創(chuàng)建一個干凈的數(shù)據(jù)庫。

測試在這里非常簡單黍瞧,不需要進(jìn)一步解釋诸尽。 我們在每個測試中遵循的基本模式如
下所示:

  1. 將數(shù)據(jù)插入數(shù)據(jù)庫
  2. 從數(shù)據(jù)庫查詢數(shù)據(jù)
  3. 對所檢索的數(shù)據(jù)作出斷言

我們可以使用Kotlin Collections API中的函數(shù)來簡化測試數(shù)據(jù)的創(chuàng)建,就像這部分代碼一樣:

val users = (1..40L).map { User(it, "Name $it", it *100, "url") }

我們創(chuàng)建了一個范圍印颤,然后將其映射到用戶列表您机。 這里有多個Kotlin概念:范圍,高階函數(shù),字符串模板际看。

Commit: https://github.com/kozmi55/Kotlin-MVP-Testing/commit/8cebc897b642cc843920a107f5f0be15d13a925c

測試UserRepository

對于repository和interactor咸产,我們將使用相同的工具。

  • 使用Mockit模擬類的依賴仲闽。
  • TestObserver用于測試Observables(在我們的例子中是Singles)

但首先我們需要啟用該選項來mock最終的類脑溢。 在kotlin里,默認(rèn)情況下每個class都是final的赖欣。 幸運的是屑彻,Mockito 2已經(jīng)支持模擬 final class,但是我們需要啟用它畏鼓。

我們需要在以下位置創(chuàng)建一個文本文件:test / resources / mockito-extensions /酱酬,名稱為org.mockito.plugins.MockMaker,并附帶以下文本:mock-maker-inline

Place of the file in Project view

現(xiàn)在我們可以開始使用Mockito來編寫我們的測試云矫。 首先,我們將添加最新版本的Mockito和JUnit汗菜。

testImplementation 'org.mockito:mockito-core:2.8.47'
testImplementation 'junit:junit:4.12'

UserRepository的代碼如下:

class UserRepository(
        private val userService: UserService,
        private val userDao: UserDao,
        private val connectionHelper: ConnectionHelper,
        private val preferencesHelper: PreferencesHelper,
        private val calendarWrapper: CalendarWrapper) {
  
    private val LAST_UPDATE_KEY = "last_update_page_"

    fun getUsers(page: Int, forced: Boolean): Single<UserListModel> {
        return Single.create<UserListModel> { emitter: SingleEmitter<UserListModel> ->
            if (shouldUpdate(page, forced)) {
                loadUsersFromNetwork(page, emitter)
            } else {
                loadOfflineUsers(page, emitter)
            }
        }
    }

    private fun shouldUpdate(page: Int, forced: Boolean) = when {
        forced -> true
        !connectionHelper.isOnline() -> false
        else -> {
            val lastUpdate = preferencesHelper.loadLong(LAST_UPDATE_KEY + page)
            val currentTime = calendarWrapper.getCurrentTimeInMillis()
            lastUpdate + Constants.REFRESH_LIMIT < currentTime
        }
    }

    private fun loadUsersFromNetwork(page: Int, emitter: SingleEmitter<UserListModel>) {
        try {
            val users = userService.getUsers(page).execute().body()
            if (users != null) {
                userDao.insertAll(users.items)
                val currentTime = calendarWrapper.getCurrentTimeInMillis()
                preferencesHelper.saveLong(LAST_UPDATE_KEY + page, currentTime)
                emitter.onSuccess(users)
            } else {
                emitter.onError(Exception("No data received"))
            }
        } catch (exception: Exception) {
            emitter.onError(exception)
        }
    }

    private fun loadOfflineUsers(page: Int, emitter: SingleEmitter<UserListModel>) {
        val users = userDao.getUsers(page)
        if (!users.isEmpty()) {
            emitter.onSuccess(UserListModel(users))
        } else {
            emitter.onError(Exception("Device is offline"))
        }
    }
}

getUsers方法中让禀,我們創(chuàng)建一個Single,它會發(fā)送users或一個error陨界。 根據(jù)不同的條件巡揍,shouldUpdate方法決定用戶是否應(yīng)該從網(wǎng)絡(luò)加載或從本地數(shù)據(jù)庫加載。

還有一點需要注意的是CalendarWrapper字段菌瘪。 這是一個簡單的包裝器腮敌,有一個返回當(dāng)前時間的方法。 在它幫助下俏扩,我們可以模擬我們測試的時間糜工。

那么我們應(yīng)該在這里測試什么? 在這里最重要的測試是在shouldUpdate方法背后的邏輯录淡。 讓我們?yōu)樗鲆恍y試捌木。

測試這個的方法是先調(diào)用getUsers方法,并在返回的Single去調(diào)用test方法嫉戚。 test方法會創(chuàng)建一個TestObserver并將其訂閱到Single刨裆。

TestObserver是一種特殊類型的Observer,它記錄事件并允許對它們進(jìn)行斷言彬檀。

我們還必須模擬UserRepository的依賴關(guān)系帆啃,并且存儲一些他們的方法來返回指定的數(shù)據(jù)。 我們可以像在Java中一樣使用Mockito窍帝,或者使用Niek Haarman的Mockito-Kotlin庫努潘。 我們將在這個例子中使用Mockito,但如果您好奇,可以檢查Github資料庫慈俯。

如果我們要使用Mockito的when方法渤刃,我們需要把它放在反引號之間,因為它是Kotlin中的保留字贴膘。 為了使這看起來更好卖子,我們可以使用as關(guān)鍵字引入具有不同名稱的when方法。

import org.mockito.Mockito.`when` as whenever

現(xiàn)在我們可以使用whenever方法進(jìn)行stubbing刑峡。

class UserRepositoryTest {

    @Mock
    lateinit var mockUserService: UserService

    @Mock
    lateinit var mockUserDao: UserDao

    @Mock
    lateinit var mockConnectionHelper: ConnectionHelper

    @Mock
    lateinit var mockPreferencesHelper: PreferencesHelper

    @Mock
    lateinit var mockCalendarWrapper: CalendarWrapper

    @Mock
    lateinit var mockUserCall: Call<UserListModel>

    @Mock
    lateinit var mockUserResponse: Response<UserListModel>

    lateinit var userRepository: UserRepository

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        userRepository = UserRepository(mockUserService, mockUserDao, 
                                        mockConnectionHelper, mockPreferencesHelper, 
                                        mockCalendarWrapper)
    }

    @Test
    fun testGetUsers_isOnlineReceivedOneItem_emitListWithOneItem() {
        val userListModel = UserListModel(listOf(User()))
        setUpStubbing(true, 1000 * 60 * 60 * 12 + 1, 0, modelFromUserService = userListModel)
        
        val testObserver = userRepository.getUsers(1, false).test()

        testObserver.assertNoErrors()
        testObserver.assertValue { userListModelResult: UserListModel -> 
                                  userListModelResult.items.size == 1 }
        verify(mockUserDao).insertAll(userListModel.items)
    }

    @Test
    fun testGetUsers_isOfflineOneItemInDatabase_emitListWithOneItem() {
        val modelFromDatabase = listOf(User())
        setUpStubbing(false, 1000 * 60 * 60 * 12 + 1, 0, modelFromDatabase = modelFromDatabase)
        
        val testObserver = userRepository.getUsers(1, false).test()

        testObserver.assertNoErrors()
        testObserver.assertValue { userListModelResult: UserListModel -> 
                                  userListModelResult.items.size == 1 }
    }

    private fun setUpStubbing(isOnline: Boolean, currentTime: Long, lastUpdateTime: Long,
                              modelFromUserService: UserListModel = UserListModel(emptyList()),
                              modelFromDatabase: List<User> = emptyList()) {
        whenever(mockConnectionHelper.isOnline()).thenReturn(isOnline)
        whenever(mockCalendarWrapper.getCurrentTimeInMillis()).thenReturn(currentTime)
        whenever(mockPreferencesHelper.loadLong("last_update_page_1")).thenReturn(lastUpdateTime)

        whenever(mockUserService.getUsers(1)).thenReturn(mockUserCall)
        whenever(mockUserCall.execute()).thenReturn(mockUserResponse)
        whenever(mockUserResponse.body()).thenReturn(modelFromUserService)
        whenever(mockUserDao.getUsers(1)).thenReturn(modelFromDatabase)
    }
}

以上我們可以看到UserRepositoryTest的代碼洋闽。 我們在這個例子中使用Mockito注解來初始化mocks,但是可以用不同的方法來完成突梦。 每個測試包括3個步驟:

  1. 指定stubbed方法返回什么值诫舅。 我們使用setUpStubbing私有方法來避免我們的測試中的樣板代碼。 我們可以在每個具有不同參數(shù)的測試用例中調(diào)用此方法宫患,這取決于正在測試的狀態(tài)刊懈。 Kotlin的默認(rèn)參數(shù)在這里非常有用,因為有時我們不需要指定每個參數(shù)娃闲。
  2. 調(diào)用getUsers方法虚汛,并通過在返回的Single上調(diào)用test方法來獲取一個TestObserver。
  3. TestObserver或模擬對象上進(jìn)行一些斷言以驗證預(yù)期的行為皇帮。 在這個例子中卷哩,我們使用assertNoErrors方法來驗證Single不會發(fā)出錯誤。 我們使用的另一種方法是assertValue属拾。 有了它的幫助将谊,我們可以斷言Single發(fā)出的值是不是正確。 執(zhí)行此操作的方式是將lambda傳遞給assertValue方法渐白,該方法返回一個布爾值尊浓。 如果它返回true,則斷言將通過礼预。 在這種情況下眠砾,我們驗證發(fā)出的列表包含1個元素。 有很多其他方法可以在TestObserver上做出斷言托酸,這些可以在TestObserver的超類BaseTestConsumer的文檔中找到褒颈。

在此提交中可以找到這些更改:

https://github.com/kozmi55/Kotlin-MVP-Testing/commit/17fc4645bb446879a0e44560c19d6c2c36810a89

測試 GetUsers interactor

測試GetUsers interactor的方法類似于我們用來測試UserRepository的方法。

GetUsers是一個非常簡單的類励堡,它的目的是將data層中的數(shù)據(jù)轉(zhuǎn)換為presentation層中的數(shù)據(jù)谷丸。

class GetUsers(private val userRepository: UserRepository) {

    fun execute(page: Int, forced: Boolean) : Single<List<UserViewModel>> {
        val usersList = userRepository.getUsers(page, forced)
        return usersList.map { userListModel: UserListModel? ->
            val items = userListModel?.items ?: emptyList()
            items.map { UserViewModel(it.userId, it.displayName, it.reputation, it.profileImage) }
        }
    }
}

我們使用RxJava和Kotlin Collection API中的一些轉(zhuǎn)換來實現(xiàn)想要的結(jié)果。

來看看我們的測試長什么樣:

class GetUsersTest {

    @Mock
    lateinit var mockUserRepository: UserRepository

    lateinit var getUsers: GetUsers

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        getUsers = GetUsers(mockUserRepository)
    }

    @Test
    fun testExecute_userListModelWithOneItem_emitListWithOneViewModel() {
        val userListModel = UserListModel(listOf(User(1, "Name", 100, "Image url")))
        setUpStubbing(userListModel)

        val testObserver = getUsers.execute(1, false).test()

        testObserver.assertNoErrors()
        testObserver.assertValue { userViewModels: List<UserViewModel> -> userViewModels.size == 1 }
        testObserver.assertValue { userViewModels: List<UserViewModel> ->
            userViewModels.get(0) == UserViewModel(1, "Name", 100, "Image url") }
    }

    @Test
    fun testExecute_userListModelEmpty_emitEmptyList() {
        val userListModel = UserListModel(emptyList())
        setUpStubbing(userListModel)

        val testObserver = getUsers.execute(1, false).test()

        testObserver.assertNoErrors()
        testObserver.assertValue { userViewModels: List<UserViewModel> -> userViewModels.isEmpty() }
    }

    private fun setUpStubbing(userListModel: UserListModel) {
        val fakeSingle = Single.create { e: SingleEmitter<UserListModel>? ->
            e?.onSuccess(userListModel) }

        whenever(mockUserRepository.getUsers(1, false))
                .thenReturn(fakeSingle)
    }
}

唯一的區(qū)別在于应结,我們創(chuàng)建一個假的從getUsers方法返回的Single對象刨疼。 我們使用Single將UserListModel發(fā)送給setUpStubbing方法泉唁,在這里我們創(chuàng)建了假的Single,并將其設(shè)置為getUsers方法的返回值揩慕。

剩下的代碼使用與UserRepositoryTest中相同的概念亭畜。

Commit在這:https://github.com/kozmi55/Kotlin-MVP-Testing/commit/49652a53813f004b2c11f962d8ba5666575365fc

這是第一部分。 我們學(xué)習(xí)了如何在Kotlin測試中使用RxJava來處理一些常見問題迎卤,如何利用一些Kotlin功能來編寫更簡單的測試拴鸵,并且還可以看看如何測試Room數(shù)據(jù)庫。

在第二部分中蜗搔,我將向您展示如何在TestScheduler的幫助下測試Presenter劲藐,以及如何使用Espresso和假數(shù)據(jù)來進(jìn)行UI測試。 敬請關(guān)注樟凄。

Thanks for reading my article.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末聘芜,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子缝龄,更是在濱河造成了極大的恐慌汰现,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件叔壤,死亡現(xiàn)場離奇詭異服鹅,居然都是意外死亡,警方通過查閱死者的電腦和手機百新,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來庐扫,“玉大人饭望,你說我怎么就攤上這事⌒瓮ィ” “怎么了铅辞?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長萨醒。 經(jīng)常有香客問我斟珊,道長,這世上最難降的妖魔是什么富纸? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任囤踩,我火速辦了婚禮,結(jié)果婚禮上晓褪,老公的妹妹穿的比我還像新娘堵漱。我一直安慰自己,他們只是感情好涣仿,可當(dāng)我...
    茶點故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布勤庐。 她就那樣靜靜地躺著示惊,像睡著了一般。 火紅的嫁衣襯著肌膚如雪愉镰。 梳的紋絲不亂的頭發(fā)上米罚,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天,我揣著相機與錄音丈探,去河邊找鬼录择。 笑死,一個胖子當(dāng)著我的面吹牛类嗤,可吹牛的內(nèi)容都是我干的糊肠。 我是一名探鬼主播,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼遗锣,長吁一口氣:“原來是場噩夢啊……” “哼货裹!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起精偿,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤弧圆,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后笔咽,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體搔预,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年叶组,在試婚紗的時候發(fā)現(xiàn)自己被綠了拯田。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡甩十,死狀恐怖船庇,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情侣监,我是刑警寧澤鸭轮,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站橄霉,受9級特大地震影響窃爷,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜姓蜂,卻給世界環(huán)境...
    茶點故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一按厘、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧覆糟,春花似錦刻剥、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽御吞。三九已至,卻和暖如春漓藕,著一層夾襖步出監(jiān)牢的瞬間陶珠,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工享钞, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留揍诽,地道東北人。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓栗竖,卻偏偏與公主長得像暑脆,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子狐肢,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,877評論 2 345

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