测试中的 LiveData(由 Flow 支持)反映了旧值

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

我正在尝试为我的视图模型编写一个测试来验证当我调用 setFirstTime 时,视图模型的 state 包含 firstTime 的更新值设置为 false.

UserPreferencesRepository 向视图模型提供 Flow 首选项,将它们公开为 LiveData(使用 asLiveData 扩展名)。

这是我遇到问题的测试:

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

我通过调试器验证了 dataStore.edit 代码的主体在测试的最后一个断言之前 运行。我还注意到 dataStore.data.map 的正文在更新后也是 运行,正确填充的 preferences 设置为 false。似乎 运行 在调试模式下测试并快速通过我的断点会导致测试通过,但 运行 测试通常会失败,这让我相信存在一些竞争条件存在。

我的工作基于 Google Codelab。任何帮助将不胜感激。

我设法确定了问题所在。当我在应用程序中创建我的 DataStore 时,我使用的是默认协程范围,即 Dispatchers.IO。在我的测试中,我将主协程替换为 kotlinx.coroutines.test.TestCoroutineDispatcher,但我还需要以某种方式将 DataStore 实例化为 TestCoroutineScope,以便那些保存操作将 运行同步。

从这个 extremely helpful article 中获得了很多自由,我的最终代码如下所示:

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