cashapp / cashapp/molecule

Testing Android ViewModels that use viewModelScope + Molecule may affect other tests

Open
#121 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Kotlin
Stars
2.2k
Forks
116
Avg merge
1h 36m
Merged PRs (30d)
8

Description

### Discussed in https://github.com/cashapp/molecule/discussions/118

Originally posted by **mhernand40** September 18, 2022
Been playing around with Molecule in my team's Android project by trying to introduce it as an implementation detail of two View Models; one that extends Jetpack's `ViewModel` and uses `viewModelScope`, and another that does not extend Jetpack's `ViewModel` and accepts any `CoroutineScope` via the constructor.

When it came to running the tests for each View Model, the tests passed when each test class was run in isolation. However, when running all the tests in one run, the test class for the View Model that extends Jetpack's `ViewModel` runs before the test class for the View Model that is a plain class, causing the latter's tests to fail.

It is worth noting the following:
* The tests use `runTest { … }` from the Coroutines Test library with the default `StandardTestDispatcher`,
* `viewModelScope` is used for the Jetpack `ViewModel`
* Requires `Dispatchers.setMain(…)`/`Dispatchers.resetMain()`
* the tests do not explicitly clear the `ViewModel`
* the tests do not explicitly cancel `viewModelScope`
* `TestScope(testScheduler)` is used for the View Model that does not extend Jetpack's `ViewModel`
* No usage of `Dispatchers.setMain(…)`/`Dispatchers.resetMain()`

I have reduced the repro down to the following test class (no Jetpack `ViewModel` required):
```Kotlin
internal class Repro {

// This test passes but causes the next test to fail.
@Test
fun test1() {
try {
Dispatchers.setMain(StandardTestDispatcher())
runTest {
val moleculeScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
doRunTest(moleculeScope)
// moleculeScope.cancel() // Uncommenting this fixes the test2 failure.
}
} finally {
Dispatchers.resetMain()
}
}

// This test only passes when run by itself or if it runs before test1.
// If you rename this to test0 so that it runs before test1, it will pass.
@Test
fun test2() = runTest {
val moleculeScope = TestScope(testScheduler)
doRunTest(moleculeScope)
}

private fun TestScope.doRunTest(moleculeScope: CoroutineScope) {
val event = MutableSharedFlow(extraBufferCapacity = 1)
val state = moleculeScope.launchMolecule(RecompositionClock.Immediate) {
var value by remember { mutableStateOf("") }
LaunchedEffect(event) { event.collect { value = it } }
value
}
runCurrent()
event.tryEmit("test")
runCurrent()
assertEquals("test", state.value)
}
}
```

`test1` simulates the scenario when testing a Jetpack `ViewModel` that uses `viewModelScope`.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the reduced Repro test in the issue and run test1 and test2 together and separately using the shown coroutine and Molecule setup. Trace how the first test's scope or dispatcher affects the second test; done means both tests pass consistently in isolation and as part of the full test run.

Written by the indexing model from the issue text.

Assessment

Tech stack
kotlin
Domain
mobile-dev, testing-qa
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.