Как внедрить модульный тест viewModelScope для Android с сопрограммами Kotlin?

Ожидал

  1. Какова лучшая стратегия внедрения viewModelScope для модульных тестов Android с сопрограммами Kotlin?

  2. Когда CoroutineScope вводится в ViewModel для модульных тестов, следует ли также вводить и определять CoroutineDispatcher с помощью flowOn, даже если он не нужен в производственном коде?

flowOn не требуется в производственном коде в этом случае использования, поскольку Retrofit обрабатывает потоки на Dispatchers.IO в SomeRepository.kt, а viewModelScope возвращает данные на Dispathers.Main, оба по умолчанию.

Наблюдаемый

Запустите модульный тест на значениях состояния представления ViewModel Android, сохраненных в значении Kotlin Flow.

Реализация

Не удалось инициализировать модуль с главным диспетчером. Для тестов можно использовать Dispatchers.setMain из модуля kotlinx-coroutines-test

Модульный тест завершается ошибкой в ​​первом случае, когда CoroutineScope жестко запрограммирован. viewModelScope используется для того, чтобы запущенная сопрограмма поддерживала жизненный цикл ViewModel. Однако viewModelScope создается из ViewModel, что делает его более сложным для внедрения по сравнению с CoroutineDispatcher, который может быть определен вне ViewModel и передан в качестве аргумента.

Полный журнал ошибок

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.

В производстве ViewModel создается с нулевым viewModelScope, поскольку используется flowOn в ViewModel. Для тестирования flowOn передается как аргумент ViewModel.

Вопросов


person Adam Hurwitz    schedule 11.06.2020    source источник


Ответы (1)


Исключение в потоке «main @ coroutine # 1» java.lang.IllegalStateException: не удалось инициализировать модуль с главным диспетчером. Для тестов Dispatchers.setMain из модуля kotlinx-coroutines-test можно использовать по адресу kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing (MainDispatchers.kt: 113) по адресу kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatch: MainNispatcher.isDispatch: kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith (DispatchedContinuation.kt: 285) в kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable (Cancellable.kt: 26) в kotlinx.coroutines.Coroutine.CoroutineStart: .AbstractCoroutine.start (AbstractCoroutine.kt: 158) по адресу kotlinx.coroutines.BuildersKt__Builders_commonKt.launch (Builders.common.kt: 56) по адресу kotlinx.coroutines.BuildersKt.launch ($ Неизвестный источник) по адресу kotlinx.coroutinesKuilders_commonKt__ (Builders.common.kt: 49) в kotlinx.coroutines.BuildersKt.launch $ default (Неизвестный источник) в kotlinx.coroutines.flow.FlowKt__Co llectKt.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. (Test kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt: 33) в kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt: 56) в kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch (50) TestCoroutineDispatcher.dispatch (50) по адресу kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith (DispatchedContinuation.kt: 288) по адресу kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable (Cancellable.kt: 26) в kotlinx.coroutines.ct: 26) по адресу: сопрограммы.Abs tractCoroutine.start (AbstractCoroutine.kt: 158) по адресу kotlinx.coroutines.BuildersKt__Builders_commonKt.async (Builders.common.kt: 91) по адресу kotlinx.coroutines.BuildersKt.async ($ Неизвестный источник) по адресу kotlinx.coroutilders_Builders (источник) (по умолчанию) (по умолчанию) в kotlinx.coroutine__Builders (источник) 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 (FeedTess.Imreflect.Next: 47) по адресу sun. (Собственный метод) в sun.reflect.NativeMethodAccessorImpl.invoke (NativeMethodAccessorImpl.java:62) в sun.reflect.DelegatingMethodAccessorImpl.invoke (DelegatingMethodAccessorImpl.java:43) в java.lang.hod. ) в org.junit.runners.model.FrameworkMethod $ 1.runReflectiveCall (FrameworkMethod.java:50) в org.junit.internal.runners.model.ReflectiveCallable.run (ReflectiveCallable.java:12) на org.junners.runners. .FrameworkMethod.invokeExplosively (FrameworkMethod.java:47) в org.junit.internal.runners.statements.InvokeMethod.evaluate (InvokeMethod.java:17) в org.junit.runners.ParentRunner.avajun325 org.junit.runners.BlockJUnit4ClassRunner.runChi ld (BlockJUnit4ClassRunner.java:78) по адресу org.junit.runners.BlockJUnit4ClassRunner.runChild (BlockJUnit4ClassRunner.java:57) по адресу org.junit.runners.ParentRunner $ 3.run (Parentrunners.java.java). ParentRunner $ 1.schedule (ParentRunner.java:71) на org.junit.runners.ParentRunner.runChildren (ParentRunner.java:288) на org.junit.runners.ParentRunner.access $ 000 (ParentRunner.java:junit) на org. .runners.ParentRunner $ 2.evaluate (ParentRunner.java:268) в org.junit.runners.ParentRunner.run (ParentRunner.java:363) в org.junit.runner.JUnitCore.run (JUnitCore.java:137) .intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs (JUnit4IdeaTestRunner.java:68) по адресу com.intellij.rt.junit.IdeaTestRunner $ Repeater.startRunnerWithArgs (Idea.j.j.nitRunner) (Idea.jt.jsnit.StartRunnerWithArgs. .java: 230) в com.intellij.rt.junit.JUnitStarter.main (JUnitStarter.java:58) Вызвано: java.lang. RuntimeException: метод getMainLooper в android.os.Looper не издевается. Подробнее см. http://g.co/androidstudio/not-mocked. в android.os.Looper.getMainLooper (Looper.java) по адресу kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher (HandlerDispatcher.kt: 55) по адресу kotlinx.coroutines.android.AndroidDispatcherFactory.createlerDispatcher (HandlerDispatcherFactory.ktlinDispatcher) по адресу: сопрограммы. внутренние. MainDispatchersKt.tryCreateDispatcher (MainDispatchers.kt: 57) по адресу kotlinx.coroutines.test.internal.TestMainDispatcher.getDelegate (MainTestDispatcher.kt: 19) по адресу kotlinx.coroutines.test.internal.TestMainDispatcher.get: android. .lifecycle.ViewModelKt.getViewModelScope (ViewModel.kt: 42) ... еще 40 Исключение в потоке "main @ coroutine # 1" java.lang.IllegalStateException: не удалось инициализировать модуль с главным диспетчером. Для тестов Dispatchers.setMain из модуля kotlinx-coroutines-test можно использовать по адресу kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing (MainDispatchers.kt: 113) по адресу kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatch: MainNispatcher.isDispatch: kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith (DispatchedContinuation.kt: 285) в kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable (Cancellable.kt: 26) в kotlinx.coroutines.Coroutine.CoroutineStart: .AbstractCoroutine.start (AbstractCoroutine.kt: 158) по адресу kotlinx.coroutines.BuildersKt__Builders_commonKt.launch (Builders.common.kt: 56) по адресу kotlinx.coroutines.BuildersKt.launch ($ Неизвестный источник) по адресу kotlinx.coroutinesKuilders_commonKt__ (Builders.common.kt: 49) в kotlinx.coroutines.BuildersKt.launch $ default (Неизвестный источник) в kotlinx.coroutines.flow.FlowKt__Co llectKt.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. (Test kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt: 33) в kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt: 56) в kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch (50) TestCoroutineDispatcher.dispatch (50) по адресу kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith (DispatchedContinuation.kt: 288) по адресу kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable (Cancellable.kt: 26) в kotlinx.coroutines.To: сопрограммы. AbstractCoroutine.start (AbstractCoroutine.kt: 158) в kotlinx.coroutines.BuildersKt__Builders_commonKt.async (Builders.common.kt: 91) в kotlinx.coroutines.BuildersKt.async ($ по умолчанию) в kotlinx.coroutines_Builders ($ default) в kotlinx.coroutines_Builders ($ default) Builders.common.kt: 84) по адресу kotlinx.coroutines.BuildersKt.async $ default (Неизвестный источник) по адресу kotlinx.coroutines.test.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: 42esmoview) в app.topcafcaf .FeedViewModel.bindIntents (FeedViewModel.kt: 38) ... еще 39

SomeUtils.kt

SomeViewModel.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

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

}

Вводить и определять CoroutineScope при создании ViewModel

@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()
        ...
    }
}
person Adam Hurwitz    schedule 11.06.2020