AdamHurwitz
AdamHurwitz

Reputation: 10364

Room Local Unit Test - Query PagedList from DataSource.Factory

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

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)

Upvotes: 0

Views: 1960

Answers (1)

AdamHurwitz
AdamHurwitz

Reputation: 10364

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.
        ...
}

Upvotes: 3

Related Questions