Testing Android Room with LiveData, Coroutines and Transactions
Asked Answered
R

2

18

I want to test my database layer and I have caught myself in a catch-22 type of a situation.

The test case consists of two things:

  • Save some entities
  • Load the entities and assert the database mapping works as expected

The problem, in short, is that:

  • Insert is a suspend method, which means it needs to be run in runBlocking{}
  • Query returns a LiveData of the result, which is also asynchronous. Therefore it needs to be observed. There's this SO question that explains how to do that.
  • In order to observe the LiveData according to the above link, however, I must use the InstantTaskExecutorRule. (Otherwise I get java.lang.IllegalStateException: Cannot invoke observeForever on a background thread.)
  • This works for most of the cases, but it does not work with @Transaction-annotated DAO methods. The test never finishes. I think it's deadlocked on waiting for some transaction thread.
  • Removing the InstantTaskExecutorRule lets the Transaction-Insert method finish, but then I am not able to assert its results, because I need the rule to be able to observe the data.

Detailed description

My Dao class looks like this:

@Dao
interface GameDao {
    @Query("SELECT * FROM game")
    fun getAll(): LiveData<List<Game>>

    @Insert
    suspend fun insert(game: Game): Long

    @Insert
    suspend fun insertRound(round: RoundRoom)

    @Transaction
    suspend fun insertGameAndRounds(game: Game, rounds: List<RoundRoom>) {
        val gameId = insert(game)
        rounds.onEach {
            it.gameId = gameId
        }

        rounds.forEach {
            insertRound(it)
        }
    }

The test case is:

@RunWith(AndroidJUnit4::class)
class RoomTest {
    private lateinit var gameDao: GameDao
    private lateinit var db: AppDatabase

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(
            context, AppDatabase::class.java
        ).build()
        gameDao = db.gameDao()
    }

    @Test
    @Throws(Exception::class)
    fun storeAndReadGame() {
        val game = Game(...)

        runBlocking {
            gameDao.insert(game)
        }

        val allGames = gameDao.getAll()

        // the .getValueBlocking cannot be run on the background thread - needs the InstantTaskExecutorRule
        val result = allGames.getValueBlocking() ?: throw InvalidObjectException("null returned as games")

        // some assertions about the result here
    }

    @Test
    fun storeAndReadGameLinkedWithRound() {
        val game = Game(...)

        val rounds = listOf(
            Round(...),
            Round(...),
            Round(...)
        )

        runBlocking {
            // This is where the execution freezes when InstantTaskExecutorRule is used
            gameDao.insertGameAndRounds(game, rounds)
        }

        // retrieve the data, assert on it, etc
    }
}

The getValueBlocking is an extension function for LiveData, pretty much copypasted from the link above

fun <T> LiveData<T>.getValueBlocking(): T? {
    var value: T? = null
    val latch = CountDownLatch(1)

    val observer = Observer<T> { t ->
        value = t
        latch.countDown()
    }

    observeForever(observer)

    latch.await(2, TimeUnit.SECONDS)
    return value
}

What's the proper way to test this scenario? I need these types of tests while developing the database mapping layer to make sure everything works as I expect.

Rapine answered 14/7, 2019 at 13:27 Comment(4)
This is the only way to test room with live data and coroutine. Soon google will release new test lib to resolve such issues.Unmoral
That's sad to hear. Do you happen to have a link to where they say they will resolve this issue?Rapine
github.com/googlesamples/android-architecture-components/blob/… This is google sample code to test live data.Unmoral
Another related questionFike
R
10

There is now a solution to this issue, explained in this answer.

The fix is adding a single line to the Room in-memory database builder:

db = Room
    .inMemoryDatabaseBuilder(context, AppDatabase::class.java)
    .setTransactionExecutor(Executors.newSingleThreadExecutor()) // <-- this makes all the difference
    .build()

With the single thread executor the tests are working as expected.

Rapine answered 5/4, 2020 at 14:19 Comment(0)
L
4

The problem is with the thing that transactions itself use runBlocking somewhere inside and that cause deadlock. I have changed InstantTaskExecutorRule to this class:

class IsMainExecutorRule : TestWatcher() {

    val defaultExecutor = DefaultTaskExecutor()

    override fun starting(description: Description?) {
        super.starting(description)
        ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
            override fun executeOnDiskIO(runnable: Runnable) {
                defaultExecutor.executeOnDiskIO(runnable)
            }

            override fun postToMainThread(runnable: Runnable) {
                defaultExecutor.executeOnDiskIO(runnable)
            }

            override fun isMainThread(): Boolean {
                return true
            }
        })
    }

    override fun finished(description: Description?) {
        super.finished(description)
        ArchTaskExecutor.getInstance().setDelegate(null)
    }
}

Then in code it will be:

@get:Rule
val liveDataRule = IsMainExecutorRule()

It will not cause deadlocks but still allow to observe livedatas.

Levitical answered 5/8, 2019 at 12:31 Comment(2)
This solution seems to fix the issue but there is no explanation of why exactly it works and no information whether it might affect some other lifecycle-related (e.g. LiveData) tests. If there are no downsides and side effects then why isn't it the default way to test? Can you elaborate @vbevans94?Ministerial
It actually uses the default executor, but runs everything on one of 4 (this may differ with version) threads in a thread pool, which means that it may indeed solve the immediate problem, but any assumptions about main thread sequential execution is almost certainly wrong, and race conditions / non-synced data are likely problems. I would not go down this path.Northeast

© 2022 - 2024 — McMap. All rights reserved.