How to inject viewModelScope for Android unit test with Kotlin coroutines?

白昼怎懂夜的黑 提交于 2020-06-17 15:47:08

问题


Questions

  1. What is the best strategy to inject viewModelScope for Android unit tests with Kotlin coroutines?

  2. When the CoroutineScope is injected into a ViewModel for unit tests, should the CoroutineDispatcher also be injected and defined using flowOn even if it is not needed in production code?

flowOn is not needed in the production code in this use case as Retrofit handles the threading on Dispatchers.IO in SomeRepository.kt, and the viewModelScope returns the data on Dispathers.Main, both by default.

Expected

Run a unit test on Android's ViewModel view state values saved in a Kotlin Flow value.

Observed

Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

The unit test is failing on the first occurrence where a CoroutineScope is hardcoded. viewModelScope is utilized so that the coroutine launched will maintain the lifecycle of the ViewModel. However, viewModelScope is created from within the ViewModel, which makes it more complicated to inject compared to a CoroutineDispatcher that can be defined outside the ViewModel and passed in as an argument.

Implementation

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

Note: A JUnit 5 test extension will be used in the final version.

Full error log

Exception in thread "main @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:113) at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:91) at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:285) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56) at kotlinx.coroutines.BuildersKt.launch(Unknown Source) at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:49) at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source) at kotlinx.coroutines.flow.FlowKt__CollectKt.launchIn(Collect.kt:49) at kotlinx.coroutines.flow.FlowKt.launchIn(Unknown Source) at app.topcafes.feed.viewmodel.FeedViewModel.bindIntents(FeedViewModel.kt:38) at app.topcafes.FeedTest$topCafesPoc$1.invokeSuspend(FeedTest.kt:53) at app.topcafes.FeedTest$topCafesPoc$1.invoke(FeedTest.kt) at kotlinx.coroutines.test.TestBuildersKt$runBlockingTest$deferred$1.invokeSuspend(TestBuilders.kt:50) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) at kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50) at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async(Builders.common.kt:91) at kotlinx.coroutines.BuildersKt.async(Unknown Source) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async$default(Builders.common.kt:84) at kotlinx.coroutines.BuildersKt.async$default(Unknown Source) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:49) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:80) at app.topcafes.FeedTest.topCafesPoc(FeedTest.kt:47) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.junit.runner.JUnitCore.run(JUnitCore.java:137) at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68) at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33) at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230) at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58) Caused by: java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details. at android.os.Looper.getMainLooper(Looper.java) at kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:55) at kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:52) at kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:57) at kotlinx.coroutines.test.internal.TestMainDispatcher.getDelegate(MainTestDispatcher.kt:19) at kotlinx.coroutines.test.internal.TestMainDispatcher.getImmediate(MainTestDispatcher.kt:32) at androidx.lifecycle.ViewModelKt.getViewModelScope(ViewModel.kt:42) ... 40 more Exception in thread "main @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:113) at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:91) at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:285) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56) at kotlinx.coroutines.BuildersKt.launch(Unknown Source) at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:49) at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source) at kotlinx.coroutines.flow.FlowKt__CollectKt.launchIn(Collect.kt:49) at kotlinx.coroutines.flow.FlowKt.launchIn(Unknown Source) at app.topcafes.feed.viewmodel.FeedViewModel.bindIntents(FeedViewModel.kt:42) at app.topcafes.FeedTest$topCafesPoc$1.invokeSuspend(FeedTest.kt:53) at app.topcafes.FeedTest$topCafesPoc$1.invoke(FeedTest.kt) at kotlinx.coroutines.test.TestBuildersKt$runBlockingTest$deferred$1.invokeSuspend(TestBuilders.kt:50) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) at kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50) at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async(Builders.common.kt:91) at kotlinx.coroutines.BuildersKt.async(Unknown Source) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async$default(Builders.common.kt:84) at kotlinx.coroutines.BuildersKt.async$default(Unknown Source) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:49) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:80) at app.topcafes.FeedTest.topCafesPoc(FeedTest.kt:47) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.junit.runner.JUnitCore.run(JUnitCore.java:137) at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68) at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33) at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230) at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58) Caused by: java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details. at android.os.Looper.getMainLooper(Looper.java) at kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:55) at kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:52) at kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:57) at kotlinx.coroutines.test.internal.TestMainDispatcher.getDelegate(MainTestDispatcher.kt:19) at kotlinx.coroutines.test.internal.TestMainDispatcher.getImmediate(MainTestDispatcher.kt:32) at androidx.lifecycle.ViewModelKt.getViewModelScope(ViewModel.kt:42) at app.topcafes.feed.viewmodel.FeedViewModel.bindIntents(FeedViewModel.kt:38) ... 39 more


回答1:


Inject and determine CoroutineScope on ViewModel creation

In production, the ViewModel is created with a null coroutineScopeProvider, as the ViewModel's viewModelScope is used. For testing, TestCoroutineScope is passed as the ViewModel argument.

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


来源:https://stackoverflow.com/questions/62332403/how-to-inject-viewmodelscope-for-android-unit-test-with-kotlin-coroutines

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!