android - 如何在单元测试中使用协程测试 livedata

标签 android kotlin android-testing android-livedata kotlin-coroutines

我正在使用 mockito、junit5 和协程在存储库中获取数据。但是在测试用例中调用了 no 方法。我尝试使用没有任何 Dispatchers 的正常挂起功能和 emit()功能和它的工作原理。因此,我猜原因可能是由于 livedata 协程

GitReposRepository.kt

fun loadReposSuspend(owner: String) = liveData(Dispatchers.IO) {
    emit(Result.Loading)
    val response = githubService.getReposNormal(owner)
    val repos = response.body()!!
    if (repos.isEmpty()) {
        emit(Result.Success(repos))
        repoDao.insert(*repos.toTypedArray())
    } else {
        emitSource(repoDao.loadRepositories(owner)
                           .map { Result.Success(it) })
    }
}

GitReposRepositoryTest.kt
internal class GitRepoRepositoryTest {

    private lateinit var appExecutors:AppExecutors
    private lateinit var repoDao: RepoDao
    private lateinit var githubService: GithubService
    private lateinit var gitRepoRepository: GitRepoRepository

    @BeforeEach
    internal fun setUp() {
        appExecutors = mock(AppExecutors::class.java)
        repoDao = mock(RepoDao::class.java)
        githubService = mock(GithubService::class.java)
        gitRepoRepository = GitRepoRepository(appExecutors,
                                              repoDao,
                                              githubService)
    }

    @Test
    internal fun `should call network to fetch result and insert to db`() = runBlocking {
        //given
        val owner = "Testing"
        val response = Response.success(listOf(Repo(),Repo()))
        `when`(githubService.getReposNormal(ArgumentMatchers.anyString())).thenReturn(response)
        //when
        gitRepoRepository.loadReposSuspend(owner)
        //then
        verify(githubService).getReposNormal(owner)
        verify(repoDao).insertRepos(ArgumentMatchers.anyList())
    }
}

最佳答案

在网上搜索了几天。我了解了如何在 livedata 中使用协程进行单元测试,并提出以下想法。这可能不是最好的主意,但希望它可以为有类似问题的人带来一些见解。
使用 livedata 进行协程单元测试的必要部分很少:

  • 需要添加2条规则用于单元测试 (Coroutine Rule, InstantExecutor Rule)。如果你像我一样使用 Junit5,你应该改用扩展。 Coroutine Rule 为您提供使用 中的 testCoroutine 调度程序的功能Java 单元测试 . InstantExecutor Rule 为您提供监控 中 livedata 发出值的功能Java 单元测试 .小心coroutine.dispatcher 中测试协程最重要的部分Java 单元测试 .建议观看 Kotlin 协程测试视频 https://youtu.be/KMb0Fs8rCRs
  • 需要在Constructor中设置要注入(inject)的CoroutineDispatcher

    You should ALWAYS inject Dispatchers (https://youtu.be/KMb0Fs8rCRs?t=850)


  • livedata 的一些 livedata 扩展,可帮助您验证实时数据中发出的值的值。

  • 这是我的存储库(我在 android 官方中关注 recommended app architecture)
    GitRepoRepository.kt(这个想法来自两个来源,LegoThemeRepositoryNetworkBoundResource
    @Singleton
    class GitRepoRepository @Inject constructor(private val appExecutors: AppExecutors,
                                                private val repoDao: RepoDao,
                                                private val githubService: GithubService,
                                                private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
                                                private val repoListRateLimit: RateLimiter<String> = RateLimiter(
                                                        10,
                                                        TimeUnit.MINUTES)
    ) {
    
        fun loadRepo(owner: String
        ): LiveData<Result<List<Repo>>> = repositoryLiveData(
                localResult = { repoDao.loadRepositories(owner) },
                remoteResult = {
                    transformResult { githubService.getRepo(owner) }.apply {
                        if (this is Result.Error) {
                            repoListRateLimit.reset(owner)
                        }
                    }
                },
                shouldFetch = { repoListRateLimit.shouldFetch(owner) },
                saveFetchResult = { repoDao.insertRepos(it) },
                dispatcher = this.dispatcher
        )
        ...
    }
    
    GitRepoRepositoryTest.kt
    @ExperimentalCoroutinesApi
    @ExtendWith(InstantExecutorExtension::class)
    class GitRepoRepositoryTest {
    
        // Set the main coroutines dispatcher for unit testing
        companion object {
            @JvmField
            @RegisterExtension
            var coroutinesRule = CoroutinesTestExtension()
        }
    
        private lateinit var appExecutors: AppExecutors
        private lateinit var repoDao: RepoDao
        private lateinit var githubService: GithubService
        private lateinit var gitRepoRepository: GitRepoRepository
        private lateinit var rateLimiter: RateLimiter<String>
    
        @BeforeEach
        fun setUp() {
            appExecutors = mock(AppExecutors::class.java)
            repoDao = mock(RepoDao::class.java)
            githubService = mock(GithubService::class.java)
            rateLimiter = mock(RateLimiter::class.java) as RateLimiter<String>
            gitRepoRepository = GitRepoRepository(appExecutors,
                                                  repoDao,
                                                  githubService,
                                                  coroutinesRule.dispatcher,
                                                  rateLimiter)
        }
    
        @Test
        fun `should not call network to fetch result if the process in rate limiter is not valid`() = coroutinesRule.runBlocking {
            //given
            val owner = "Tom"
            val response = Response.success(listOf(Repo(), Repo()))
            `when`(githubService.getRepo(anyString())).thenReturn(
                    response)
            `when`(rateLimiter.shouldFetch(anyString())).thenReturn(false)
            //when
            gitRepoRepository.loadRepo(owner).getOrAwaitValue()
            //then
            verify(githubService, never()).getRepo(owner)
            verify(repoDao, never()).insertRepos(anyList())
        }
    
        @Test
        fun `should reset ratelimiter if the network response contains error`() = coroutinesRule.runBlocking {
            //given
            val owner = "Tom"
            val response = Response.error<List<Repo>>(500,
                                                      "Test Server Error".toResponseBody(
                                                              "text/plain".toMediaTypeOrNull()))
            `when`(githubService.getRepo(anyString())).thenReturn(
                    response)
            `when`(rateLimiter.shouldFetch(anyString())).thenReturn(true)
            //when
            gitRepoRepository.loadRepo(owner).getOrAwaitValue()
            //then
            verify(rateLimiter, times(1)).reset(owner)
        }
    }
    
    CoroutineUtil.kt(想法也来自here如果你想记录一些信息,这里应该是自定义的实现,下面的测试用例为你提供了一些如何在协程中测试它的见解
    sealed class Result<out R> {
        data class Success<out T>(val data: T) : Result<T>()
        object Loading : Result<Nothing>()
        data class Error<T>(val message: String) : Result<T>()
        object Finish : Result<Nothing>()
    }
    
    fun <T, A> repositoryLiveData(localResult: (() -> LiveData<T>) = { MutableLiveData() },
                                  remoteResult: (suspend () -> Result<A>)? = null,
                                  saveFetchResult: suspend (A) -> Unit = { Unit },
                                  dispatcher: CoroutineDispatcher = Dispatchers.IO,
                                  shouldFetch: () -> Boolean = { true }
    ): LiveData<Result<T>> =
            liveData(dispatcher) {
                emit(Result.Loading)
                val source: LiveData<Result<T>> = localResult.invoke()
                        .map { Result.Success(it) }
                emitSource(source)
                try {
                    remoteResult?.let {
                        if (shouldFetch.invoke()) {
                            when (val response = it.invoke()) {
                                is Result.Success -> {
                                    saveFetchResult(response.data)
                                }
                                is Result.Error -> {
                                    emit(Result.Error<T>(response.message))
                                    emitSource(source)
                                }
                                else -> {
                                }
                            }
                        }
                    }
                } catch (e: Exception) {
                    emit(Result.Error<T>(e.message.toString()))
                    emitSource(source)
                } finally {
                    emit(Result.Finish)
                }
            }
    
    suspend fun <T> transformResult(call: suspend () -> Response<T>): Result<T> {
        try {
            val response = call()
            if (response.isSuccessful) {
                val body = response.body()
                if (body != null) return Result.Success(body)
            }
            return error(" ${response.code()} ${response.message()}")
        } catch (e: Exception) {
            return error(e.message ?: e.toString())
        }
    }
    
    fun <T> error(message: String): Result<T> {
        return Result.Error("Network call has failed for a following reason: $message")
    }
    
    CoroutineUtilKtTest.kt
    interface Delegation {
        suspend fun remoteResult(): Result<String>
        suspend fun saveResult(s: String)
        fun localResult(): MutableLiveData<String>
        fun shouldFetch(): Boolean
    }
    
    fun <T> givenSuspended(block: suspend () -> T) = BDDMockito.given(runBlocking { block() })
    
    @ExperimentalCoroutinesApi
    @ExtendWith(InstantExecutorExtension::class)
    class CoroutineUtilKtTest {
        // Set the main coroutines dispatcher for unit testing
        companion object {
            @JvmField
            @RegisterExtension
            var coroutinesRule = CoroutinesTestExtension()
        }
    
        val delegation: Delegation = mock()
        private val LOCAL_RESULT = "Local Result Fetch"
        private val REMOTE_RESULT = "Remote Result Fetch"
        private val REMOTE_CRASH = "Remote Result Crash"
    
        @BeforeEach
        fun setUp() {
            given { delegation.shouldFetch() }
                    .willReturn(true)
            given { delegation.localResult() }
                    .willReturn(MutableLiveData(LOCAL_RESULT))
            givenSuspended { delegation.remoteResult() }
                    .willReturn(Result.Success(REMOTE_RESULT))
        }
    
        @Test
        fun `should call local result only if the remote result should not fetch`() = coroutinesRule.runBlocking {
            //given
            given { delegation.shouldFetch() }.willReturn(false)
    
            //when
            repositoryLiveData<String, String>(
                    localResult = { delegation.localResult() },
                    remoteResult = { delegation.remoteResult() },
                    shouldFetch = { delegation.shouldFetch() },
                    dispatcher = coroutinesRule.dispatcher
            ).getOrAwaitValue()
            //then
            verify(delegation, times(1)).localResult()
            verify(delegation, never()).remoteResult()
        }
    
    
        @Test
        fun `should call remote result and then save result`() = coroutinesRule.runBlocking {
            //when
            repositoryLiveData<String, String>(
                    shouldFetch = { delegation.shouldFetch() },
                    remoteResult = { delegation.remoteResult() },
                    saveFetchResult = { s -> delegation.saveResult(s) },
                    dispatcher = coroutinesRule.dispatcher
            ).getOrAwaitValue()
            //then
            verify(delegation, times(1)).remoteResult()
            verify(delegation,
                   times(1)).saveResult(REMOTE_RESULT)
        }
    
        @Test
        fun `should emit Loading, Success, Finish Status when we fetch local and then remote`() = coroutinesRule.runBlocking {
            //when
            val ld = repositoryLiveData<String, String>(
                    localResult = { delegation.localResult() },
                    shouldFetch = { delegation.shouldFetch() },
                    remoteResult = { delegation.remoteResult() },
                    saveFetchResult = { delegation.shouldFetch() },
                    dispatcher = coroutinesRule.dispatcher
            )
            //then
            ld.captureValues {
                assertEquals(arrayListOf(Result.Loading,
                                         Result.Success(LOCAL_RESULT),
                                         Result.Finish), values)
            }
        }
    
        @Test
        fun `should emit Loading,Success, Error, Success, Finish Status when we fetch remote but fail`() = coroutinesRule.runBlocking {
            givenSuspended { delegation.remoteResult() }
                    .willThrow(RuntimeException(REMOTE_CRASH))
            //when
            val ld = repositoryLiveData<String, String>(
                    localResult = { delegation.localResult() },
                    shouldFetch = { delegation.shouldFetch() },
                    remoteResult = { delegation.remoteResult() },
                    saveFetchResult = { delegation.shouldFetch() },
                    dispatcher = coroutinesRule.dispatcher
            )
            //then
            ld.captureValues {
                assertEquals(arrayListOf(Result.Loading,
                                         Result.Success(LOCAL_RESULT),
                                         Result.Error(REMOTE_CRASH),
                                         Result.Success(LOCAL_RESULT),
                                         Result.Finish
                ), values)
            }
        }
    
    
    }
    
    LiveDataTestUtil.kt (这个想法来自 aac sample , kotlin-coroutine )
    fun <T> LiveData<T>.getOrAwaitValue(
            time: Long = 2,
            timeUnit: TimeUnit = TimeUnit.SECONDS,
            afterObserve: () -> Unit = {}
    ): T {
        var data: T? = null
        val latch = CountDownLatch(1)
        val observer = object : Observer<T> {
            override fun onChanged(o: T?) {
                data = o
                latch.countDown()
                this@getOrAwaitValue.removeObserver(this)
            }
        }
        this.observeForever(observer)
    
        afterObserve.invoke()
    
        // Don't wait indefinitely if the LiveData is not set.
        if (!latch.await(time, timeUnit)) {
            this.removeObserver(observer)
            throw TimeoutException("LiveData value was never set.")
        }
    
        @Suppress("UNCHECKED_CAST")
        return data as T
    }
    
    class LiveDataValueCapture<T> {
    
        val lock = Any()
    
        private val _values = mutableListOf<T?>()
        val values: List<T?>
            get() = synchronized(lock) {
                _values.toList() // copy to avoid returning reference to mutable list
            }
    
        fun addValue(value: T?) = synchronized(lock) {
            _values += value
        }
    }
    
    inline fun <T> LiveData<T>.captureValues(block: LiveDataValueCapture<T>.() -> Unit) {
        val capture = LiveDataValueCapture<T>()
        val observer = Observer<T> {
            capture.addValue(it)
        }
        observeForever(observer)
        try {
            capture.block()
        } finally {
            removeObserver(observer)
        }
    }
    

    关于android - 如何在单元测试中使用协程测试 livedata,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/60887866/

    相关文章:

    android - 使用 ComposeRule 进行 UI 测试崩溃

    android - 如何从 DialogFlowV2 webhook 发送不同的语音和显示文本?

    android - 控制 Firebase 断开连接超时

    android - 引导加载程序如何在 "restarting system with command"之后获取命令?

    android - 自定义 View 的仪器测试

    Android 架构示例单元测试实时数据

    android - Bootstrap 2.3.2 - 不要将移动布局用于平板电脑纵向

    android - Kotlin、Proguard 和 lambdas

    android - 如何修复 startKoin() 方法上的 NoSuchMethodError

    android-studio - ApplicationVariant、BaseVariantOutput 已弃用