房间本地单元测试 - 从 DataSource.Factory 查询 PagedList

Room Local Unit Test - Query PagedList from DataSource.Factory

问题

预计

使用 JUnit 5 本地单元测试,运行 Room 数据库 @InsertQuery TestCoroutineDispatcher()

观察到

房间数据库 @Insert@QueryTestCoroutineDispatcher().runBlockingTest 内执行,导致以下错误。如果使用非测试调度程序 Dispatchers.IO.

显式定义线程,则数据库调用将起作用

错误日志:

Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

实施

1.添加库

build.gradle (SomeProjectName)

dependencies {
    ...
    // JUnit 5
    classpath("de.mannodermaus.gradle.plugins:android-junit5:X.X.X")
}

build.gradle (:someModuleName)

apply plugin: "de.mannodermaus.android-junit5"

// JUnit 5
testImplementation "org.junit.jupiter:junit-jupiter-api:X.X.X"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:X.X.X"

// Robolectric
testImplementation "org.robolectric:robolectric:X.X.X"
testImplementation "androidx.test.ext:junit:X.X.X"

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:X.X.X"

2。创建测试

一个。设置测试 Dispatcher 和 LiveData 执行器。

b。创建测试数据库:Test and debug your database.

c。确保测试数据库与单元测试在同一个 Dispatcher 上执行:Testing AndroidX Room + Kotlin Coroutines - @Eyal Guthmann

d。 运行 TestCoroutineDispatcher().runBlockingTest.

内的数据库 @Insert@Query

SomeTest.kt

import androidx.test.core.app.ApplicationProvider

@ExperimentalCoroutinesApi
@Config(maxSdk = Build.VERSION_CODES.P, minSdk = Build.VERSION_CODES.P)
@RunWith(RobolectricTestRunner::class)
class SomeTest {

    private val testDispatcher = TestCoroutineDispatcher()

    @Test
    fun someTest() = testDispatcher.runBlockingTest {

        // Test setup, moved to test extension in production. Also, cleanup methods not included here for simplicity.

        // Set Coroutine Dispatcher.
        Dispatchers.setMain(testDispatcher)
        // Set LiveData Executor.
        ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
            override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
            override fun postToMainThread(runnable: Runnable) = runnable.run()
            override fun isMainThread(): Boolean = true
        })
        val appContext = ApplicationProvider.getApplicationContext<Context>()
        // Room database setup
        db = Room.inMemoryDatabaseBuilder(appContext, SomeDatabase::class.java)
            .setTransactionExecutor(testDispatcher.asExecutor())
            .setQueryExecutor(testDispatcher.asExecutor())
            .build()
        dao = db.someDao()

        // Insert into database.
        dao.insertData(mockDataList)
        // Query database.
        val someQuery = dao.queryData().toLiveData(PAGE_SIZE).asFlow()
        someQuery.collect {
            // TODO: Test something here.
        }

        // TODO: Make test assertions.
        ...
}

SomeDao.kt

@Dao
interface SomeDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertData(data: List<SomeData>)

    @Query("SELECT * FROM someDataTable")
    fun queryData(): DataSource.Factory<Int, SomeData>
}

尝试过的解决方案

1。将 suspend 修饰符添加到 SomeDao.ktqueryData 函数。

添加 suspend 后,随后调用 queryData 的方法也必须实现 suspend 或使用 launch 从协程启动,如下所示.

这会导致编译器出错。

error: Not sure how to convert a Cursor to this method's return type (androidx.paging.DataSource.Factory<{SomeDataClassPathHere}>).

SomeDao.kt

@Dao
interface SomeDao {
    ...
    @Query("SELECT * FROM someDataTable")
    suspend fun queryData(): DataSource.Factory<Int, SomeData>
}

SomeRepo.kt

suspend fun getInitialData(pagedListBoundaryCallback: PagedList.BoundaryCallback<SomeData>) = flow {
        emit(Resource.loading(null))
        try {
            dao.insertData(getDataRequest(...))
            someDataQuery(pagedListBoundaryCallback).collect {
                emit(Resource.success(it))
            }
        } catch (error: Exception) {
            someDataQuery(pagedListBoundaryCallback).collect {
                emit(Resource.error(error.localizedMessage!!, it))
            }
        }
    }

SomeViewModel.kt

private suspend fun loadNetwork(toRetry: Boolean) {
    repository.getInitialData(pagedListBoundaryCallback(toRetry)).onEach {
            when (it.status) {
                LOADING -> _viewState.value = ...
                SUCCESS -> _viewState.value = ...
                ERROR -> _viewState.value = ...
            }
    }.flowOn(coroutineDispatcherProvider.io()).launchIn(coroutineScope)
}

fun bindIntents(view: FeedView) {
        view.loadNetworkIntent().onEach {
            coroutineScope.launch(coroutineDispatcherProvider.io()) {
                loadNetwork(it.toRetry)
            }
        }.launchIn(coroutineScope)
    }

 private fun pagedListBoundaryCallback(toRetry: Boolean) =
        object : PagedList.BoundaryCallback<SomeData>() {
            override fun onZeroItemsLoaded() {
                super.onZeroItemsLoaded()
                if (toRetry) {
                    coroutineScope.launch(coroutineDispatcherProvider.io()) {
                        loadNetwork(false)
                    }
                }
            }

2。 运行 测试 TestCoroutineScope.

SomeTest.kt

@ExperimentalCoroutinesApi
@Config(maxSdk = Build.VERSION_CODES.P, minSdk = Build.VERSION_CODES.P)
@RunWith(RobolectricTestRunner::class)
class SomeTest {
    private val testDispatcher = TestCoroutineDispatcher()
    private val testScope = TestCoroutineScope(testDispatcher)


    @Test
    fun someTest() = testScope.runBlockingTest {
        ... 
    }

3。 运行 测试 runBlockingTest.

    @Test
    fun someTest() = runBlockingTest {
        ... 
    }

4。使用 TestCoroutineDispatcher 上的 TestCoroutineScope 启动房间调用。

这不会导致主线程错误。但是,房间调用不适用于此方法。

    @Test
    fun topCafesTest() = testDispatcher.runBlockingTest {
        testScope.launch(testDispatcher) {
            dao.insertCafes(mockCafesList)
            val cafesQuery = dao.queryCafes().toLiveData(PAGE_SIZE).asFlow()
            cafesQuery.collect {
                ...
            }
        }
    }

完整的错误日志

java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

at androidx.room.RoomDatabase.assertNotMainThread(RoomDatabase.java:267) at androidx.room.RoomDatabase.beginTransaction(RoomDatabase.java:351) at app.topcafes.feed.database.FeedDao_Impl.call(FeedDao_Impl.java:91) at app.topcafes.feed.database.FeedDao_Impl.call(FeedDao_Impl.java:88) at androidx.room.CoroutinesRoom$Companion$execute.invokeSuspend(CoroutinesRoom.kt:54) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) at androidx.room.TransactionExecutor.run(TransactionExecutor.java:45) at kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50) at kotlinx.coroutines.DispatcherExecutor.execute(Executors.kt:62) at androidx.room.TransactionExecutor.scheduleNext(TransactionExecutor.java:59) at androidx.room.TransactionExecutor.execute(TransactionExecutor.java:52) at kotlinx.coroutines.ExecutorCoroutineDispatcherBase.dispatch(Executors.kt:82) at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:166) at kotlinx.coroutines.BuildersKt.withContext(Unknown Source) at androidx.room.CoroutinesRoom$Companion.execute(CoroutinesRoom.kt:53) at androidx.room.CoroutinesRoom.execute(CoroutinesRoom.kt) at app.topcafes.feed.database.FeedDao_Impl.insertCafes(FeedDao_Impl.java:88) at app.topcafes.FeedTest$topCafesTest.invokeSuspend(FeedTest.kt:76) at app.topcafes.FeedTest$topCafesTest.invoke(FeedTest.kt) at kotlinx.coroutines.test.TestBuildersKt$runBlockingTest$deferred.invokeSuspend(TestBuilders.kt:50) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) at kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50) at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async(Builders.common.kt:91) at kotlinx.coroutines.BuildersKt.async(Unknown Source) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async$default(Builders.common.kt:84) at kotlinx.coroutines.BuildersKt.async$default(Unknown Source) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:49) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:80) at app.topcafes.FeedTest.topCafesTest(FeedTest.kt:70) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) at org.robolectric.RobolectricTestRunner$HelperTestRunner.evaluate(RobolectricTestRunner.java:546) at org.robolectric.internal.SandboxTestRunner.lambda$evaluate[=41=](SandboxTestRunner.java:252) at org.robolectric.internal.bytecode.Sandbox.lambda$runOnMainThread[=41=](Sandbox.java:89) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748)

运行 房间 @Insert@Query Dispatchers.IO

SomeTest.kt

@ExperimentalCoroutinesApi
@Config(maxSdk = Build.VERSION_CODES.P, minSdk = Build.VERSION_CODES.P)
@RunWith(RobolectricTestRunner::class)
class SomeTest {

    private val testDispatcher = TestCoroutineDispatcher()
    private val testScope = TestCoroutineScope(testDispatcher)

    @Test
    fun someTest() = testDispatcher.runBlockingTest {
        // Same Dispatcher, LiveData, and Room setup used as defined in the question above.

        testScope.launch(Dispatchers.IO) {
            // Insert into database.
            dao.insertData(mockDataList)
            // Query database.
            val someQuery = dao.queryData().toLiveData(PAGE_SIZE).asFlow()
            someQuery.collect {
                // TODO: Test something here.
            }
        }

        // TODO: Make test assertions.
        ...
}