Kotlin 101: Unit Tests Cheat Sheet for Paging Library and Room Database
Recently, I started a new job as a mobile developer. My first assignment: write unit tests for an application that leveraged Paging Library. If you’re unfamiliar with this powerful tool, you can delve deeper into its info here: Paging Library Overview.
To give you a quick snapshot, the Paging Library is an integral part of Android’s Architecture Components, designed to streamline the handling of large datasets in a mobile application. While it indeed interfaces with the RecyclerView, it goes well beyond simple extension. What truly distinguishes it is the intricately engineered approach it adopts for data management. Here’s a breakdown of how it operates:
- Efficient Data Retrieval: The Paging Library is designed to lessen the burden on the backend server. In situations with a limited user base, data retrieval may seem straightforward. However, when the user count scales up, the strain on the backend becomes evident. By systematically fetching data and storing it locally, we reduce the demand on the server, making the application more scalable and responsive.
- Enhanced User Experience: Waiting for data to load is a common frustration for users. The Paging Library addresses this by ensuring that users don’t have to endure lengthy loading times. Data stored in the local Room database is readily available, resulting in a faster, more seamless user experience.
- Optimized Network Connectivity: In addition to alleviating server strain, the library enhances network efficiency. By storing data locally, the application minimizes the need for frequent network requests. This not only conserves bandwidth but also ensures a smoother experience for users, especially in areas with limited connectivity.
- Offline Accessibility: One of the most significant advantages of employing Room for data storage is the potential for complete offline functionality. In the event of server downtime, users can continue to access data seamlessly from the local database.
Now that you’ve got a handle on how the application’s main interface looks like, a significant question arises: “How do you write unit tests for fetching and storing data in the Room Database”?
Database
For the purpose of testing, it’s a good practice to set up an in-memory version of your database. This approach offers several advantages, including:
- Isolation: In-memory databases allow you to keep your tests completely isolated from the actual data stored on the device. This ensures that your tests don’t accidentally affect or rely on real data.
- Speed: In-memory databases are considerably faster to work with in a testing context. They don’t involve disk I/O, which can be a performance bottleneck, making your tests more efficient.
- Predictability: With an in-memory database, you can easily set up a known state for your tests. You can pre-populate it with specific data to simulate various scenarios and ensure consistent and predictable test outcomes.
Here’s a simple example of how to create an in-memory database for your tests:
class CarTest {
private lateinit var carDao: CarDao
private lateinit var db: TestDatabase
// Set up the in-memory database before each test.
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
// Create an in-memory test database.
db = Room.inMemoryDatabaseBuilder(
context, TestDatabase::class.java).build()
carDao = db.getCarDao()
}
// Close and release the test database after each test.
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
}
Unit tests
Now, when it comes to testing, there are three types of returned values that I need to take into consideration:
1. PagedResource
PagedResource is a versatile data structure optimized for managing paged data within the Android Paging Library. Configured via the store function, it encapsulates key attributes, including a Flow for observing paged data, data refresh functions, and an indicator for data emptiness.
data class PagedResource<Type, QueryParameters> (
// A Flow of paged data, ready for UI observation.
val resource: Flow<PagingData<Type>>,
// Initiates a full data refresh from the source.
val refresh: (QueryParameters) -> Unit,
// Enforces a complete data refresh, regardless of the page size configuration.
val refreshAll: suspend (QueryParameters) -> Unit,
// Indicates if the paged list is currently empty.
val isEmpty: Flow<Boolean>
) where Type: Any, Type: Identifiable
To ensure the data in UI aligns with expectations, you can make use of the paging-testing dependency along with the asSnapShot()
extension for PagedResource flows. This extension equips you with convenient APIs within a lambda receiver, enabling you to simulate interactions like refreshing or scrolling. As these interactions generate a list of Values, you can confidently confirm that your UI is operating as intended.
There are other layer tests available, as can be found here. In our project, however, we’ve found that concentrating on UI layer testing suffices already, following this approach:
fun shouldFetchAllCars() = runTest {
// Logic function for remote and local calls
val call: PagedResource<Car, Unit> = store.cars.all(pageSize = 10)
val itemsSnapshot: List<Car> = call.resource.asSnapshot {
refresh()
}
// With the asSnapshot complete, you can now verify that the snapshot
// has the expected values
Assert.assertFalse("No cars were fetched", itemsSnapshot.isEmpty())
}
2. ListResource
ListResource is a data structure and utility designed to efficiently manage and observe non-paginated lists of data within your application. It provides a Flow of lists, making it ideal for scenarios where data doesn’t need to be retrieved in pages.
In contrast to PagedResource, which is tailored for managing paged data using the Paging Library, ListResource focuses on non-paginated lists. The key distinctions include the type of data they handle, with ListResource dealing with lists of data List<Type>
, while PagedResource works with paged data via PagingData<Type>
. Moreover, ListResource introduces the networkState feature to track network request statuses, which isn’t present in PagedResource.
data class ListResource<Type, QueryParameters>(
// A Flow of lists, ready for UI observation.
val resource: Flow<List<Type>>,
// A Flow representing network request status, providing user feedback.
val networkState: Flow<NetworkState>,
// A function to trigger a complete data refresh from the source.
val refresh: (QueryParameters) -> Unit
) where Type: Any, Type: Identifiable
UnconfinedTestDispatcher
A specialized dispatcher used in testing scenarios with the Kotlin Coroutines library. Some key features of it includes:
- Similar to
Dispatchers.Unconfined
but skips delays for predictability. - Convenient for testing where specific thread management isn’t required.
- Ensures prompt execution of
launch
andasync
blocks inrunTest
. - Valuable for quickly launching and resuming child coroutines in tests, especially for Channel and StateFlow.
💡 Note: UnconfinedTestDispatcher is designed for testing functionality with predictable execution order, simplifying scenarios where precise control isn’t necessary. For further details, you can find additional information here.
In the context of writing unit tests, the UnconfinedTestDispatcher
is a valuable tool for integration with the Paging Library. The test scenario involves calling a function to retrieve a list of values from store
, with the fetched data being observed using a CompletableDeferred
.
fun shouldFetchCarsWithoutPaging() = runTest {
// Logic function for remote and local calls
val call = hybridStore.cars().allList(pageSize = 10)
val deferred = CompletableDeferred<List<Car>>()
// Launch a coroutine to collect and complete data for testing.
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
call.resource.collect { list ->
deferred.complete(list)
}
}
// Wait for the data to be fetched and cancel the job when done.
val cars = deferred.await()
job.cancel()
Assert.assertFalse("No cars were fetched", cars.isEmpty())
}
3. SingleResource
SingleResource serves as a versatile utility for handling and observing individual pieces of data. It offers a Flow that provides access to a single data item, which can be efficiently observed by the user interface. Additionally, it optionally includes a Flow for remote data, which can be valuable in cases where local caching is not needed or desired.
In comparison to ListResource, SingleResource is basically the same but tailored for scenarios where you’re working with single pieces of data rather than lists. While ListResource handles Flow of list, SingleResource provides a Flow of a single data item.
data class SingleResource<Type, QueryParameters>(
// A Flow for observing a single data item.
val resource: Flow<Type?>,
// Optional Flow for remote data when local caching is unnecessary.
val remoteResource: Flow<Type?>?,
// Network request status for user feedback.
val networkState: Flow<NetworkState>,
// Function to trigger a full data refresh from the source.
val refresh: (QueryParameters) -> Unit
) where Type: Any
Creating unit tests for SingleResource follows the same approach as that for ListResource, with the only distinction being in using CompletableDeferred
for a single value instead of a list:
fun shouldFetchCarById() = runTest {
// Logic function for remote and local calls
val call = store.cars().car(id= 15, pageSize = 10)
val deferred = CompletableDeferred<Car?>()
// Launch a coroutine to collect and complete data for testing.
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
call.resource.first()
call.resource.collect { it ->
deferred.complete(it)
}
}
// Wait for the car data to be fetched and cancel the job when done.
val car = deferred.await()
job.cancel()
Assert.assertNotNull("No car was fetched", car)
}
Conclusion
I hope that this article has proven to be a valuable resource for your unit testing trials. While it may not encompass every conceivable testing scenario, it should have provided you with a solid grasp of the fundamentals. Armed with this newfound knowledge, you’ll find that writing unit tests becomes a more approachable and less daunting aspect of your development work.