Issue
Issue
Expected
Using a JUnit 5 local unit test, run a Room database @Insert
and Query
within a TestCoroutineDispatcher()
.
Observed
The Room database @Insert
and @Query
is executed within TestCoroutineDispatcher().runBlockingTest
, causing the error below. The database calls will work if the threading is explicitly defined with the non-test dispatcher, Dispatchers.IO
.
Error log:
Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
Implement
1. Add libraries
- JUnit 5: Setup
- Robolectric: Getting Started
- AndroidX: Set up project for AndroidX Test > List of AndroidX Test dependencies
- Kotlin coroutines: Using in your project
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. Create test
a. Set test Dispatcher and LiveData executor.
b. Create a test database: Test and debug your database.
c. Ensure the test database executes on the same Dispatcher as the unit test: Testing AndroidX Room + Kotlin Coroutines - @Eyal Guthmann
d. Run the database @Insert
and @Query
within TestCoroutineDispatcher().runBlockingTest
.
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>
}
Attempted solutions
1. Add suspend
modifier to SomeDao.kt's queryData
function.
After adding suspend
, the methods that subsequently call queryData
must either implement suspend
as well or be launched using launch
, from a coroutine, as shown below.
This results in an error from the compiler.
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. Run the test with 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. Run the test with runBlockingTest
.
@Test
fun someTest() = runBlockingTest {
...
}
4. Launch the Room calls using TestCoroutineScope on TestCoroutineDispatcher.
This doesn't cause a main thread error. However, the Room calls do not work with this method.
@Test
fun topCafesTest() = testDispatcher.runBlockingTest {
testScope.launch(testDispatcher) {
dao.insertCafes(mockCafesList)
val cafesQuery = dao.queryCafes().toLiveData(PAGE_SIZE).asFlow()
cafesQuery.collect {
...
}
}
}
Full error log
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$2.call(FeedDao_Impl.java:91) at app.topcafes.feed.database.FeedDao_Impl$2.call(FeedDao_Impl.java:88) at androidx.room.CoroutinesRoom$Companion$execute$2.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$1.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$1.invokeSuspend(FeedTest.kt:76) at app.topcafes.FeedTest$topCafesTest$1.invoke(FeedTest.kt) at kotlinx.coroutines.test.TestBuildersKt$runBlockingTest$deferred$1.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$1.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$1.evaluate(RobolectricTestRunner.java:546) at org.robolectric.internal.SandboxTestRunner$2.lambda$evaluate$0(SandboxTestRunner.java:252) at org.robolectric.internal.bytecode.Sandbox.lambda$runOnMainThread$0(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)
Solution
Run Room @Insert
and @Query
on 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.
...
}
Answered By - Adam Hurwitz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.