如何使用Kotlin协程为Android单元测试注入viewModelScope?

时间:2020-06-11 19:49:02

标签: android kotlin junit kotlin-coroutines kotlin-flow

问题

  1. 使用Kotlin协程为Android单元测试注入viewModelScope的最佳策略是什么?

  2. 将CoroutineScope注入ViewModel进行单元测试时,即使在生产代码中不需要,也应该使用flowOn注入和定义CoroutineDispatcher吗?

在此用例中,生产代码中不需要

flowOn,因为Retrofit在 SomeRepository.kt 中处理Dispatchers.IO上的线程,并且viewModelScope返回默认情况下,Dispathers.Main上的数据。

预期

对保存在Kotlin Flow值中的Android ViewModel视图状态值进行单元测试。

已观察

具有主调度程序的模块无法初始化。对于测试,可以使用kotlinx-coroutines-test模块中的Dispatchers.setMain

在第一次对CoroutineScope进行硬编码的情况下,单元测试失败。使用viewModelScope以便启动的协程将维持ViewModel的生命周期。但是,viewModelScope是在ViewModel内部创建的,与可在ViewModel外部定义并作为参数传递的CoroutineDispatcher相比,它的注入更加复杂。

实施

SomeViewModel.kt

fun bindIntents(view: FeedView) {
    view.initStateIntent().onEach {
        initState(view)
    }.launchIn(viewModelScope)        
}

SomeTest.kt

@ExperimentalCoroutinesApi
class SomeTest : BeforeAllCallback, AfterAllCallback {

    private val testDispatcher = TestCoroutineDispatcher()
    private val testScope = TestCoroutineScope(testDispatcher)
    private val repository = mockkClass(FeedRepository::class)
    private var loadNetworkIntent = MutableStateFlow<LoadNetworkIntent?>(null)

    override fun beforeAll(context: ExtensionContext?) {
        // Set Coroutine Dispatcher.
        Dispatchers.setMain(testDispatcher)
    }

    override fun afterAll(context: ExtensionContext?) {
        Dispatchers.resetMain()
        // Reset Coroutine Dispatcher and Scope.
        testDispatcher.cleanupTestCoroutines()
        testScope.cleanupTestCoroutines()
    }

    @Test
    fun topCafesPoc() = testDispatcher.runBlockingTest {
        coEvery {
            repository.getInitialCafes(any())
        } returns mockGetInitialCafes(mockCafesList, SUCCESS)

        val viewModel = FeedViewModel(repository)
        viewModel.bindIntents(object : FeedView {
            @ExperimentalCoroutinesApi
            override fun initStateIntent() = MutableStateFlow(true)

            @ExperimentalCoroutinesApi
            override fun loadNetworkIntent() = loadNetworkIntent.filterNotNull()

            override fun render(viewState: FeedViewState) {
                // TODO: Test viewState
            }

        })
        loadNetworkIntent.value = LoadNetworkIntent(true)
        // TODO
        // assertEquals(4, 2 + 2)
    }
}

注意:最终版本将使用JUnit 5测试扩展。

完整错误日志

线程“ main @ coroutine#1”中的异常java.lang.IllegalStateException:具有主调度程序的模块无法初始化。对于测试,可以使用kotlinx-coroutines-test模块中的Dispatchers.setMain 在kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:113) 在kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:91) 在kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:285) 在kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) 在kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) 在kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56) 在kotlinx.coroutines.BuildersKt.launch(来源不明) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.launch $ default(Builders.common.kt:49) 在kotlinx.coroutines.BuildersKt.launch $ default(未知来源) 在kotlinx.coroutines.flow.FlowKt__CollectKt.launchIn(Collect.kt:49) 在kotlinx.coroutines.flow.FlowKt.launchIn(未知来源) 在app.topcafes.feed.viewmodel.FeedViewModel.bindIntents(FeedViewModel.kt:38) 在app.topcafes.FeedTest $ topCafesPoc $ 1.invokeSuspend(FeedTest.kt:53) 在app.topcafes.FeedTest $ topCafesPoc $ 1.invoke(FeedTest.kt) 在kotlinx.coroutines.test.TestBuildersKt $ runBlockingTest $ deferred $ 1.invokeSuspend(TestBuilders.kt:50) 在kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) 在kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) 在kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50) 在kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288) 在kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) 在kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) 在kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.async(Builders.common.kt:91) 在kotlinx.coroutines.BuildersKt.async(未知来源) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.async $ default(Builders.common.kt:84) 在kotlinx.coroutines.BuildersKt.async $ default(未知来源) 在kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:49) 在kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:80) 在app.topcafes.FeedTest.topCafesPoc(FeedTest.kt:47) 在sun.reflect.NativeMethodAccessorImpl.invoke0(本机方法)处 在sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) 在sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) 在java.lang.reflect.Method.invoke(Method.java:498) 在org.junit.runners.model.FrameworkMethod $ 1.runReflectiveCall(FrameworkMethod.java:50) 在org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) 在org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) 在org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) 在org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) 在org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) 在org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) 在org.junit.runners.ParentRunner $ 3.run(ParentRunner.java:290) 在org.junit.runners.ParentRunner上$ 1.schedule(ParentRunner.java:71) 在org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 在org.junit.runners.ParentRunner.access $ 000(ParentRunner.java:58) 在org.junit.runners.ParentRunner上$ 2.evaluate(ParentRunner.java:268) 在org.junit.runners.ParentRunner.run(ParentRunner.java:363) 在org.junit.runner.JUnitCore.run(JUnitCore.java:137) 在com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68) 在com.intellij.rt.junit.IdeaTestRunner $ Repeater.startRunnerWithArgs(IdeaTestRunner.java:33) 在com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230) 在com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58) 由以下原因引起:java.lang.RuntimeException:android.os.Looper中的方法getMainLooper没有被模拟。有关详情,请参见http://g.co/androidstudio/not-mocked。 在android.os.Looper.getMainLooper(Looper.java) 在kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:55) 在kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:52) 在kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:57) 在kotlinx.coroutines.test.internal.TestMainDispatcher.getDelegate(MainTestDispatcher.kt:19) 在kotlinx.coroutines.test.internal.TestMainDispatcher.getImmediate(MainTestDispatcher.kt:32) 在androidx.lifecycle.ViewModelKt.getViewModelScope(ViewModel.kt:42) ...另外40个 线程“主@ coroutine#1”中的异常java.lang.IllegalStateException:具有主调度程序的模块无法初始化。对于测试,可以使用kotlinx-coroutines-test模块中的Dispatchers.setMain 在kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:113) 在kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:91) 在kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:285) 在kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) 在kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) 在kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56) 在kotlinx.coroutines.BuildersKt.launch(来源不明) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.launch $ default(Builders.common.kt:49) 在kotlinx.coroutines.BuildersKt.launch $ default(未知来源) 在kotlinx.coroutines.flow.FlowKt__CollectKt.launchIn(Collect.kt:49) 在kotlinx.coroutines.flow.FlowKt.launchIn(未知来源) 在app.topcafes.feed.viewmodel.FeedViewModel.bindIntents(FeedViewModel.kt:42) 在app.topcafes.FeedTest $ topCafesPoc $ 1.invokeSuspend(FeedTest.kt:53) 在app.topcafes.FeedTest $ topCafesPoc $ 1.invoke(FeedTest.kt) 在kotlinx.coroutines.test.TestBuildersKt $ runBlockingTest $ deferred $ 1.invokeSuspend(TestBuilders.kt:50) 在kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) 在kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) 在kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50) 在kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288) 在kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) 在kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) 在kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.async(Builders.common.kt:91) 在kotlinx.coroutines.BuildersKt.async(未知来源) 在kotlinx.coroutines.BuildersKt__Builders_commonKt.async $ default(Builders.common.kt:84) 在kotlinx.coroutines.BuildersKt.async $ default(未知来源) 在kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:49) 在kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:80) 在app.topcafes.FeedTest.topCafesPoc(FeedTest.kt:47) 在sun.reflect.NativeMethodAccessorImpl.invoke0(本机方法)处 在sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) 在sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) 在java.lang.reflect.Method.invoke(Method.java:498) 在org.junit.runners.model.FrameworkMethod $ 1.runReflectiveCall(FrameworkMethod.java:50) 在org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) 在org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) 在org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) 在org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) 在org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) 在org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) 在org.junit.runners.ParentRunner $ 3.run(ParentRunner.java:290) 在org.junit.runners.ParentRunner上$ 1.schedule(ParentRunner.java:71) 在org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 在org.junit.runners.ParentRunner.access $ 000(ParentRunner.java:58) 在org.junit.runners.ParentRunner上$ 2.evaluate(ParentRunner.java:268) 在org.junit.runners.ParentRunner.run(ParentRunner.java:363) 在org.junit.runner.JUnitCore.run(JUnitCore.java:137) 在com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68) 在com.intellij.rt.junit.IdeaTestRunner $ Repeater.startRunnerWithArgs(IdeaTestRunner.java:33) 在com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230) 在com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58) 由以下原因引起:java.lang.RuntimeException:android.os.Looper中的方法getMainLooper没有被模拟。有关详情,请参见http://g.co/androidstudio/not-mocked。 在android.os.Looper.getMainLooper(Looper.java) 在kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:55) 在kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:52) 在kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:57) 在kotlinx.coroutines.test.internal.TestMainDispatcher.getDelegate(MainTestDispatcher.kt:19) 在kotlinx.coroutines.test.internal.TestMainDispatcher.getImmediate(MainTestDispatcher.kt:32) 在androidx.lifecycle.ViewModelKt.getViewModelScope(ViewModel.kt:42) 在app.topcafes.feed.viewmodel.FeedViewModel.bindIntents(FeedViewModel.kt:38) ...还有39个

1 个答案:

答案 0 :(得分:6)

在创建ViewModel时注入并确定CoroutineScope

在生产中,因为使用了ViewModel的coroutineScopeProvider,所以使用空viewModelScope创建了ViewModel。为了进行测试,将TestCoroutineScope作为ViewModel参数传递。

SomeUtils.kt

/**
 * Configure CoroutineScope injection for production and testing.
 *
 * @receiver ViewModel provides viewModelScope for production
 * @param coroutineScope null for production, injects TestCoroutineScope for unit tests
 * @return CoroutineScope to launch coroutines on
 */
fun ViewModel.getViewModelScope(coroutineScope: CoroutineScope?) =
    if (coroutineScope == null) this.viewModelScope
    else coroutineScope

SomeViewModel.kt

class FeedViewModel(
    private val coroutineScopeProvider: CoroutineScope? = null,
    private val repository: FeedRepository
) : ViewModel() {

    private val coroutineScope = getViewModelScope(coroutineScopeProvider)

    fun getSomeData() {
        repository.getSomeDataRequest().onEach {
            // Some code here.            
        }.launchIn(coroutineScope)
    }

}

SomeTest.kt

@ExperimentalCoroutinesApi
class FeedTest : BeforeAllCallback, AfterAllCallback {

    private val testDispatcher = TestCoroutineDispatcher()
    private val testScope = TestCoroutineScope(testDispatcher)
    private val repository = mockkClass(FeedRepository::class)
    private var loadNetworkIntent = MutableStateFlow<LoadNetworkIntent?>(null)

    override fun beforeAll(context: ExtensionContext?) {
        // Set Coroutine Dispatcher.
        Dispatchers.setMain(testDispatcher)
    }

    override fun afterAll(context: ExtensionContext?) {
        Dispatchers.resetMain()
        // Reset Coroutine Dispatcher and Scope.
        testDispatcher.cleanupTestCoroutines()
        testScope.cleanupTestCoroutines()
    }

    @Test
    fun topCafesPoc() = testDispatcher.runBlockingTest {
        ...
        val viewModel = FeedViewModel(testScope, repository)
        viewmodel.getSomeData()
        ...
    }
}