Test fails while trying to use a ViewModel-like object with Turbine
(Apologies if this should be an issue for Turbine)
I have the following BaseViewModel class which is a lot like the class in the sample-viewmodel folder:
abstract class BaseViewModel<Event, Model>(
private val backgroundScope: CoroutineScope,
private val recompositionMode: RecompositionMode,
)
{
private val events = MutableSharedFlow<Event>(extraBufferCapacity = 20)
val models: StateFlow<Model> by lazy(LazyThreadSafetyMode.NONE) {
println("starting compose runtime")
backgroundScope.launchMolecule(mode = recompositionMode) {
models(events)
}
}
fun take(event: Event) {
println("Taking event $event")
if(!events.tryEmit(event)) {
error("Event buffer overflow")
}
}
@Composable
protected abstract fun models(events: Flow<Event>): Model
}
Note that BaseViewModel does not extend from the AAC ViewModel.
Here is an implementation of BaseViewModel+ composable presenter:
class PetListViewModel @AssistedInject constructor(
private val petDb: PetDb,
private val ioDispatcher: CoroutineDispatcher,
@Assisted scope: CoroutineScope,
@Assisted recompositionMode: RecompositionMode,
): BaseViewModel<PetListEvent, PetListUiState>(scope, recompositionMode)
{
private val viewModelState = MutableStateFlow(PetListUiState())
init {
viewModelState.update {
it.copy(
pets = petDb.petQueries.selectAll().executeAsList().map { Pet(id = it._id, name = it.name) }
)
}
}
@Composable
override fun models(events: Flow<PetListEvent>): PetListUiState {
return PetListPresenter(events = events, petDb, ioDispatcher)
}
@AssistedFactory
interface Factory
{
fun create(scope: CoroutineScope, recompositionMode: RecompositionMode): PetListViewModel
}
}
@Composable
fun PetListPresenter(events: Flow<PetListEvent>, petDb: PetDb, ioDispatcher: CoroutineDispatcher): PetListUiState {
var counter by remember { mutableStateOf(0) }
var selectedPet: Pet? by remember { mutableStateOf(null) }
val pets by remember {
petDb.petQueries.selectAll().asFlow()
.mapToList(ioDispatcher)
.map {
it.map { Pet(id = it._id, name = it.name) }
}
}.collectAsState(initial = emptyList())
LaunchedEffect(events) {
events.collect { event ->
print("Received $event")
when(event) {
is PetListEvent.PetSelected -> {
println("Changing selected pet")
selectedPet = event.pet
}
}
}
}
return PetListUiState(
pets = pets,
selectedPet = selectedPet,
)
}
This works great inside an actual Android app, but testing it is proving to be a pain with the following failing test:
@Test
fun `selectedPet gets updated after selecting a pet from the list using viewmodel`() = runTest(timeout = 500.milliseconds) {
val testDispatcher = StandardTestDispatcher(testScheduler)
val testScope = TestScope(testScheduler)
val viewModel = PetListViewModel(petDb, testDispatcher, testScope, RecompositionMode.Immediate)
viewModel.models.test {
println("marker 1")
assertEquals(null, awaitItem().selectedPet)
viewModel.take(PetListEvent.PetSelected(Pet(0, "Sparky")))
println("marker 2")
assertEquals(Pet(0, "Sparky"), awaitItem().selectedPet)
println("marker 3")
}
}
Which fails with this message:
Expected :Pet(id=0, name=Sparky)
Actual :null
Here's a test that succeeds, skipping the PetListViewModel entirely and using the composable function directly:
@Test
fun `selectedPet gets updated after selecting a pet from the list`() = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
val testScope = TestScope(testScheduler)
val events = Channel<PetListEvent>()
testScope.launchMolecule(RecompositionMode.Immediate) {
PetListPresenter(events = events.receiveAsFlow(), petDb = petDb, testDispatcher)
}.test {
println("marker 1")
assertEquals(null, awaitItem().selectedPet)
events.send(PetListEvent.PetSelected(Pet(0, "Sparky")))
println("marker 2")
assertEquals(Pet(0, "Sparky"), awaitItem().selectedPet)
println("marker 3")
}
}
Am I using a wrong CoroutineScope here? I've tried using TestScope and CoroutineScope but no luck. I should note that the PetListViewModel works great inside an Android app with the following instantiation:
private val viewModel: PetListViewModel by retain { entry ->
petListViewModelFactory.get().create(CoroutineScope(entry.scope.coroutineContext + AndroidUiDispatcher.Main), RecompositionMode.ContextClock)
// PetListViewModel(petDb, Dispatchers.IO, entry.scope.coroutineContext + AndroidUiDispatcher.Main)
}
This doesn't look like a Turbine issue, no. I have some free advice, though:
- Using a channel under tests for events is a good idea; be wary, though, of the possibility of the test subject collecting twice on the resulting flow. If this happens, only one collector will receive each event. Ifyou run into this, it can be fixed with
shareIn. - Inside
runTest,thiswill refer to aTestScope, which has abackgroundScopeval; injectbackgroundScopeas yourCoroutineScope. - For your dispatcher, inject
CoroutineContextinstead ofCoroutineDispatcher; that way, you can injectEmptyCoroutineContext, which is a no-op under test (it will just use whatever dispatcher is already active). - You may need to add a
distinctUntilChanged()before testing your molecule.
Happy hunting!