Skip to content

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

@danielPerez97

Description

@danielPerez97

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions