cashapp / molecule

Build a StateFlow stream using Jetpack Compose
https://cashapp.github.io/molecule/docs/1.x/
Apache License 2.0
1.87k stars 81 forks source link

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

Open danielPerez97 opened 1 year ago

danielPerez97 commented 1 year ago

(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)
    }
jingibus commented 1 year ago

This doesn't look like a Turbine issue, no. I have some free advice, though:

Happy hunting!