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:
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.
Recommended by LinkedIn
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.
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.