Unit Testing Jetpack Datastore

Alfian HanantioAlfian Hanantio
5 min read

Jetpack DataStore is a popular and powerful data storage solution for Android apps. It allows us to store either key-value pairs, or strongly typed objects with protocol buffer. In this article, we will explore how to test our DataStore.

Setup

Kotlin coroutines and Flow are used to store data asynchronously, consistently, and transactionally in DataStore. Therefore, to perform testing of coroutine-related functionality in DataStore, it is necessary to add the coroutine test dependency in our module.

    dependencies {
        implementation("androidx.datastore:datastore-preferences:1.0.0")
        testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
        ...
    }

Let's say we have a class that is responsible for reading and writing data to our DataStore. So we want to check that the class and its functions are doing what they are supposed to do when interacting with DataStore.

val KEY_NAME = stringPreferencesKey("name")

class UserRepository(
    private val dataStore: DataStore<Preferences>,
) {
    val name: Flow<String> = dataStore.data.map { preferences ->
        preferences[KEY_NAME].orEmpty()
    }

    suspend fun setName(name: String) {
        dataStore.edit { mutablePreferences ->
            mutablePreferences[KEY_NAME] = name
        }
    }
}

Testing DataStore

To fully test a DataStore, we need to ensure that real updates are being made to our storage as expected, just like we are writing and reading from an actual file.

We can also simply mock and inject a DataStore instance to our subject when unit testing, but we would not be able to do actual validation checks on the DataStore itself.

Create UserRepositoryTest class:

class UserRepostiroyTest {
    // All test cases we want to cover
    @Test
    fun whenGetNameForTheFirstTime_thenReturnDefaultValue() {
    }

    @Test
    fun whenSetName_thenUpdateName() {
    }
}

Having set up the structure for our test, let's begin by creating our test subject, which is UserRepository, and then proceed in reverse order!

In order to obtain an instance of UserRepository, we must provide a DataStore instance as a parameter.

private val subject: UserRepository = UserRepository(testDataStore)

We will construct a testDataStore instance that generates a distinct test file, which we can then employ to establish and verify dummy data.

private val testDataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
        scope = testScope,
        produceFile = { TODO() }
    )

To construct a Preference DataStore instance, we use the PreferenceDataStoreFactory and pass a coroutine scope and function to produce a file for DataStore.

  • scope - The scope in which IO operations and transform functions will execute.

  • produceFile - Function which returns the file that the new DataStore will act on. The function must return the same path every time. No two instances of PreferenceDataStore should act on the same file at the same time. The file must have the extension preferences_pb.

Because DataStore is built on Kotlin coroutines, it's crucial to ensure that our test has the appropriate coroutine setup in place. This can be achieved by adding a test dispatcher and test scope

private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher + Job())

The scope provides detailed control over the execution of coroutines for tests. Adding the Job facilitates simple cancellation of the coroutine as part of the routine cleanup process following each test.

To produce files for DataStore we need to define TemporaryFolder rule to allow us creating files and folders that should be deleted when the test method finishes (whether it passes or fails).

@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()

After defining TempraryFolder rule we will produce a file for DataStore using newFile function and our DataStore now look like this:

private val testDataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
        scope = testScope,
        produceFile = { tmpFolder.newFile("user.preferences_pb") }
    )

Things to notes: the file must have the extension preferences_pb

After setting up, our test class should now look like this:

class UserRepositoryTest {

    @get:Rule
    val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
    private val testDispatcher = UnconfinedTestDispatcher()
    private val testScope = TestScope(testDispatcher + Job())

    private val testDataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
        scope = testScope,
        produceFile = { tmpFolder.newFile("user.preferences_pb") }
    )
    private val subject: UserRepository = UserRepository(testDataStore)

    @Test
    fun whenGetNameForTheFirstTime_thenReturnDefaultValue() {
    }

    @Test
    fun whenSetName_thenUpdateName() {
    }
}

Our first test case is to check when get name for the first time after testDataStore is created then we will get the snapshot of the data without subscribing to the Flow and do a quick check against our expected result.

@Test
fun whenGetNameForTheFirstTime_thenReturnDefaultValue() = testScope.runTest {
    //When
    val actual = subject.name.first()

    //Then
    assertEquals("", actual)
}

Then have one test case left. Our next test case verifies when our repository set name should make changes to DataStore.

@Test
    fun whenSetName_thenUpdateName() = testScope.runTest {
        // When
        subject.setName("Alfian")

        //Then
        assertEquals("Alfian", subject.name.first())
    }

Finally, let’s take a look at the whole test class now that it’s complete:

class UserRepositoryTest {

    @get:Rule
    val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
    private val testDispatcher = UnconfinedTestDispatcher()
    private val testScope = TestScope(testDispatcher + Job())

    private val testDataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
        scope = testScope,
        produceFile = { tmpFolder.newFile("user.preferences_pb") }
    )
    private val subject: UserRepository = UserRepository(testDataStore)

    @Test
    fun whenGetNameForTheFirstTime_thenReturnDefaultValue() = testScope.runTest {
        //When
        val actual = subject.name.first()

        //Then
        assertEquals("", actual)
    }

    @Test
    fun whenSetName_thenUpdateName() = testScope.runTest {
        // When
        subject.setName("Alfian")

        //Then
        assertEquals("Alfian", subject.name.first())
    }
}

Conclusions

Testing our DataStore is an important step in ensuring that our Android apps are reliable and robust. With Jetpack DataStore, we can store data asynchronously and transactionally, making it a powerful storage solution for modern Android apps. By following the steps outlined in this article, we can test our DataStore implementation and verify that it's working as expected. This will help us catch potential issues early in the development process and give us confidence that our app is working correctly.

0
Subscribe to my newsletter

Read articles from Alfian Hanantio directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Alfian Hanantio
Alfian Hanantio