Issue
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)
}
Answered By - Peter
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.