Testing Android DataStore
In this blog
Google's Jetpack DataStore is still in beta at the time of writing — meaning that the API is stable but they are working on final polish — which means this is a perfect time to become familiar with the library!
DataStore allows us to store either key-value pairs, or strongly typed objects, and is the recommended replacement for the legacy SharedPreferences Android API. According to Google,
DataStore uses Kotlin coroutines and Flow to store data asynchronously, consistently, and transactionally.
This article will focus specifically on how to use and test the Preferences DataStore to store simple key-value pairs. If you need to store strongly typed, complex objects, consider using the protocol buffers-based Proto DataStore instead.
My test project
To test DataStore I created a simple application that remembers your name when you launch it, and clears the stored name if you enter a blank when prompted.
Project configuration
Google recommends managing your application DataStore as a singleton. I chose to use Koin dependency injection in my project to manage singletons and inject instances of the data storage keys. This centralized my configuration to a single location and gave an abstraction to ease the testing process.
The simplest way to test DataStore is to run the test on-device as part of an Integration Test Suite that lives in the androidTest
source tree. To support this, I added some extra Gradle dependencies in my build
dependencies {
// ... skipping other project dependencies .. //
implementation 'io.insert-koin:koin-android:3.0.1'
implementation 'io.insert-koin:koin-android-ext:3.0.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3'
implementation 'androidx.datastore:datastore-preferences:1.0.0-beta01'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.3'
}
The Koin module configuration was reasonably simple - the production code was written to be agnostic about the keys used with the DataStore (it just cares that we have a key that represents a returning user) so we declared them in our Koin module to centralize the configuration of our store.
private val preferencesModule = module {
factory(named("returning-user")) {
stringPreferencesKey("returning-user")
}
single {
PreferenceDataStoreFactory.create {
androidContext().preferencesDataStoreFile("my-preferences")
}
}
}
and then configured the ViewModel to take them as constructor parameters.
val firstModule = module {
viewModel {
FirstViewModel(
dataStore = get(),
returningUserKey = get(named("returning-user"))
)
}
}
Test setup and teardown
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()
}
}
DataStore files need to be flushed between tests to ensure a clean state; we can simply delete all files in the application's filesDir
to ensure this happens but the more important part of the cleanup is canceling the coroutine context associated with the DataStore - if that isn't canceled then an error will be raised on a test run.
Our user interface uses LiveData and Coroutines, both of which can be configured to run in blocking / synchronous mode to make tests easier. This code makes an ideal base class to live in a shared location in your project!
abstract class CoroutineTest {
@Rule
@JvmField
val rule = InstantTaskExecutorRule()
protected val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
protected val testCoroutineScope = TestCoroutineScope(testDispatcher)
@Before
fun setupViewModelScope() {
Dispatchers.setMain(testDispatcher)
}
@After
fun cleanupViewModelScope() {
Dispatchers.resetMain()
}
@After
fun cleanupCoroutines() {
testDispatcher.cleanupTestCoroutines()
testDispatcher.resumeDispatcher()
}
fun coTest(block: suspend TestCoroutineScope.() -> Unit) =
testCoroutineScope.runBlockingTest(block)
}
First test: Reading from a DataStore
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class FirstViewModelTest : DataStoreTest() {
private val returningUserName = UUID.randomUUID().toString()
private val testKey: Preferences.Key<String> = stringPreferencesKey("test-key")
@Test
fun defaultMessageDisplayedWhenNoPreferenceAvailable() = coTest {
val testObject = FirstViewModel(dataStore, testKey)
testObject.greeting.observeForever { value ->
assertEquals("Hello there and welcome!", value)
}
}
@Test
fun storedNameIsUsedWhenAvailable() = coTest {
dataStore.edit { preferences ->
preferences[testKey] = returningUserName
}
val testObject = FirstViewModel(dataStore, testKey)
testObject.greeting.observeForever { value ->
assertEquals("Welcome back $returningUserName!", value)
}
}
}
Our initial test is (by far) the simplest and validates that the DataStore has been created correctly but is empty. A default message should display if no returning user is found.
Running the test coroutine dispatcher in blocking mode enables us to write a very clean and simple second test - the lambda passed to the edit()
method is committed to the file as a distinct transaction before the rest of the test method runs and we construct the ViewModel.
Making the test pass, following the Red - Green - Refactor cycle of test-driven development, made good use of LiveData's support for coroutines and Flow: the call to collect()
replaces a much more cumbersome API in the legacy SharedPreferences world that would have required creating and registering a listener and remembering to de-register it later.
class FirstViewModel(
private val dataStore: DataStore<Preferences>,
private val returningUserKey: Preferences.Key<String>
) : ViewModel() {
val greeting: LiveData<String> = liveData {
dataStore.data.collect {
val user: String? = it[returningUserKey]
val displayName = if (user.isNullOrEmpty()) DEFAULT_MESSAGE else "Welcome back $user!"
emit(displayName)
}
}
companion object {
const val DEFAULT_MESSAGE = "Hello there and welcome!"
}
}
Second test: Writing to a DataStore
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class SecondViewModelTest : DataStoreTest() {
private val returningUserName = UUID.randomUUID().toString()
private val testKey: Preferences.Key<String> = stringPreferencesKey("test-key")
@Test
fun saveNameOverridesPreviousValue() = coTest {
val previousName = UUID.randomUUID().toString()
dataStore.edit { preferences ->
preferences[testKey] = previousName
}
val testObject = SecondViewModel(dataStore, testKey)
assertEquals(previousName, testKey.findCurrentValue())
testObject.saveName(returningUserName)
assertEquals(returningUserName, testKey.findCurrentValue())
}
suspend fun <T> Preferences.Key<T>.findCurrentValue() =
dataStore.data.first()[this]
}
As we saw in the initial test - we can simply write a value to the DataStore as we start our test and it will be stored. This makes it very easy to set an initial value and then verify that saving a new value replaces it in the store.
@Test
fun savingAnEmptyNameRemovesThePreference() = coTest {
dataStore.edit { preferences ->
preferences[testKey] = returningUserName
}
val testObject = SecondViewModel(dataStore, testKey)
testObject.saveName("")
assertNull(testKey.findCurrentValue())
}
DataStore returns null
when you query for data and its not found; testing that our production code removes a key-value pair boils down to a simple null check. Making the test pass was as simple as creating a suspend function that we call as we handle the "next" button click.
class SecondViewModel(
private val dataStore: DataStore<Preferences>,
private val returningUserKey: Preferences.Key<String>
) : ViewModel() {
fun moveToThird(v: View) {
viewModelScope.launch {
saveName(nameField.value)
v.findNavController().navigate(
SecondFragmentDirections.actionSecondFragmentToThirdFragment(nameField.value)
)
}
}
@VisibleForTesting
suspend fun saveName(name: String?) {
dataStore.edit {
if (name.isNullOrEmpty()) {
it.remove(returningUserKey)
} else {
it[returningUserKey] = name
}
}
}
}
Final thoughts
If we follow the Google recommended path of testing DataStore on-device, the process of testing our code is reasonably simple thanks to the testing support in LiveData and Coroutines.
Additional resources
- Google announced DataStore (in beta) at Google I/O this year: https://youtu.be/D_mVOAXcrtc?t=720
- Jetpack DataStore documentation: https://developer.android.com/topic/libraries/architecture/datastore
- Example code repo: https://github.com/wwt/testing-android-datastore