molecule icon indicating copy to clipboard operation
molecule copied to clipboard

Test fails while trying to use a ViewModel-like object with Turbine

Open danielPerez97 opened this issue 2 years ago • 1 comments

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

danielPerez97 avatar Aug 28 '23 21:08 danielPerez97

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, this will refer to a TestScope, which has a backgroundScope val; inject backgroundScope as your CoroutineScope.
  • For your dispatcher, inject CoroutineContext instead of CoroutineDispatcher; that way, you can inject EmptyCoroutineContext, 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!

jingibus avatar Aug 28 '23 22:08 jingibus