如何在单元测试中使用协程测试实时数据

问题描述 投票:0回答:1

我正在使用Mockito,junit5和协程在存储库中获取数据。但是在测试用例中调用了no方法。我尝试使用没有任何Dispatchersemit()函数的正常暂停功能,并且该方法可以正常工作。因此,我想原因可能是由于实时数据协程

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())
    }
}
android kotlin android-testing android-livedata kotlin-coroutines
1个回答
1
投票

几天后在互联网上搜索。我了解了如何在实时数据中使用协程进行单元测试,并提出以下想法。这可能不是最好的主意,但希望它可以为遇到类似问题的人们带来一些见识。

使用实时数据进行协程单元测试的必要部分很少:

  1. 需要添加2条规则进行单元测试(Coroutine Rule, InstantExecutor Rule)。如果像我一样使用Junit5,则应该改用扩展名。协程规则为您提供使用Java UnitTest中的testCoroutine调度程序的功能。 InstantExecutor Rule为您提供监视Java UnitTest中的实时数据发射值的功能。并且要注意coroutine.dispatcherJava UnitTest中测试协程的最重要部分。建议在Kotlin中观看有关协程测试的视频https://youtu.be/KMb0Fs8rCRs

  2. 需要设置要在构造函数中注入的CoroutineDispatcher

    您应该总是注入调度员https://youtu.be/KMb0Fs8rCRs?t=850

  3. 用于livedata的一些livedata扩展名,可帮助您验证来自实时数据的发射值的值。

这是我的存储库(我遵循android官方的recommended app architecture

GitRepoRepository.kt(此想法来自2个来源,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 samplekotlin-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()
            [email protected](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)
    }
}
© www.soinside.com 2019 - 2024. All rights reserved.