Android的DataStore在UnitTest测试中实践

630 阅读4分钟

1、介绍

DataStore是Jetpack提供的一种数据存储解决方案,提过两种不同实现:Preferences DataStore 和 Proto DataStore。它的数据是通过文件形式存储的,根据其源码可知,其文件存储在全局文件夹datastore下,代码如下:

 /**
 * Generate the File object for Preferences DataStore based on the provided context and name. The
 * file is in the [this.applicationContext.filesDir] + "datastore/" subdirectory with [name].
 * This is public to allow for testing and backwards compatibility (e.g. if moving from the
 * `preferencesDataStore` delegate or context.createDataStore to
 * PreferencesDataStoreFactory).
 *
 * Do NOT use the file outside of DataStore.
 *
 *  @this the context of the application used to get the files directory
 *  @name the name of the preferences
 */

public fun Context.preferencesDataStoreFile(name: String): File =
    this.dataStoreFile("$name.preferences_pb")

public fun Context.dataStoreFile(fileName: String): File =
    File(applicationContext.filesDir, "datastore/$fileName")

2、UnitTest测试问题

在我们编写测试代码的时候,如果我们测试过程中涉及到了DataStore数据操作,在运行单个测试用例时,不会察觉到数据的问题,但当我们运行多个测试用例时,如果每个测试用例涉及到了相同的DataStore数据操作,则会发现后面的个别用例测试运行达不到预期结果。这是因为其整个对象在运行测试用例过程中,所引用都是同一个对象,数据并没有清除重置。

在我们整个工程里面,涉及到DataStore操作时,其对象是通过单例创建出来。有两处会导致:

1、我们自己代码创建生成的DataStore对象,如:

val Context.netDataStore: DataStore<Preferences> by preferencesDataStore(
    name = SP_NET_TOKEN
)

2、PreferenceDataStoreDelegate 中源码部分:

internal class PreferenceDataStoreSingletonDelegate internal constructor(
    private val name: String,
    private val corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    private val produceMigrations: (Context) -> List<DataMigration<Preferences>>,
    private val scope: CoroutineScope
) : ReadOnlyProperty<Context, DataStore<Preferences>> {
    private val lock = Any()
    @GuardedBy("lock")
    @Volatile
    private var INSTANCE: DataStore<Preferences>? = null
    /**
 * Gets the instance of the DataStore.
 *
 *  @param thisRef must be an instance of [Context]
 *  @param property not used
 */
 override fun getValue(thisRef: Context, property: KProperty<*>): DataStore<Preferences> {
        return INSTANCE ?: synchronized(lock) {
 if (INSTANCE == null) {
                val applicationContext = thisRef.applicationContext

 INSTANCE = PreferenceDataStoreFactory.create(
                    corruptionHandler = corruptionHandler,
                    migrations = produceMigrations(applicationContext),
                    scope = scope
                ) {
 applicationContext.preferencesDataStoreFile(name)
                }
 }
            INSTANCE!!
        }
 }
}

3、UnitTest中的解决方案:

通过源码可以知道,我们可以通过直接删除其文件夹形式删除数据。这一方案在一些博客上也有推荐。但这种操作方式在运行单个测试用例中,可以这样使用。而在多个测试用例中则无法达到清除数据的效果。原因除了其在测试全局是单例外,还因为其数据更新过程中,是存储在内存中。

当在我们更新数据的时候,其数据会同步写入到data中,它是一个flow数据,它内部又是通过一个状态流发送数据的。当我们调用updateData更新数据的时候,它也同步将数据发送到了这个状态流中,代码如下:

internal class SingleProcessDataStore<T>(
    private val produceFile: () -> File,
    private val serializer: Serializer<T>,
    /**
 * The list of initialization tasks to perform. These tasks will be completed before any data
 * is published to the data and before any read-modify-writes execute in updateData.  If
 * any of the tasks fail, the tasks will be run again the next time data is collected or
 * updateData is called. Init tasks should not wait on results from data - this will
 * result in deadlock.
 */
 initTasksList: List<suspend (api: InitializerApi<T>) -> Unit> = emptyList(),
    private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler<T>(),
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
) : DataStore<T> {
    override val data: Flow<T> = flow {
 val currentDownStreamFlowState = downstreamFlow.value

        if (currentDownStreamFlowState !is Data) {
            // We need to send a read request because we don't have data yet.
            actor.offer(Message.Read(currentDownStreamFlowState))
        }

        emitAll(
            downstreamFlow.dropWhile {
 if (currentDownStreamFlowState is Data<T> ||
                    currentDownStreamFlowState is Final<T>
                ) {
                    // We don't need to drop any Data or Final values.
                    false
                } else {
                    // we need to drop the last seen state since it was either an exception or
                    // wasn't yet initialized. Since we sent a message to actor, we *will* see a
                    // new value.
                    it === currentDownStreamFlowState
                }
            } .map {
 when (it) {
                    is ReadException<T> -> throw it.readException
                    is Final<T> -> throw it.finalException
                    is Data<T> -> it.value
                    is UnInitialized -> error(
                        "This is a bug in DataStore. Please file a bug at: " +
                            "https://issuetracker.google.com/issues/new?" +
                            "component=907884&template=1466542"
                    )
                }
            }
 )
    }



 override suspend fun updateData(transform: suspend (t: T) -> T): T {
        /**
 * The states here are the same as the states for reads. Additionally we send an ack that
 * the actor *must* respond to (even if it is cancelled).
 */
 val ack = CompletableDeferred<T>()
        val currentDownStreamFlowState = downstreamFlow.value

        val updateMsg =
            Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)

        actor.offer(updateMsg)
        return ack.await()
    }
}

也就导致了即使我们在每一个测试用例运行前删除了文件,但在我们运行多个测试用例的时候,测试用例中的数据一直使用的是内存中的数据。也就是说,使用到的DataStore数据,事实上都是上一个测试用例跑完后赋值的数据,而不是初始数据。

internal class SingleProcessDataStore<T>(
    private val produceFile: () -> File,
    private val serializer: Serializer<T>,
    /**
 * The list of initialization tasks to perform. These tasks will be completed before any data
 * is published to the data and before any read-modify-writes execute in updateData.  If
 * any of the tasks fail, the tasks will be run again the next time data is collected or
 * updateData is called. Init tasks should not wait on results from data - this will
 * result in deadlock.
 */
 initTasksList: List<suspend (api: InitializerApi<T>) -> Unit> = emptyList(),
    private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler<T>(),
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
) : DataStore<T> {

    override val data: Flow<T> = flow {
 val currentDownStreamFlowState = downstreamFlow.value
        if (currentDownStreamFlowState !is Data) {
            // We need to send a read request because we don't have data yet.
            actor.offer(Message.Read(currentDownStreamFlowState))
        }

        emitAll(
            downstreamFlow.dropWhile {
 if (currentDownStreamFlowState is Data<T> ||
                    currentDownStreamFlowState is Final<T>
                ) {
                    // We don't need to drop any Data or Final values.
                    false
                } else {
                    // we need to drop the last seen state since it was either an exception or
                    // wasn't yet initialized. Since we sent a message to actor, we *will* see a
                    // new value.
                    it === currentDownStreamFlowState
                }
            } .map {
 when (it) {
                    is ReadException<T> -> throw it.readException
                    is Final<T> -> throw it.finalException
                    is Data<T> -> it.value
                    is UnInitialized -> error(
                        "This is a bug in DataStore. Please file a bug at: " +
                            "https://issuetracker.google.com/issues/new?" +
                            "component=907884&template=1466542"
                    )
                }
            }
 )
    }

 override suspend fun updateData(transform: suspend (t: T) -> T): T {
        /**
 * The states here are the same as the states for reads. Additionally we send an ack that
 * the actor *must* respond to (even if it is cancelled).
 */
 val ack = CompletableDeferred<T>()
        val currentDownStreamFlowState = downstreamFlow.value

        val updateMsg =
            Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)

        actor.offer(updateMsg)

        return ack.await()
    }
}

3、UnitTest测试解决方案

所以,关于DataStore的最佳测试实践,是在运行每个测试用例前,将数据重置。比如重新赋值成最初值来解决数据初始化的问题。