7

I just added DataStore to our codebase. After that, I found that all sequential UI tests are failing - the first one in a test case pass but next fails with There are multiple DataStores active for the same file.

I provide a data store instance using Hilt

@InstallIn(SingletonComponent::class)
@Module
internal object DataStoreModule {

    @Singleton
    @Provides
    internal fun provideConfigurationDataStore(
        @ApplicationContext context: Context,
        configurationLocalSerializer: ClientConfigurationLocalSerializer
    ): DataStore<ClientConfigurationLocal> = DataStoreFactory.create(
        serializer = configurationLocalSerializer,
        produceFile = { context.dataStoreFile("configuration.pb") }
    )
}

I guess this is happening because In a Hilt test, the singleton component’s lifetime is scoped to the lifetime of a test case rather than the lifetime of the Application.

Any ideas on how to workaround this?

Dawid Hyży
  • 3,581
  • 5
  • 26
  • 41

4 Answers4

3

I had the same issue. One solution I tried but which didn't work (correctly) is to make sure the tests, once done, remove the dataStore files (the whole folder) and close the scope (the overridden scope that you manage in a "manager" class), like so: https://github.com/wwt/testing-android-datastore/blob/main/app/src/androidTest/java/com/wwt/sharedprefs/DataStoreTest.kt

I had this in a finished() block of a TestWatcher used for these UI tests. For some reason, this was not enough so I ended up not looking deeper into why.

Instead I just used a simpler solution: the UI tests would use their own Dagger component, which has its own StorageModule module, which provides its own IStorage implementation, which for UI tests is backed just by an in-memory map, whereas on a production Dagger module would back it up via a DataStore:

interface IStorage {

suspend fun retrieve(key: String): String?

suspend fun store(key: String, data: String)

suspend fun remove(key: String)

suspend fun clear()

I prefer this approach in my case as I don't need to test the actual disk-persistance of this storage in UI tests, but if I had needed it, I'd investigate further into how to reliably ensure the datastore folder and scope are cleaned up before/after each UI test.

oblakr24
  • 825
  • 8
  • 5
1

I was having the same issues and I came out with a workaround. I append a random number to the file name of the preferences for each test case and I just delete the whole datastore file afterward.

HiltTestModule

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [LocalModule::class, RemoteModule::class]
)
object TestAppModule {

    @Singleton
    @Provides
    fun provideFakePreferences(
        @ApplicationContext context: Context,
        scope: CoroutineScope
    ): DataStore<Preferences> {
        val random = Random.nextInt() // generating here
        return PreferenceDataStoreFactory
            .create(
                scope = scope,
                produceFile = {
                    // creating a new file for every test case and finally 
                    // deleting them all
                    context.preferencesDataStoreFile("test_pref_file-$random")
                }
            )
    }
}

@After function

@After
fun teardown() {
    File(context.filesDir, "datastore").deleteRecursively()
}
Astro
  • 376
  • 5
  • 11
0

I'd suggest for more control + better unit-test properties (ie. no IO, fast, isolated) oblakr24's answer is a good clean way to do this; abstract away the thing that you don't own that has behavior undesirable in tests.

However, there's also the possibility these tests are more like end-to-end / feature tests, so you want them to be as "real" as possible, fewer test doubles, maybe just faking a back-end but otherwise testing your whole app integrated. If so, you ought to use the provided property delegate that helps to ensure a singleton, and declare it top-level, outside a class, as per the docs. That way the property delegate will only get created once within the class-loader, and if you reference it from somewhere else (eg. in your DI graph) that will get torn down and recreated for each test, it won't matter; the property delegate will ensure the same instance is used.

themightyjon
  • 1,241
  • 11
  • 12
0

A more general solution, not limited to Hilt, would be to mock Context.dataStoreFile() function with mockk to return a random file name.

I like this approach as it doesn't require any changes on the production code.

Example of TestWatcher:

class CleanDataStoreTestRule : TestWatcher() {

    override fun starting(description: Description) {
        replaceDataStoreNamesWithRandomUuids()
        super.starting(description)
    }

    override fun finished(description: Description) {
        super.finished(description)
        removeDataStoreFiles()
    }
    
    private fun replaceDataStoreNamesWithRandomUuids() {
        mockkStatic("androidx.datastore.DataStoreFile")
        val contextSlot = slot<Context>()
        every {
            capture(contextSlot).dataStoreFile(any())
        } answers {
            File(
                contextSlot.captured.filesDir,
                "datastore/${UUID.randomUUID()}",
            )
        }
    }

    private fun removeDataStoreFiles() {
        InstrumentationRegistry.getInstrumentation().targetContext.run {
            File(filesDir, "datastore").deleteRecursively()
        }
    }
}

and then use it in tests:

class SomeTest {
    
    @get:Rule
    val cleanDataStoreTestRule = CleanDataStoreTestRule()
    
    ...
}

The solution assumes that you use Context.dataStoreFile() and that the file name does not matter. IMO the assumptions are reasonable in most cases.

Jure Sencar
  • 554
  • 4
  • 13