Search code examples
androidandroid-livedataandroid-testingkotlin-flow

LiveData (backed by Flow) in Test is reflecting old value


I am trying to write a test for my View Model that verifies when I call setFirstTime, the state of the view model contains the updated value for firstTime set to false.

The UserPreferencesRepository provides a Flow of the preferences to the viewmodel, which exposes them as LiveData (using asLiveData extension).

Here is my test I am having trouble with:

MainViewModelTest.kt

package com.example.fitness.main

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.fitness.MainCoroutineRule
import com.example.fitness.data.UserPreferencesRepository
import com.example.fitness.getOrAwaitValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class MainViewModelTest {
    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    @get:Rule
    @ExperimentalCoroutinesApi
    var mainCoroutineRule = MainCoroutineRule()

    private lateinit var mainViewModel: MainViewModel

    @Inject
    lateinit var userPreferencesRepository: UserPreferencesRepository

    @Before
    @ExperimentalCoroutinesApi
    fun init() {
        hiltRule.inject()

        // Execute all pending coroutine actions in MainViewModel initialization
        mainCoroutineRule.runBlockingTest {
            mainViewModel = MainViewModel(userPreferencesRepository)
        }
    }

    @ExperimentalCoroutinesApi
    @Test
    fun `#setFirstTime marks the user as have opened the app at least once`() {
        assertThat(mainViewModel.state.getOrAwaitValue().firstTime, `is`(true))

        mainCoroutineRule.runBlockingTest {
            mainViewModel.setFirstTime()
        }

        # Failing assertion. Comes back as `true` when I expect it to be `false`
        assertThat(mainViewModel.state.getOrAwaitValue().firstTime, `is`(false))
    }
}

MainViewModel.kt

package com.example.fitness.main

import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.example.fitness.data.UserPreferencesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class MainViewModel @Inject constructor(
    private val userPreferencesRepository: UserPreferencesRepository
) : ViewModel() {
    val state = userPreferencesRepository.userPreferencesFlow.asLiveData()

    /**
     * Persists a value signifying that the user has started the app before.
     */
    fun setFirstTime() {
        viewModelScope.launch {
            userPreferencesRepository.updateFirstTime(false)
        }
    }
}

UserPreferencesRepository

package com.example.fitness.data

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

data class UserPreferences(
    val firstTime: Boolean
)

class UserPreferencesRepository @Inject constructor(private val dataStore: DataStore<Preferences>) {
    private object PreferencesKeys {
        val FIRST_TIME = booleanPreferencesKey("first_time")
    }

    val userPreferencesFlow: Flow<UserPreferences> = dataStore.data.map { preferences ->
        val firstTime = preferences[PreferencesKeys.FIRST_TIME] ?: true
        UserPreferences(firstTime)
    }

    suspend fun updateFirstTime(firstTime: Boolean) {
        dataStore.edit { preferences ->
            preferences[PreferencesKeys.FIRST_TIME] = firstTime
        }
    }
}

I verified via the debugger that the body of the dataStore.edit code is being run prior to the last assertion of the test. I also noticed that the body of dataStore.data.map is also being run after the update, with the correctly populated preferences set to false. It appears that running the test in debug mode and quickly stepping through my break points results in a passing test, but running the test normally produces a failure, which leads me to believe there is some race condition present.

I am basing my work off of a Google Codelab. Any help would be greatly appreciated.


Solution

  • I managed to determine what the issue was. When I am creating my DataStore in the app, I am using the default coroutine scope, which is Dispatchers.IO. In my tests, I was replacing the main coroutine with kotlinx.coroutines.test.TestCoroutineDispatcher, but I needed to somehow instantiate the DataStore with a TestCoroutineScope as well, so that those saving actions would run synchronously.

    Taking a lot of liberties from this extremely helpful article, my final code looks like:

    MainViewModelTest.kt

    @RunWith(AndroidJUnit4::class)
    class MainViewModelTest : DataStoreTest() {
    
        private lateinit var mainViewModel: MainViewModel
    
        @Before
        fun init() = runBlockingTest {
            val userPreferencesRepository = UserPreferencesRepository(dataStore)
            mainViewModel = MainViewModel(userPreferencesRepository)
        }
    
        @Test
        fun `#setFirstTime marks the user as having opened the app at least once`() = runBlockingTest {
            assertThat(mainViewModel.state.getOrAwaitValue().firstTime, `is`(true))
    
            mainViewModel.setFirstTime()
    
            assertThat(mainViewModel.state.getOrAwaitValue().firstTime, `is`(false))
        }
    }
    

    DataStoreTest.kt

    abstract class DataStoreTest : CoroutineTest() {
    
        private lateinit var preferencesScope: CoroutineScope
        protected lateinit var dataStore: DataStore<Preferences>
    
        @Before
        fun createDatastore() {
            preferencesScope = CoroutineScope(testDispatcher + Job())
    
            dataStore = PreferenceDataStoreFactory.create(scope = preferencesScope) {
                InstrumentationRegistry.getInstrumentation().targetContext.preferencesDataStoreFile(
                    "test-preferences-file"
                )
            }
        }
    
        @After
        fun removeDatastore() {
            File(
                ApplicationProvider.getApplicationContext<Context>().filesDir,
                "datastore"
            ).deleteRecursively()
    
            preferencesScope.cancel()
        }
    }
    

    CoroutineTest.kt

    abstract class CoroutineTest {
        @Rule
        @JvmField
        val rule = InstantTaskExecutorRule()
    
        protected val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
        private val testCoroutineScope = TestCoroutineScope(testDispatcher)
    
        @Before
        fun setupViewModelScope() {
            Dispatchers.setMain(testDispatcher)
        }
    
        @After
        fun cleanupViewModelScope() {
            Dispatchers.resetMain()
        }
    
        @After
        fun cleanupCoroutines() {
            testDispatcher.cleanupTestCoroutines()
            testDispatcher.resumeDispatcher()
        }
    
        fun runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) =
            testCoroutineScope.runBlockingTest(block)
    }