【译】使用Kotlin和RxJava测试MVP架构的完整示例 - 第1部分

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

最近我创建了一个playground项目来了解更多关于Kotlin和RxJava的信息。 这是一个非常简单的项目,但有一部分,我进行了一些尝试:测试。

在kotlin的测试上可能会有一些陷阱,而且由于它是新出的,所以没有太多的例子。 我认为分享我的经验帮助你来避免踩坑是一个好主意。

关于架构

该应用程序遵循基本MVP架构。 它使用Dagger2进行依赖注入,RxJava2用于数据流。

这些库根据不同的条件提供来自网络或本地存储的数据。 我们使用Retrofit进行网络请求,以及Room作为本地数据库。

我不会详细讲解架构和这些工具。 我想大多数人已经熟悉了他们。 您可以在此提交中查看:

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

我们将从测试数据库开始,然后向上层测试。

测试数据库

对于数据库,我们使用Android架构组件中的Room Persistence Library。 它是SQLite上的抽象层,可以减少样板代码。

这是最简单的部分。 我们不需要对Kotlin或RxJava做任何具体的事情。 我们先来看看UserDao界面的代码,以决定我们应该测试什么。

@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函数根据页码从数据库中请求下一个30个用户。

insertAll插入列表中的所有用户。

我们可以从这里发现几件事情,需要测试什么:

  • 检查插入的用户是否与检索到的用户相同。
  • 检查检索用户正确排序。
  • 检查我们是否插入具有相同ID的用户,它将替换旧的记录。
  • 检查是否查询页面,最多可以有30个用户。
  • 检查我们是否查询第二页,我们将获得正确数量的元素。

下面的代码片段显示了5例这样的实现。

@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方法中,我们需要配置我们的数据库。 在每次测试之前,我们使用Room的内存数据库创建一个干净的数据库。

测试在这里非常简单,不需要进一步解释。 我们在每个测试中遵循的基本模式如
下所示:

  1. 将数据插入数据库
  2. 从数据库查询数据
  3. 对所检索的数据作出断言

我们可以使用Kotlin Collections API中的函数来简化测试数据的创建,就像这部分代码一样:

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

我们创建了一个范围,然后将其映射到用户列表。 这里有多个Kotlin概念:范围,高阶函数,字符串模板。

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

测试UserRepository

对于repository和interactor,我们将使用相同的工具。

  • 使用Mockit模拟类的依赖。
  • TestObserver用于测试Observables(在我们的例子中是Singles)

但首先我们需要启用该选项来mock最终的类。 在kotlin里,默认情况下每个class都是final的。 幸运的是,Mockito 2已经支持模拟 final class,但是我们需要启用它。

我们需要在以下位置创建一个文本文件:test / resources / mockito-extensions /,名称为org.mockito.plugins.MockMaker,并附带以下文本:mock-maker-inline

Place of the file in Project view

现在我们可以开始使用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方法中,我们创建一个Single,它会发送users或一个error。 根据不同的条件,shouldUpdate方法决定用户是否应该从网络加载或从本地数据库加载。

还有一点需要注意的是CalendarWrapper字段。 这是一个简单的包装器,有一个返回当前时间的方法。 在它帮助下,我们可以模拟我们测试的时间。

那么我们应该在这里测试什么? 在这里最重要的测试是在shouldUpdate方法背后的逻辑。 让我们为它做一些测试。

测试这个的方法是先调用getUsers方法,并在返回的Single去调用test方法。 test方法会创建一个TestObserver并将其订阅到Single

TestObserver是一种特殊类型的Observer,它记录事件并允许对它们进行断言。

我们还必须模拟UserRepository的依赖关系,并且存储一些他们的方法来返回指定的数据。 我们可以像在Java中一样使用Mockito,或者使用Niek Haarman的Mockito-Kotlin库。 我们将在这个例子中使用Mockito,但如果您好奇,可以检查Github资料库。

如果我们要使用Mockito的when方法,我们需要把它放在反引号之间,因为它是Kotlin中的保留字。 为了使这看起来更好,我们可以使用as关键字引入具有不同名称的when方法。

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

现在我们可以使用whenever方法进行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私有方法来避免我们的测试中的样板代码。 我们可以在每个具有不同参数的测试用例中调用此方法,这取决于正在测试的状态。 Kotlin的默认参数在这里非常有用,因为有时我们不需要指定每个参数。
  2. 调用getUsers方法,并通过在返回的Single上调用test方法来获取一个TestObserver。
  3. TestObserver或模拟对象上进行一些断言以验证预期的行为。 在这个例子中,我们使用assertNoErrors方法来验证Single不会发出错误。 我们使用的另一种方法是assertValue。 有了它的帮助,我们可以断言Single发出的值是不是正确。 执行此操作的方式是将lambda传递给assertValue方法,该方法返回一个布尔值。 如果它返回true,则断言将通过。 在这种情况下,我们验证发出的列表包含1个元素。 有很多其他方法可以在TestObserver上做出断言,这些可以在TestObserver的超类BaseTestConsumer的文档中找到。

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

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

测试 GetUsers interactor

测试GetUsers interactor的方法类似于我们用来测试UserRepository的方法。

GetUsers是一个非常简单的类,它的目的是将data层中的数据转换为presentation层中的数据。

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中的一些转换来实现想要的结果。

来看看我们的测试长什么样:

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)
    }
}

唯一的区别在于,我们创建一个假的从getUsers方法返回的Single对象。 我们使用Single将UserListModel发送给setUpStubbing方法,在这里我们创建了假的Single,并将其设置为getUsers方法的返回值。

剩下的代码使用与UserRepositoryTest中相同的概念。

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

这是第一部分。 我们学习了如何在Kotlin测试中使用RxJava来处理一些常见问题,如何利用一些Kotlin功能来编写更简单的测试,并且还可以看看如何测试Room数据库。

在第二部分中,我将向您展示如何在TestScheduler的帮助下测试Presenter,以及如何使用Espresso和假数据来进行UI测试。 敬请关注。

Thanks for reading my article.

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

推荐阅读更多精彩内容