Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
danielPerez97 opened this issue Aug 28, 2023 · 1 comment
Open

Comments

@danielPerez97
Copy link

(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
Copy link
Contributor

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants