Mastering Room Database Testing and Paging3 in Android
Android Testing Room Paging3

Mastering Room Database Testing and Paging3 in Android

The Paging Library in Android is like having your own personal assistant to help handle all the big files and heavy lifting in mobile applications. Think of it as the super-friend of out build in list components, but with some serious extra powers. The real cool thing about it? It's designed to make loading and displaying large data sets from various sources super-efficient, ensuring your apps run smoothly. It supports both offline and network types of data, making it versatile for all your data needs. Plus, it works seamlessly with Kotlin coroutines and flows, LiveData and RxJava. 

When it comes to testing, think of setting up an in-memory version of your data Here's why this makes total sense: 

  • Isolation: In-memory database keeps your tests separate from the real data hanging out on the device. This way, your tests won't mess with or depend on the actual data.  Speed: Because is not dealing with disk input / output, they're quick.  Predictability: With in-memory databases, you can deck out your testing environment any way you like. You can pre-load it up with specific data to play out different scenarios and you'll always know exactly what to expect from your tests. 

Imagine we have  these two Dao functions and we want to test whether or not we've successfully added new characters into our database and fetched them right back. Notice that getallCharacters returns a PagingSource which will contain our data in chunks.  

@Dao
interface ICharacterDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertCharacters(vararg characters:CharacterEntity
    ):LongArray 

    @Query("SELECT * FROM character_entity")
    fun getAllCharacters(): PagingSource<Int, CharacterEntity>
}        

An in memory Room database for our test set up would look like this.  

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Q])
class RickMortyDatabaseTest {

    private lateinit var characterDao: ICharacterDao
    private lateinit var db: RickMortyDatabase
    private val testDispatcher = UnconfinedTestDispatcher()

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
        val context =   
            ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context,    
            RickMortyDatabase::class.java)
                .allowMainThreadQueries().build()
        characterDao = db.characterDao()
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        db.close()
    }        

@RunWith(RobolectricTestRunner::class)  Robolectric lets us run tests inside a pretend Android world using the JVM (Java Virtual Machine). What we're doing here is building the Room database with a simulated Context. At this stage, we're only checking if our database queries make sense and work well. So for that, we really don't need a physical Android device.  

In the setUp() function, we set Dispatchers.setMain(testDispatcher) to use our custom dispatcher for the main thread. We then create an in-memory version of our RickMortyDatabase and set our dao.  Finally, in tearDown() method, we're resetting the main dispatcher and closing our database connection.

The important part about making our in-memory data base reactive and simulating the fact that we obtain a PaginSource with our data right after insertion is the following.

private fun <PaginationKey : Any, Model : Any>   
    PagingSource<PaginationKey, Model>.getData(): List<Model> {
        val data = mutableListOf<Model>()
        val latch = CountDownLatch(1)
        val job = CoroutineScope(Dispatchers.Main).launch {
        val loadResult: PagingSource.LoadResult<PaginationKey,Model>     
            = this@getData.load(PagingSource.LoadParams.Refresh(
              key = null, 
              loadSize = 10, 
              placeholdersEnabled = false
            ))

         when (loadResult) {
             is PagingSource.LoadResult.Error -> 
                 throw loadResult.throwable
             is PagingSource.LoadResult.Page -> 
                 data.addAll(loadResult.data)
             else -> {}
         }
         latch.countDown()
    }
    latch.await()
    job.cancel()
    return data
}           

In getData() function, we're extending a PagingSource with generic parameters for our PaginationKey and Model. First, we have an empty list to store all the data we're going to collect. We're also setting a CountdownLatch. Imagine it as a countdown timer starting from a specific number and until it reaches zero, the door remains closed! 

When we initialize CountDownLatch with a count of 1, it's like setting that timer to 1. It's waiting for one event to happen before it retracts the barrier. The line 'latch.countDown()' is where we reduce the CountDownLatch's count by one. It's like saying "Hey, the event we were waiting for just happened!". 

In the coroutine, we're asking the PagingSource to load the data, and then storing what we get. When we try to load the data if we ran into trouble, we throw it. If we got a page of results, we put these into our list. In all scenarios we are reducing the countdown by 1, releasing the barrier.  

The 'latch.await()' pauses the progress in our getData() until the latch's count gets down to 0. This happens after getting our data from the PagingSource. Finally we proceed to cancel the coroutine and then return our data. 

Once we did that step, the test would look like this.  

@Test
@Throws(Exception::class)
fun `write and read characters`() = runTest {
     //Given
     val expected = CharacterEntityUtil.createCharacters(10)
     //When
     characterDao.insertCharacters(*expected.toTypedArray())
     val result = characterDao.getAllCharacters().getData()
     //Then
     assertEquals(expected, result)
}        

The body of the test is wrapped within runTest to ensure that all the coroutines launched inside get completed before we finish testing. This way, we're making sure that our test won't finish running till we've done every step of the test. 

Inside the test, we have three sections: Given, When, and Then, which is a popular structure for unit tests. 

  • Given: This is the setup part of the test. Here, we're generating a list of 10 fake characters and storing it in the 'expected' variable. 
  • When: Here is where we perform the operation we want to test. First we insert the characters into the database using  and then reading them back using  our already build in getData() function. 
  • Then: This is the assertion part of the test where we check the results. Here, we're making sure that the data we read out of the database (result) matches the initial dummy data we made (expected). 

So, we're stuffing dummy characters into our database, pulling them back, and having a look to see if they match what we originally put in. If they do, our test passes; if they don't, our test fails, and we know something has gone wrong with our writing or reading functions. 

To view or add a comment, sign in

More articles by Robert Constantin

Others also viewed

Explore content categories