?
Digital Application Development
6 minute read

Testing Android DataStore

As part of their commitment to Kotlin and providing a first-class developer experience using the Jetpack series of libraries, Google announced DataStore at Google I/O this year. If we follow the Google's recommended path of testing DataStore on-device, the process of testing our code is reasonably simple. This also follows our overall philosophy of testing in the most production-like way possible.

In This Article

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.

copy link

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.

 

copy link

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

copy link

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

copy link

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

copy link

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

copy link

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.

copy link

Additional resources