Closed JP-Labs closed 4 months ago
Hi could you check if creating the viewmodel after setting the main dispatcher works?:
- private val viewModel = CartViewModel(cartRepository, deleteCartItemUseCase)
@Test
fun `onDeleteClick should delete item from cart`() = runTest {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
+ viewModel = CartViewModel(cartRepository, deleteCartItemUseCase)
Thanks for the quick response. Sadly this does not change the outcome. I am doing some experiments in the same time and an approach that did work was passing the CoroutineDispatcher as constructor argument to the ViewModel.
class CartViewModel(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
cartRepository: CartRepository,
private val deleteCartItemUseCase: DeleteCartItemUseCase
) : KMMViewModel() {
// Rest of code
fun onDeleteClick(productCode: String) {
viewModelScope.coroutineScope.launch(dispatcher) {
deleteCartItemUseCase(productCode)
}
}
}
And in the test the following:
// Rest of code
private val viewModel = CartViewModel(Dispatchers.Unconfined, cartRepository, deleteCartItemUseCase)
@Test
fun `onDeleteClick should delete item from cart`() = runTest {
cartRepository.addToCart("123", "productName", "imageUrl", "supplierName")
viewModel.onDeleteClick("123")
assertTrue(cartRepository.currentCart.isEmpty())
}
I don't think that is a recommend way of implementing async workflow in view models. Tests are green but the app is crashing since I didn't adjust the dependency management. There should be a more valid solution.
Looking at the stacktrace again it doesn't seem to be the ViewModel that is causing this. Instead it's FakeCartRepository
.
Please make sure that the main dispatcher has been set before any of the code starts using it.
I had this thought too, so I created a simple test ViewModel without dependencies. The results are similar:
import com.rickclephas.kmm.viewmodel.KMMViewModel
import com.rickclephas.kmm.viewmodel.coroutineScope
import kotlinx.coroutines.launch
class TestViewModel: KMMViewModel() {
fun testThis() {
viewModelScope.coroutineScope.launch {
println("Test")
}
}
}
Corresponding test class:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.Test
import kotlin.test.assertTrue
class TestViewModelTest {
private val viewModel = TestViewModel()
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `testThis should print Test`() = runTest {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
try {
viewModel.testThis()
assertTrue(true)
} finally {
Dispatchers.resetMain()
}
}
}
And the stacktrace:
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:111)
at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:92)
at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:315)
at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable$default(Cancellable.kt:21)
at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:88)
at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:123)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:52)
at kotlinx.coroutines.BuildersKt.launch(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:43)
at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source)
at presentation.search.TestViewModel.testThis(TestViewModel.kt:10)
at presentation.search.TestViewModelTest$testThis should print Test$1.invokeSuspend(TestViewModelTest.kt:23)
at presentation.search.TestViewModelTest$testThis should print Test$1.invoke(TestViewModelTest.kt)
at presentation.search.TestViewModelTest$testThis should print Test$1.invoke(TestViewModelTest.kt)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$1.invokeSuspend(TestBuilders.kt:316)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
at kotlinx.coroutines.test.TestDispatcher.processEvent$kotlinx_coroutines_test(TestDispatcher.kt:24)
at kotlinx.coroutines.test.TestCoroutineScheduler.tryRunNextTaskUnless$kotlinx_coroutines_test(TestCoroutineScheduler.kt:99)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$workRunner$1.invokeSuspend(TestBuilders.kt:322)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:277)
at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:95)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:69)
at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:48)
at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
at kotlinx.coroutines.test.TestBuildersJvmKt.createTestResult(TestBuildersJvm.kt:10)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:310)
at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(Unknown Source)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:168)
at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(Unknown Source)
at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0$default(TestBuilders.kt:160)
at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0$default(Unknown Source)
at presentation.search.TestViewModelTest.testThis should print Test(TestViewModelTest.kt:18)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.runTestClass(JUnitTestClassExecutor.java:112)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.execute(JUnitTestClassExecutor.java:58)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.execute(JUnitTestClassExecutor.java:40)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestClassProcessor.processTestClass(AbstractJUnitTestClassProcessor.java:60)
at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.processTestClass(SuiteTestClassProcessor.java:52)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94)
at jdk.proxy1/jdk.proxy1.$Proxy2.processTestClass(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:176)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:113)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:65)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See https://developer.android.com/r/studio-ui/build/not-mocked for details.
at android.os.Looper.getMainLooper(Looper.java)
at kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:51)
at kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:53)
at kotlinx.coroutines.test.internal.TestMainDispatcherFactory.createDispatcher(TestMainDispatcherJvm.kt:11)
at kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:53)
at kotlinx.coroutines.internal.MainDispatcherLoader.loadMainDispatcher(MainDispatchers.kt:34)
at kotlinx.coroutines.internal.MainDispatcherLoader.<clinit>(MainDispatchers.kt:18)
at kotlinx.coroutines.Dispatchers.getMain(Dispatchers.kt:20)
at androidx.lifecycle.ViewModelKt.getViewModelScope(ViewModel.kt:43)
at com.rickclephas.kmm.viewmodel.ViewModelScopeImpl.<init>(ViewModelScope.kt:20)
at com.rickclephas.kmm.viewmodel.KMMViewModel.<init>(KMMViewModel.kt:20)
at presentation.search.TestViewModel.<init>(TestViewModel.kt:7)
at presentation.search.TestViewModelTest.<init>(TestViewModelTest.kt:14)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(Unknown Source)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Unknown Source)
at java.base/java.lang.reflect.Constructor.newInstance(Unknown Source)
at org.junit.runners.BlockJUnit4ClassRunner.createTest(BlockJUnit4ClassRunner.java:250)
at org.junit.runners.BlockJUnit4ClassRunner.createTest(BlockJUnit4ClassRunner.java:260)
at org.junit.runners.BlockJUnit4ClassRunner$2.runReflectiveCall(BlockJUnit4ClassRunner.java:309)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.BlockJUnit4ClassRunner.methodBlock(BlockJUnit4ClassRunner.java:306)
... 34 more
It's only working currently if I pass the dispatcher directly to the scope via dependency injection.
In that case it is indeed the ViewModel that is trying to access the Main Dispatcher.
Moving the viewModel creation into your test function (after setMain
) should solve the error.
The problem is that there is no Main dispatcher until you call setMain
so all application logic that uses this dispatcher should only be executed after the call to setMain
.
Ok I think I got you. Following solution is working currently:
@OptIn(ExperimentalCoroutinesApi::class)
class TestViewModelTest {
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `testThis should print Test`() = runTest {
val viewModel = TestViewModel()
viewModel.testThis()
assertTrue(true)
}
}
What is missing on my side is the option to solve this via a TestRule with JUnit but this is a flaw of KMP in general I think. With global declaration I was achieving that I don't have to recreate the test class with all dependencies on each test but I think I can solve this via dependency injection with e.g. koin.
Great. Yeah or performing the setup in setUp
with lateinit
properties. That should work as well.
Good point. This did work too.
private lateinit var viewModel: TestViewModel
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
viewModel = TestViewModel()
}
Thanks a lot!
KMM-ViewModel 1.0.0-ALPHA20 Kotlin Multiplatform 1.9.23
I have a shared KMMViewModel in my project.
I am trying to write tests for this ViewModel. Currently I am using
kotlin.test
andkotlinx-coroutines-test
. The test class looks like the following:Since I don't have access to TestRules from JUnit4 I used the variant for coroutine testing presented on the Android Docs.
Running the test will present the following error:
Is there some experience on how you test async workflows from KMMViewModels?